Skip to content
This repository was archived by the owner on Nov 19, 2023. It is now read-only.

Commit 21d7eca

Browse files
authored
Merge pull request #229 from snok/fix/208-test-indeterminancy
fix/208: updated handling of anyOf
2 parents 43db85e + b11ea6a commit 21d7eca

File tree

6 files changed

+49
-85
lines changed

6 files changed

+49
-85
lines changed

openapi_tester/schema_tester.py

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
""" Schema Tester """
2-
from functools import reduce
2+
from itertools import chain
33
from typing import Any, Callable, Dict, List, Optional, Union, cast
44

55
from django.conf import settings
@@ -19,7 +19,7 @@
1919
)
2020
from openapi_tester.exceptions import DocumentationError, UndocumentedSchemaSectionError
2121
from openapi_tester.loaders import DrfSpectacularSchemaLoader, DrfYasgSchemaLoader, StaticSchemaLoader
22-
from openapi_tester.utils import combine_sub_schemas, merge_objects
22+
from openapi_tester.utils import lazy_combinations, normalize_schema_section
2323
from openapi_tester.validators import (
2424
validate_enum,
2525
validate_format,
@@ -152,15 +152,6 @@ def get_response_schema_section(self, response: td.Response) -> Dict[str, Any]:
152152
)
153153
return self.get_key_value(json_object, "schema")
154154

