Skip to content

Commit a2a66e2

Browse files
authored
feat: Reference schema support (#800) (#1307)
This PR adds support for reference schemas (#800). The idea with this change is that during parsing of the schemas, when the parser encounters a `Reference` schema, rather than aborting it will attempt to fully resolve that reference by following the chain of references until it encounters a `Schema`. When it does, it will attempt to fetch that parsed `Schema` from the dictionary of available components, which should include all possible referent schemas. If it exists, it will parse the current/original reference Schema as if it was its ultimate referent. If it reaches a reference that does not exist, it logs an error and moves on: it won't be able to parse this `Reference`. The result of this is that every reference schema in the API doc will result in a separate-but-identical class, aside from the class name. This feels like a safe, basic way to handle the situation, since collapsing identical classes might break compatibility between the API and the codegen at some point. I suppose we could instead have references be subclasses or some trick like that to cut down on code duplication and let identical API objects be equivalent? If that would be preferable it is probably doable downstream of this change, since with this parsing the `Schema` objects added as references in the `Schemas` class should be identical objects which could be identified and collapsed in some fashion during code templating. Perhaps it would be useful to record the direction of references during parsing to better choose what a base class would be... That would require some more thought. In any case, this works both for single references as well as arbitrarily deep nested references, something useful for the API I'm developing for. Also includes some functional tests which include both a valid and invalid (circular reference) case for reference schemas and adjusts an existing unit test which assumed that the reference schema would be skipped to one that tests whether it has been parsed.
1 parent df1a83f commit a2a66e2

File tree

4 files changed

+124
-22
lines changed

4 files changed

+124
-22
lines changed

end_to_end_tests/functional_tests/generated_code_execution/test_properties.py

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313

1414
@with_generated_client_fixture(
15-
"""
15+
"""
1616
components:
1717
schemas:
1818
MyModel:
@@ -29,7 +29,8 @@
2929
properties:
3030
req3: {"type": "string"}
3131
required: ["req3"]
32-
""")
32+
"""
33+
)
3334
@with_generated_code_imports(
3435
".models.MyModel",
3536
".models.DerivedModel",
@@ -74,7 +75,7 @@ def test_type_hints(self, MyModel, Unset):
7475

7576

7677
@with_generated_client_fixture(
77-
"""
78+
"""
7879
components:
7980
schemas:
8081
MyModel:
@@ -89,7 +90,8 @@ def test_type_hints(self, MyModel, Unset):
8990
anyProp: {}
9091
AnyObject:
9192
type: object
92-
""")
93+
"""
94+
)
9395
@with_generated_code_imports(
9496
".models.MyModel",
9597
".models.AnyObject",
@@ -104,7 +106,7 @@ def test_decode_encode(self, MyModel, AnyObject):
104106
"intProp": 2,
105107
"anyObjectProp": {"d": 3},
106108
"nullProp": None,
107-
"anyProp": "e"
109+
"anyProp": "e",
108110
}
109111
expected_any_object = AnyObject()
110112
expected_any_object.additional_properties = {"d": 3}
@@ -116,10 +118,10 @@ def test_decode_encode(self, MyModel, AnyObject):
116118
string_prop="a",
117119
number_prop=1.5,
118120
int_prop=2,
119-
any_object_prop = expected_any_object,
121+
any_object_prop=expected_any_object,
120122
null_prop=None,
121123
any_prop="e",
122-
)
124+
),
123125
)
124126

