Skip to content
This repository has been archived by the owner on Aug 9, 2024. It is now read-only.

Commit

Permalink
Merge pull request #18 from MarketSquare/support_wider_range_of_json_…
Browse files Browse the repository at this point in the history
…property_names

Support wider range of json property names
  • Loading branch information
robinmackaij authored Jul 28, 2023
2 parents cfaddc2 + 67a054b commit aa7911a
Show file tree
Hide file tree
Showing 47 changed files with 2,404 additions and 1,760 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ venv.bak/
# mypy
.mypy_cache/

# ruff
.ruff_cache

.idea/

.dmypy.json
Expand Down
57 changes: 32 additions & 25 deletions docs/openapi_libcore.html

Large diffs are not rendered by default.

1,115 changes: 616 additions & 499 deletions poetry.lock

Large diffs are not rendered by default.

85 changes: 57 additions & 28 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name="robotframework-openapi-libcore"
version = "1.9.1"
version = "1.10.0"
description = "A Robot Framework library to facilitate library development for OpenAPI / Swagger APIs."
license = "Apache-2.0"
authors = ["Robin Mackaij <[email protected]>"]
Expand Down Expand Up @@ -31,18 +31,27 @@ rstr = "^3"
openapi-core = "^0.17.0"

[tool.poetry.group.dev.dependencies]
black = "*"
isort = "*"
pylint = "*"
mypy = "*"
types-requests = "^2.25.6"
invoke = "*"
robotframework-stacktrace = "*"
uvicorn = "*"
fastapi = ">=0.88"
coverage = {version = "^5.5", extras = ["toml"]}
robotframework-tidy = "^3.3.3"
robotframework-robocop = "^2.7.0"
fastapi = ">=0.95.0"
uvicorn = ">=0.22.0"
invoke = ">=2.0.0"
coverage = {version=">=7.2.5", extras = ["toml"]}
robotframework-stacktrace = ">=0.4.1"

[tool.poetry.group.formatting.dependencies]
black = ">=22.10.0"
isort = ">=5.10.1"
robotframework-tidy = ">=3.4.0"

[tool.poetry.group.type-checking.dependencies]
mypy = ">=1.2.0"
pyright = ">=1.1.300"
types-requests = ">=2.28.11"
types-invoke = ">=2.0.0.6"

[tool.poetry.group.linting.dependencies]
pylint = ">=2.17.2"
ruff = ">=0.0.267"
robotframework-robocop = ">=2.7.0"

[build-system]
requires = ["poetry-core>=1.0.0"]
Expand All @@ -60,18 +69,24 @@ exclude_lines = [
]

[tool.mypy]
show_error_codes = true
plugins = ["pydantic.mypy"]
warn_redundant_casts = true
warn_unused_ignores = true
disallow_any_generics = true
check_untyped_defs = true
plugins = "pydantic.mypy"
disallow_untyped_defs = true
strict = true
show_error_codes = true

[[tool.mypy.overrides]]
module = [
"DataDriver.*",
"prance.*",
"robot.*",
"openapi_core.*",
"invoke",
"OpenApiLibCore.*",
"uvicorn",
"rstr"
"invoke",
]
ignore_missing_imports = true

Expand All @@ -80,22 +95,36 @@ line-length = 88
target-version = ["py38"]

[tool.isort]
src_paths = [
"src"
]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
combine_as_imports = true
use_parentheses = true
ensure_newline_before_comments = true
line_length = 88
profile = "black"
py_version=38

[tool.ruff]
line-length = 120
select = ["E", "F", "PL"]
src = ["src/OpenApiDriver"]

[tool.pylint.'MESSAGES CONTROL']
disable = ["W1203"]
disable = ["logging-fstring-interpolation", "missing-class-docstring"]

[tool.pylint.'FORMAT CHECKER']
max-line-length=120

[tool.pylint.'SIMILARITIES CHECKER']
ignore-imports="yes"