155-
def handle_all_of(self, schema_section: dict, data: Any, reference: str, **kwargs: Any) -> None:
156-
all_of = schema_section.pop("allOf")
157-
self.test_schema_section(
158-
schema_section={**schema_section, **combine_sub_schemas(all_of)},
159-
data=data,
160-
reference=f"{reference}.allOf",
161-
**kwargs,
162-
)
163-
164155
def handle_one_of(self, schema_section: dict, data: Any, reference: str, **kwargs: Any):
165156
matches = 0
166157
for option in schema_section["oneOf"]:
@@ -174,12 +165,7 @@ def handle_one_of(self, schema_section: dict, data: Any, reference: str, **kwarg
174165

175166
def handle_any_of(self, schema_section: dict, data: Any, reference: str, **kwargs: Any):
176167
any_of: List[Dict[str, Any]] = schema_section.get("anyOf", [])
177-
combined_sub_schemas = map(
178-
lambda index: reduce(lambda x, y: combine_sub_schemas([x, y]), any_of[index:]),
179-
range(len(any_of)),
180-
)
181-
182-
for schema in [*any_of, *combined_sub_schemas]:
168+
for schema in chain(any_of, lazy_combinations(any_of)):
183169
try:
184170
self.test_schema_section(schema_section=schema, data=data, reference=f"{reference}.anyOf", **kwargs)
185171
return
@@ -236,17 +222,9 @@ def test_schema_section(
236222
f"Reference: {reference}\n\n"
237223
f"Hint: Return a valid type, or document the value as nullable"
238224
)
239-
225+
schema_section = normalize_schema_section(schema_section)
240226
if "oneOf" in schema_section:
241-
if schema_section["oneOf"] and all(item.get("enum") for item in schema_section["oneOf"]):
242-
# handle the way drf-spectacular is doing enums
243-
one_of = schema_section.pop("oneOf")
244-
schema_section = {**schema_section, **merge_objects(one_of)}
245-
else:
246-
self.handle_one_of(schema_section=schema_section, data=data, reference=reference, **kwargs)
247-
return
248-
if "allOf" in schema_section:
249-
self.handle_all_of(schema_section=schema_section, data=data, reference=reference, **kwargs)
227+
self.handle_one_of(schema_section=schema_section, data=data, reference=reference, **kwargs)
250228
return
251229
if "anyOf" in schema_section:
252230
self.handle_any_of(schema_section=schema_section, data=data, reference=reference, **kwargs)

openapi_tester/utils.py

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,47 @@
11
""" Utils Module - this file contains utility functions used in multiple places """
2-
from typing import Any, Dict, Iterable, List
2+
from copy import deepcopy
3+
from itertools import chain, combinations
4+
from typing import Any, Dict, Iterator, Sequence
35

46

5-
def merge_objects(dictionaries: List[Dict[str, Any]]) -> Dict[str, Any]:
7+
def merge_objects(dictionaries: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
68
""" helper function to deep merge objects """
79
output: Dict[str, Any] = {}
810
for dictionary in dictionaries:
911
for key, value in dictionary.items():
10-
if isinstance(value, dict) and "allOf" in value:
11-
all_of = merge_objects(value.pop("allOf"))
12-
value = merge_objects([value, all_of])
1312
if key not in output:
1413
output[key] = value
1514
continue
1615
current_value = output[key]
1716
if isinstance(current_value, list) and isinstance(value, list):
18-
output[key] = list({*output[key], *value})
17+
output[key] = list(chain(output[key], value))
18+
continue
1919
if isinstance(current_value, dict) and isinstance(value, dict):
2020
output[key] = merge_objects([current_value, value])
21+
continue
2122
return output
2223

2324

24-
def combine_object_schemas(schemas: List[dict]) -> Dict[str, Any]:
25-
properties = merge_objects([schema.get("properties", {}) for schema in schemas])
26-
required_list = [schema.get("required", []) for schema in schemas]
27-
required = list({key for required in required_list for key in required})
28-
return {"type": "object", "required": required, "properties": properties}
25+
def normalize_schema_section(schema_section: dict) -> dict:
26+
""" helper method to remove allOf and handle edge uses of oneOf"""
27+
output: Dict[str, Any] = deepcopy(schema_section)
28+
if output.get("allOf"):
29+
all_of = output.pop("allOf")
30+
output = {**output, **merge_objects(all_of)}
31+
if output.get("oneOf") and all(item.get("enum") for item in output["oneOf"]):
32+
# handle the way drf-spectacular is doing enums
33+
one_of = output.pop("oneOf")
34+
output = {**output, **merge_objects(one_of)}
35+
for key, value in output.items():
36+
if isinstance(value, dict):
37+
output[key] = normalize_schema_section(value)
38+
elif isinstance(value, list):
39+
output[key] = [normalize_schema_section(entry) if isinstance(entry, dict) else entry for entry in value]
40+
return output
2941

3042

31-
def combine_sub_schemas(schemas: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
32-
array_schemas = [schema for schema in schemas if schema.get("type") == "array"]
33-
object_schemas = [schema for schema in schemas if schema.get("type") == "object" or not schema.get("type")]
34-
if array_schemas:
35-
return {
36-
"type": "array",
37-
"items": combine_sub_schemas([schema.get("items", {}) for schema in array_schemas]),
38-
}
39-
if object_schemas:
40-
return combine_object_schemas(object_schemas)
41-
return merge_objects([schema for schema in schemas if schema.get("type") not in ["object", "array"]])
43+
def lazy_combinations(options_list: Sequence[Dict[str, Any]]) -> Iterator[dict]:
44+
""" helper to lazy evaluate possible permutations of possible combinations """
45+
for i in range(2, len(options_list) + 1):
46+
for combination in combinations(options_list, i):
47+
yield merge_objects(combination)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ max-branches = 20
9797
max-locals = 20
9898

9999
[tool.pylint.BASIC]
100-
good-names = "_,e"
100+
good-names = "_,e,i"
101101

102102
[tool.coverage.run]
103103
source = ["openapi_tester/*"]

tests/schema_converter.py

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
""" Schema to Python converter """
22
import base64
33
import random
4+
from copy import deepcopy
45
from datetime import datetime
56
from typing import Any, Dict, List, Optional, Union
67

78
from faker import Faker
89

9-
from openapi_tester.utils import combine_sub_schemas, merge_objects
10+
from openapi_tester.utils import merge_objects, normalize_schema_section
1011

1112

1213
class SchemaToPythonConverter:
@@ -20,27 +21,19 @@ class SchemaToPythonConverter:
2021
def __init__(self, schema: dict):
2122
Faker.seed(0)
2223
self.faker = Faker()
23-
self.result = self.convert_schema(schema)
24+
self.result = self.convert_schema(deepcopy(schema))
2425

2526
def convert_schema(self, schema: Dict[str, Any]) -> Any:
2627
schema_type = schema.get("type", "object")
27-
sample: List[Dict[str, Any]] = []
28-
if "allOf" in schema:
29-
all_of = schema.pop("allOf")
30-
return self.convert_schema({**schema, **combine_sub_schemas(all_of)})
28+
schema = normalize_schema_section(schema)
3129
if "oneOf" in schema:
3230
one_of = schema.pop("oneOf")
33-
if all(item.get("enum") for item in one_of):
34-
# this is meant to handle the way drf-spectacular does enums
35-
return self.convert_schema({**schema, **merge_objects(one_of)})
36-
while not sample:
37-
sample = random.sample(one_of, 1)
38-
return self.convert_schema({**schema, **sample[0]})
31+
return self.convert_schema({**schema, **random.sample(one_of, 1)[0]})
3932
if "anyOf" in schema:
4033
any_of = schema.pop("anyOf")
41-
while not sample:
42-
sample = random.sample(any_of, random.randint(1, len(any_of)))
43-
return self.convert_schema({**schema, **combine_sub_schemas(sample)})
34+
return self.convert_schema(
35+
{**schema, **merge_objects(random.sample(any_of, random.randint(1, len(any_of))))}
36+
)
4437
if schema_type == "array":
4538
return self.convert_schema_array_to_list(schema)
4639
if schema_type == "object":
@@ -80,9 +73,9 @@ def schema_type_to_mock_value(self, schema_object: Dict[str, Any]) -> Any:
8073
return random.sample(enum, 1)[0]
8174
if schema_type in ["integer", "number"] and (minimum is not None or maximum is not None):
8275
if minimum is not None:
83-
minimum += 1 if schema_object.get("excludeMinimum") else 0
76+
minimum += 1 if schema_object.get("exclusiveMinimum") else 0
8477
if maximum is not None:
85-
maximum -= 1 if schema_object.get("excludeMaximum") else 0
78+
maximum -= 1 if schema_object.get("exclusiveMaximum") else 0
8679
if minimum is not None or maximum is not None:
8780
minimum = minimum or 0
8881
maximum = maximum or minimum * 2

tests/schemas/one_of_any_of_test_schema.yaml renamed to tests/schemas/any_of_one_of_test_schema.yaml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,9 @@ components:
104104
type: string
105105
format: byte
106106
price:
107-
minimum: 0.1
108-
maximum: 1.0
109-
type: number
110-
format: float
107+
minimum: 0
108+
maximum: 10
109+
type: integer
111110

112111
Alien:
113112
type: object

tests/test_utils.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from openapi_tester.utils import combine_sub_schemas, merge_objects
1+
from openapi_tester.utils import merge_objects
22
from tests.utils import sort_object
33

44
object_1 = {"type": "object", "required": ["key1"], "properties": {"key1": {"type": "string"}}}
@@ -19,26 +19,14 @@ def test_documentation_error_sort_data_type():
1919
assert sort_object(["1", {}, []]) == ["1", {}, []]
2020

2121

22-
def test_combine_sub_schemas_array_list():
23-
test_schemas = [{"type": "array", "items": {"type": "string"}}, {"type": "array", "items": {"type": "integer"}}]
24-
expected = {"type": "array", "items": {"type": "string"}}
25-
assert sort_object(combine_sub_schemas(test_schemas)) == sort_object(expected)
26-
27-
28-
def test_combine_sub_schemas_object_list():
29-
test_schemas = [object_1, object_2]
30-
assert sort_object(combine_sub_schemas(test_schemas)) == sort_object({**merged_object})
31-
32-
3322
def test_merge_objects():
3423
test_schemas = [
3524
object_1,
3625
object_2,
37-
{"type": "object", "properties": {"key3": {"allOf": [object_1, object_2]}}},
3826
]
3927
expected = {
4028
"type": "object",
4129
"required": ["key1", "key2"],
42-
"properties": {"key1": {"type": "string"}, "key2": {"type": "string"}, "key3": merged_object},
30+
"properties": {"key1": {"type": "string"}, "key2": {"type": "string"}},
4331
}
4432
assert sort_object(merge_objects(test_schemas)) == sort_object(expected)

0 commit comments

Comments
 (0)