125127
@pytest.mark.parametrize(
@@ -144,7 +146,7 @@ def test_type_hints(self, MyModel, Unset):
144146

145147

146148
@with_generated_client_fixture(
147-
"""
149+
"""
148150
components:
149151
schemas:
150152
MyModel:
@@ -154,7 +156,8 @@ def test_type_hints(self, MyModel, Unset):
154156
dateTimeProp: {"type": "string", "format": "date-time"}
155157
uuidProp: {"type": "string", "format": "uuid"}
156158
unknownFormatProp: {"type": "string", "format": "weird"}
157-
""")
159+
"""
160+
)
158161
@with_generated_code_imports(
159162
".models.MyModel",
160163
".types.Unset",
@@ -184,3 +187,59 @@ def test_type_hints(self, MyModel, Unset):
184187
assert_model_property_type_hint(MyModel, "date_time_prop", Union[datetime.datetime, Unset])
185188
assert_model_property_type_hint(MyModel, "uuid_prop", Union[uuid.UUID, Unset])
186189
assert_model_property_type_hint(MyModel, "unknown_format_prop", Union[str, Unset])
190+
191+
192+
@with_generated_client_fixture(
193+
"""
194+
components:
195+
schemas:
196+
MyModel:
197+
type: object
198+
properties:
199+
booleanProp: {"type": "boolean"}
200+
stringProp: {"type": "string"}
201+
numberProp: {"type": "number"}
202+
intProp: {"type": "integer"}
203+
anyObjectProp: {"$ref": "#/components/schemas/AnyObject"}
204+
nullProp: {"type": "null"}
205+
anyProp: {}
206+
AnyObject:
207+
$ref: "#/components/schemas/OtherObject"
208+
OtherObject:
209+
$ref: "#/components/schemas/AnotherObject"
210+
AnotherObject:
211+
type: object
212+
properties:
213+
booleanProp: {"type": "boolean"}
214+
215+
"""
216+
)
217+
@with_generated_code_imports(
218+
".models.MyModel",
219+
".models.AnyObject",
220+
".types.Unset",
221+
)
222+
class TestReferenceSchemaProperties:
223+
def test_decode_encode(self, MyModel, AnyObject):
224+
json_data = {
225+
"booleanProp": True,
226+
"stringProp": "a",
227+
"numberProp": 1.5,
228+
"intProp": 2,
229+
"anyObjectProp": {"booleanProp": False},
230+
"nullProp": None,
231+
"anyProp": "e",
232+
}
233+
assert_model_decode_encode(
234+
MyModel,
235+
json_data,
236+
MyModel(
237+
boolean_prop=True,
238+
string_prop="a",
239+
number_prop=1.5,
240+
int_prop=2,
241+
any_object_prop=AnyObject(boolean_prop=False),
242+
null_prop=None,
243+
any_prop="e",
244+
),
245+
)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import pytest
2+
3+
from end_to_end_tests.functional_tests.helpers import assert_bad_schema, with_generated_client_fixture
4+
5+
6+
@with_generated_client_fixture(
7+
"""
8+
components:
9+
schemas:
10+
MyModel:
11+
type: object
12+
properties:
13+
booleanProp: {"type": "boolean"}
14+
stringProp: {"type": "string"}
15+
numberProp: {"type": "number"}
16+
intProp: {"type": "integer"}
17+
anyObjectProp: {"$ref": "#/components/schemas/AnyObject"}
18+
nullProp: {"type": "null"}
19+
anyProp: {}
20+
AnyObject:
21+
$ref: "#/components/schemas/OtherObject"
22+
OtherObject:
23+
$ref: "#/components/schemas/AnyObject"
24+
25+
"""
26+
)
27+
class TestReferenceSchemaProperties:
28+
def test_decode_encode(self, generated_client):
29+
assert "Circular schema references found" in generated_client.generator_result.stdout

openapi_python_client/parser/properties/__init__.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
Parameters,
4141
ReferencePath,
4242
Schemas,
43+
get_reference_simple_name,
4344
parse_reference_path,
4445
update_parameters_with_data,
4546
update_schemas_with_data,
@@ -324,17 +325,30 @@ def _create_schemas(
324325
while still_making_progress:
325326
still_making_progress = False
326327
errors = []
327-
next_round = []
328+
next_round: list[tuple[str, oai.Reference | oai.Schema]] = []
328329
# Only accumulate errors from the last round, since we might fix some along the way
329330
for name, data in to_process:
330-
if isinstance(data, oai.Reference):
331-
schemas.errors.append(PropertyError(data=data, detail="Reference schemas are not supported."))
332-
continue
331+
schema_data: oai.Reference | oai.Schema | None = data
333332
ref_path = parse_reference_path(f"#/components/schemas/{name}")
334333
if isinstance(ref_path, ParseError):
335334
schemas.errors.append(PropertyError(detail=ref_path.detail, data=data))
336335
continue
337-
schemas_or_err = update_schemas_with_data(ref_path=ref_path, data=data, schemas=schemas, config=config)
336+
if isinstance(data, oai.Reference):
337+
# Fully dereference reference schemas
338+
seen = [name]
339+
while isinstance(schema_data, oai.Reference):
340+
data_ref_schema = get_reference_simple_name(schema_data.ref)
341+
if data_ref_schema in seen:
342+
schemas.errors.append(PropertyError(detail="Circular schema references found", data=data))
343+
break
344+
# use derefenced schema definition for this schema
345+
schema_data = components.get(data_ref_schema)
346+
if isinstance(schema_data, oai.Schema):
347+
schemas_or_err = update_schemas_with_data(
348+
ref_path=ref_path, data=schema_data, schemas=schemas, config=config
349+
)
350+
else:
351+
schemas.errors.append(PropertyError(detail="Referent schema not found", data=data))
338352
if isinstance(schemas_or_err, PropertyError):
339353
next_round.append((name, data))
340354
errors.append(schemas_or_err)

tests/test_parser/test_properties/test_init.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -235,22 +235,22 @@ def test__string_based_property_binary_format(self, file_property_factory, confi
235235

236236

237237
class TestCreateSchemas:
238-
def test_skips_references_and_keeps_going(self, mocker, config):
239-
components = {"a_ref": Reference.model_construct(), "a_schema": Schema.model_construct()}
238+
def test_dereference_references(self, mocker, config):
239+
components = {"a_ref": Reference(ref="#/components/schemas/a_schema"), "a_schema": Schema.model_construct()}
240240
update_schemas_with_data = mocker.patch(f"{MODULE_NAME}.update_schemas_with_data")
241241
parse_reference_path = mocker.patch(f"{MODULE_NAME}.parse_reference_path")
242242
schemas = Schemas()
243243

244244
result = _create_schemas(components=components, schemas=schemas, config=config)
245-
# Should not even try to parse a path for the Reference
246-
parse_reference_path.assert_called_once_with("#/components/schemas/a_schema")
247-
update_schemas_with_data.assert_called_once_with(
245+
246+
parse_reference_path.assert_has_calls(
247+
[call("#/components/schemas/a_ref"), call("#/components/schemas/a_schema")]
248+
)
249+
update_schemas_with_data.assert_called_with(
248250
ref_path=parse_reference_path.return_value,
249251
config=config,
250252
data=components["a_schema"],
251-
schemas=Schemas(
252-
errors=[PropertyError(detail="Reference schemas are not supported.", data=components["a_ref"])]
253-
),
253+
schemas=result,
254254
)
255255
assert result == update_schemas_with_data.return_value
256256

0 commit comments

Comments
 (0)