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

Commit 3090f9a

Browse files
authored
Merge pull request #227 from snok/fix/224-nullable-enums-not-validating-correctly
fix/224: updated schema testing and conversion
2 parents 626f4c5 + 3ebd376 commit 3090f9a

File tree

12 files changed

+288
-76
lines changed

12 files changed

+288
-76
lines changed

openapi_tester/loaders.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
import pathlib
55
from json import dumps, loads
6-
from typing import Callable, Dict, List, Optional, Tuple
6+
from typing import Callable, Dict, List, Optional, Tuple, cast
77
from urllib.parse import ParseResult, urlparse
88

99
import yaml
@@ -18,6 +18,8 @@
1818
from rest_framework.schemas.generators import BaseSchemaGenerator, EndpointEnumerator
1919
from rest_framework.settings import api_settings
2020

21+
import openapi_tester.type_declarations as td
22+
2123

2224
def handle_recursion_limit(schema: dict) -> Callable:
2325
"""
@@ -46,6 +48,7 @@ class BaseSchemaLoader:
4648

4749
base_path = "/"
4850
field_key_map: Dict[str, str]
51+
schema: Optional[dict] = None
4952

5053
def __init__(self, field_key_map: Optional[Dict[str, str]] = None):
5154
super().__init__()
@@ -62,9 +65,10 @@ def get_schema(self) -> dict:
6265
"""
6366
Returns OpenAPI schema.
6467
"""
65-
if self.schema is None:
66-
self.set_schema(self.load_schema())
67-
return self.schema # type: ignore
68+
if self.schema:
69+
return self.schema
70+
self.set_schema(self.load_schema())
71+
return self.get_schema()
6872

6973
def de_reference_schema(self, schema: dict) -> dict:
7074
url = schema["basePath"] if "basePath" in schema else self.base_path
@@ -130,7 +134,7 @@ def resolve_path(self, endpoint_path: str, method: str) -> Tuple[str, ResolverMa
130134
else:
131135
for key, value in resolved_route.kwargs.items():
132136
index = path.rfind(str(value))
133-
path = f"{path[:index]}{{{key}}}{path[index + len(str(value)) :]}"
137+
path = f"{path[:index]}{{{key}}}{path[index + len(str(value)):]}"
134138
if "{pk}" in path and api_settings.SCHEMA_COERCE_PATH_PK:
135139
path, resolved_route = self.handle_pk_parameter(
136140
resolved_route=resolved_route, path=path, method=method
@@ -142,10 +146,14 @@ def resolve_path(self, endpoint_path: str, method: str) -> Tuple[str, ResolverMa
142146
message += "\n\nDid you mean one of these?" + "\n- ".join(close_matches)
143147
raise ValueError(message)
144148

145-
def handle_pk_parameter( # pylint: disable=no-self-use
146-
self, resolved_route: ResolverMatch, path: str, method: str
147-
) -> Tuple[str, ResolverMatch]:
148-
coerced_path = BaseSchemaGenerator().coerce_path(path=path, method=method, view=resolved_route.func) # type: ignore
149+
@staticmethod
150+
def handle_pk_parameter(resolved_route: ResolverMatch, path: str, method: str) -> Tuple[str, ResolverMatch]:
151+
"""
152+
Handle the DRF conversion of params called {pk} into a named parameter based on Model field
153+
"""
154+
coerced_path = BaseSchemaGenerator().coerce_path(
155+
path=path, method=method, view=cast(td.APIView, resolved_route.func)
156+
)
149157
pk_field_name = "".join(
150158
entry.replace("+ ", "") for entry in difflib.Differ().compare(path, coerced_path) if "+ " in entry
151159
)

openapi_tester/schema_tester.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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
22+
from openapi_tester.utils import combine_sub_schemas, merge_objects
2323
from openapi_tester.validators import (
2424
validate_enum,
2525
validate_format,
@@ -238,8 +238,13 @@ def test_schema_section(
238238
)
239239

240240
if "oneOf" in schema_section:
241-
self.handle_one_of(schema_section=schema_section, data=data, reference=reference, **kwargs)
242-
return
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
243248
if "allOf" in schema_section:
244249
self.handle_all_of(schema_section=schema_section, data=data, reference=reference, **kwargs)
245250
return

openapi_tester/type_declarations.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,8 @@ def export_anything(cls, module_name: str) -> None:
4444
# noinspection PyUnresolvedReferences
4545
from rest_framework.test import APITestCase
4646

47+
# noinspection PyUnresolvedReferences
48+
from rest_framework.views import APIView
49+
4750
# noinspection PyUnresolvedReferences
4851
from openapi_tester.loaders import BaseSchemaLoader, StaticSchemaLoader

openapi_tester/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ def combine_sub_schemas(schemas: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
3838
}
3939
if object_schemas:
4040
return combine_object_schemas(object_schemas)
41-
return merge_objects([schema for schema in schemas if schema.get("type") not in ["object", "array", None]])
41+
return merge_objects([schema for schema in schemas if schema.get("type") not in ["object", "array"]])

test_project/api/views/names.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
from rest_framework import serializers
12
from rest_framework.generics import RetrieveAPIView
2-
from rest_framework.serializers import ModelSerializer
33
from rest_framework.viewsets import ReadOnlyModelViewSet
44

55
from test_project.models import Names
66

77

8-
class NamesSerializer(ModelSerializer):
8+
class NamesSerializer(serializers.ModelSerializer):
99
class Meta:
1010
model = Names
1111
fields = "__all__"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 3.1.6 on 2021-02-26 17:36
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("test_project", "0001_initial"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="names",
15+
name="name",
16+
field=models.CharField(
17+
blank=True,
18+
choices=[("mo", "Moses"), ("moi", "Moishe"), ("mu", "Mush")],
19+
default=None,
20+
max_length=254,
21+
null=True,
22+
),
23+
),
24+
]

test_project/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33

44
class Names(models.Model):
55
custom_id_field = models.IntegerField(primary_key=True)
6+
name = models.CharField(
7+
max_length=254,
8+
choices=(("mo", "Moses"), ("moi", "Moishe"), ("mu", "Mush")),
9+
default=None,
10+
null=True,
11+
blank=True,
12+
)
613

714
class Meta:
815
app_label = "test_project"

tests/schema_converter.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from faker import Faker
88

9-
from openapi_tester.utils import combine_sub_schemas
9+
from openapi_tester.utils import combine_sub_schemas, merge_objects
1010

1111

1212
class SchemaToPythonConverter:
@@ -26,15 +26,21 @@ def convert_schema(self, schema: Dict[str, Any]) -> Any:
2626
schema_type = schema.get("type", "object")
2727
sample: List[Dict[str, Any]] = []
2828
if "allOf" in schema:
29-
return self.convert_schema(combine_sub_schemas(schema["allOf"]))
29+
all_of = schema.pop("allOf")
30+
return self.convert_schema({**schema, **combine_sub_schemas(all_of)})
3031
if "oneOf" in schema:
32+
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)})
3136
while not sample:
32-
sample = random.sample(schema["oneOf"], 1)
33-
return self.convert_schema(sample[0])
37+
sample = random.sample(one_of, 1)
38+
return self.convert_schema({**schema, **sample[0]})
3439
if "anyOf" in schema:
40+
any_of = schema.pop("anyOf")
3541
while not sample:
36-
sample = random.sample(schema["anyOf"], random.randint(1, len(schema["anyOf"])))
37-
return self.convert_schema(combine_sub_schemas(sample))
42+
sample = random.sample(any_of, random.randint(1, len(any_of)))
43+
return self.convert_schema({**schema, **combine_sub_schemas(sample)})
3844
if schema_type == "array":
3945
return self.convert_schema_array_to_list(schema)
4046
if schema_type == "object":
@@ -71,7 +77,7 @@ def schema_type_to_mock_value(self, schema_object: Dict[str, Any]) -> Any:
7177
maximum: Optional[Union[int, float]] = schema_object.get("maximum")
7278
enum: Optional[list] = schema_object.get("enum")
7379
if enum:
74-
return enum[0]
80+
return random.sample(enum, 1)[0]
7581
if schema_type in ["integer", "number"] and (minimum is not None or maximum is not None):
7682
if minimum is not None:
7783
minimum += 1 if schema_object.get("excludeMinimum") else 0
@@ -98,9 +104,9 @@ def convert_schema_object_to_dict(self, schema_object: dict) -> Dict[str, Any]:
98104

99105
def convert_schema_array_to_list(self, schema_array: Any) -> List[Any]:
100106
parsed_items: List[Any] = []
101-
raw_items = schema_array.get("items", {})
107+
items = self.convert_schema(schema_array.get("items", {}))
102108
min_items = schema_array.get("minItems", 1)
103109
max_items = schema_array.get("maxItems", 1)
104110
while len(parsed_items) < min_items or len(parsed_items) < max_items:
105-
parsed_items.append(self.convert_schema(raw_items))
111+
parsed_items.append(items)
106112
return parsed_items

tests/schemas/spectactular_reference_schema.json

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,74 @@
289289
}
290290
}
291291
},
292+
"/api/v1/router_generated/names/": {
293+
"get": {
294+
"operationId": "api_v1_router_generated_names_list",
295+
"description": "",
296+
"tags": [
297+
"api"
298+
],
299+
"security": [
300+
{
301+
"cookieAuth": []
302+
},
303+
{}
304+
],
305+
"responses": {
306+
"200": {
307+
"content": {
308+
"application/json": {
309+
"schema": {
310+
"type": "array",
311+
"items": {
312+
"$ref": "#/components/schemas/Names"
313+
}
314+
}
315+
}
316+
},
317+
"description": ""
318+
}
319+
}
320+
}
321+
},
322+
"/api/v1/router_generated/names/{custom_id_field}/": {
323+
"get": {
324+
"operationId": "api_v1_router_generated_names_retrieve",
325+
"description": "",
326+
"parameters": [
327+
{
328+
"in": "path",
329+
"name": "custom_id_field",
330+
"schema": {
331+
"type": "integer"
332+
},
333+
"description": "A unique value identifying this names.",
334+
"required": true
335+
}
336+
],
337+
"tags": [
338+
"api"
339+
],
340+
"security": [
341+
{
342+
"cookieAuth": []
343+
},
344+
{}
345+
],
346+
"responses": {
347+
"200": {
348+
"content": {
349+
"application/json": {
350+
"schema": {
351+
"$ref": "#/components/schemas/Names"
352+
}
353+
}
354+
},
355+
"description": ""
356+
}
357+
}
358+
}
359+
},
292360
"/api/v1/snake-case/": {
293361
"get": {
294362
"operationId": "api_v1_snake_case_list",
@@ -530,6 +598,11 @@
530598
},
531599
"components": {
532600
"schemas": {
601+
"BlankEnum": {
602+
"enum": [
603+
""
604+
]
605+
},
533606
"Car": {
534607
"type": "object",
535608
"properties": {
@@ -562,17 +635,44 @@
562635
"width"
563636
]
564637
},
638+
"NameEnum": {
639+
"enum": [
640+
"mo",
641+
"moi",
642+
"mu"
643+
],
644+
"type": "string"
645+
},
565646
"Names": {
566647
"type": "object",
567648
"properties": {
568649
"custom_id_field": {
569650
"type": "integer"
651+
},
652+
"name": {
653+
"nullable": true,
654+
"oneOf": [
655+
{
656+
"$ref": "#/components/schemas/NameEnum"
657+
},
658+
{
659+
"$ref": "#/components/schemas/BlankEnum"
660+
},
661+
{
662+
"$ref": "#/components/schemas/NullEnum"
663+
}
664+
]
570665
}
571666
},
572667
"required": [
573668
"custom_id_field"
574669
]
575670
},
671+
"NullEnum": {
672+
"enum": [
673+
null
674+
]
675+
},
576676
"SnakeCase": {
577677
"type": "object",
578678
"properties": {

0 commit comments

Comments
 (0)