[tool.robotidy]
line_length = 120
spacecount = 4

[tool.robocop]
filetypes = [".robot", ".resource"]
configure = [
"line-too-long:line_length:120",
"too-many-calls-in-test-case:max_calls:15"
]
exclude = [
"missing-doc-suite",
"missing-doc-test-case",
"missing-doc-keyword",
"too-few-calls-in-test-case"
]
24 changes: 18 additions & 6 deletions src/OpenApiLibCore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,29 @@
PropertyValueConstraint,
Relation,
UniquePropertyValueConstraint,
)
from OpenApiLibCore.dto_utils import DefaultDto
from OpenApiLibCore.openapi_libcore import (
OpenApiLibCore,
RequestData,
RequestValues,
resolve_schema,
)
from OpenApiLibCore.dto_utils import DefaultDto
from OpenApiLibCore.openapi_libcore import OpenApiLibCore, RequestData, RequestValues
from OpenApiLibCore.value_utils import IGNORE

try:
__version__ = version("robotframework-openapi-libcore")
except Exception: # pragma: no cover
pass

__all__ = [
"Dto",
"IdDependency",
"IdReference",
"PathPropertiesConstraint",
"PropertyValueConstraint",
"Relation",
"UniquePropertyValueConstraint",
"DefaultDto",
"OpenApiLibCore",
"RequestData",
"RequestValues",
"resolve_schema",
"IGNORE",
]
96 changes: 93 additions & 3 deletions src/OpenApiLibCore/dto_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
test and constraints / restrictions on properties of the resources.
"""
from abc import ABC
from dataclasses import asdict, dataclass
from copy import deepcopy
from dataclasses import dataclass, fields
from logging import getLogger
from random import shuffle
from random import choice, shuffle
from typing import Any, Dict, List, Optional, Union
from uuid import uuid4

Expand All @@ -18,6 +19,72 @@
SENTINEL = object()


def resolve_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
"""
Helper function to resolve allOf, anyOf and oneOf instances in a schema.
The schemas are used to generate values for headers, query parameters and json
bodies to be able to make requests.
"""
# Schema is mutable, so deepcopy to prevent mutation of original schema argument
resolved_schema = deepcopy(schema)

# allOf / anyOf / oneOf may be nested, so recursively resolve the dict-typed values
for key, value in resolved_schema.items():
if isinstance(value, dict):
resolved_schema[key] = resolve_schema(value)

# When handling allOf there should no duplicate keys, so the schema parts can
# just be merged after resolving the individual parts
if schema_parts := resolved_schema.pop("allOf", None):
for schema_part in schema_parts:
resolved_part = resolve_schema(schema_part)
resolved_schema = merge_schemas(resolved_schema, resolved_part)
# Handling anyOf and oneOf requires extra logic to deal with the "type" information.
# Some properties / parameters may be of different types and each type may have its
# own restrictions e.g. a parameter that accepts an enum value (string) or an
# integer value within a certain range.
# Since the library needs all this information for different purposes, the
# schema_parts cannot be merged, so a helper property / key "types" is introduced.
any_of = resolved_schema.pop("anyOf", [])
one_of = resolved_schema.pop("oneOf", [])
schema_parts = any_of if any_of else one_of

for schema_part in schema_parts:
resolved_part = resolve_schema(schema_part)
if isinstance(resolved_part, dict) and "type" in resolved_part.keys():
if "types" in resolved_schema.keys():
resolved_schema["types"].append(resolved_part)
else:
resolved_schema["types"] = [resolved_part]
else:
resolved_schema = merge_schemas(resolved_schema, resolved_part)

return resolved_schema


def merge_schemas(first: Dict[str, Any], second: Dict[str, Any]) -> Dict[str, Any]:
"""Helper method to merge two schemas, recursively."""
merged_schema = deepcopy(first)
for key, value in second.items():
# for existing keys, merge dict and list values, leave others unchanged
if key in merged_schema.keys():
if isinstance(value, dict):
# if the key holds a dict, merge the values (e.g. 'properties')
merged_schema[key].update(value)
elif isinstance(value, list):
# if the key holds a list, extend the values (e.g. 'required')
merged_schema[key].extend(value)
else:
logger.warning(
f"key '{key}' with value '{merged_schema[key]}' not "
f"updated to '{value}'"
)
else:
merged_schema[key] = value
return merged_schema


class ResourceRelation(ABC): # pylint: disable=too-few-public-methods
"""ABC for all resource relations or restrictions within the API."""

Expand Down Expand Up @@ -131,6 +198,8 @@ def get_invalidated_data(
"""Return a data set with one of the properties set to an invalid value or type."""
properties: Dict[str, Any] = self.as_dict()

schema = resolve_schema(schema)

relations = self.get_relations_for_error_code(error_code=status_code)
# filter PathProperyConstraints since in that case no data can be invalidated
relations = [
Expand Down Expand Up @@ -184,6 +253,18 @@ def get_invalidated_data(
return properties

value_schema = schema["properties"][property_name]
value_schema = resolve_schema(value_schema)

# Filter "type": "null" from the possible types since this indicates an
# optional / nullable property that can only be invalidated by sending
# invalid data of a non-null type
if value_schemas := value_schema.get("types"):
if len(value_schemas) > 1:
value_schemas = [
schema for schema in value_schemas if schema["type"] != "null"
]
value_schema = choice(value_schemas)

# there may not be a current_value when invalidating an optional property
current_value = properties.get(property_name, SENTINEL)
if current_value is SENTINEL:
Expand Down Expand Up @@ -220,4 +301,13 @@ def get_invalidated_data(

def as_dict(self) -> Dict[Any, Any]:
"""Return the dict representation of the Dto."""
return asdict(self)
result = {}

for field in fields(self):
field_name = field.name
if field_name not in self.__dict__:
continue
original_name = field.metadata["original_property_name"]
result[original_name] = getattr(self, field_name)

return result
20 changes: 17 additions & 3 deletions src/OpenApiLibCore/dto_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ class get_dto_class:
def __init__(self, mappings_module_name: str) -> None:
try:
mappings_module = import_module(mappings_module_name)
self.dto_mapping: Dict[Tuple[str, str], Type[Dto]] = mappings_module.DTO_MAPPING # type: ignore[attr-defined]
self.dto_mapping: Dict[
Tuple[str, str], Type[Dto]
] = mappings_module.DTO_MAPPING
except (ImportError, AttributeError, ValueError) as exception:
if mappings_module_name != "no mapping":
logger.error(f"DTO_MAPPING was not imported: {exception}")
Expand All @@ -53,13 +55,25 @@ class get_id_property_name:
def __init__(self, mappings_module_name: str) -> None:
try:
mappings_module = import_module(mappings_module_name)
self.id_mapping: Dict[str, Union[str, Tuple[str, Callable[[str], str]]]] = mappings_module.ID_MAPPING # type: ignore[attr-defined]
self.id_mapping: Dict[
str,
Union[
str,
Tuple[
str, Callable[[Union[str, int, float]], Union[str, int, float]]
],
],
] = mappings_module.ID_MAPPING
except (ImportError, AttributeError, ValueError) as exception:
if mappings_module_name != "no mapping":
logger.error(f"ID_MAPPING was not imported: {exception}")
self.id_mapping = {}

def __call__(self, endpoint: str) -> Union[str, Tuple[str, Callable[[str], str]]]:
def __call__(
self, endpoint: str
) -> Union[
str, Tuple[str, Callable[[Union[str, int, float]], Union[str, int, float]]]
]:
try:
return self.id_mapping[endpoint]
except KeyError:
Expand Down
Loading

0 comments on commit aa7911a

Please sign in to comment.