diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b2372aca..585ccdadb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `none` will not create a project folder at all, only the inner package folder (which won't be inner anymore) - Attempt to detect and alert users if they are using an unsupported version of OpenAPI (#281). - Fixes `Enum` deserialization when the value is `UNSET`. +- Basic support for `allOf` in models (#98) ### Changes diff --git a/end_to_end_tests/golden-record-custom/custom_e2e/models/__init__.py b/end_to_end_tests/golden-record-custom/custom_e2e/models/__init__.py index d3ca924b3..382ddc401 100644 --- a/end_to_end_tests/golden-record-custom/custom_e2e/models/__init__.py +++ b/end_to_end_tests/golden-record-custom/custom_e2e/models/__init__.py @@ -1,12 +1,15 @@ """ Contains all the data models used in inputs/outputs """ from .a_model import AModel +from .all_of_sub_model import AllOfSubModel from .an_enum import AnEnum from .an_int_enum import AnIntEnum +from .another_all_of_sub_model import AnotherAllOfSubModel from .body_upload_file_tests_upload_post import BodyUploadFileTestsUploadPost from .different_enum import DifferentEnum from .free_form_model import FreeFormModel from .http_validation_error import HTTPValidationError +from .model_from_all_of import ModelFromAllOf from .model_with_additional_properties_inlined import ModelWithAdditionalPropertiesInlined from .model_with_additional_properties_inlined_additional_property import ( ModelWithAdditionalPropertiesInlinedAdditionalProperty, diff --git a/end_to_end_tests/golden-record-custom/custom_e2e/models/all_of_sub_model.py b/end_to_end_tests/golden-record-custom/custom_e2e/models/all_of_sub_model.py new file mode 100644 index 000000000..baa59d06a --- /dev/null +++ b/end_to_end_tests/golden-record-custom/custom_e2e/models/all_of_sub_model.py @@ -0,0 +1,54 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +import attr + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="AllOfSubModel") + + +@attr.s(auto_attribs=True) +class AllOfSubModel: + """ """ + + a_sub_property: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + a_sub_property = self.a_sub_property + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if a_sub_property is not UNSET: + field_dict["a_sub_property"] = a_sub_property + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + a_sub_property = d.pop("a_sub_property", UNSET) + + all_of_sub_model = cls( + a_sub_property=a_sub_property, + ) + + all_of_sub_model.additional_properties = d + return all_of_sub_model + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/golden-record-custom/custom_e2e/models/another_all_of_sub_model.py b/end_to_end_tests/golden-record-custom/custom_e2e/models/another_all_of_sub_model.py new file mode 100644 index 000000000..3949852c6 --- /dev/null +++ b/end_to_end_tests/golden-record-custom/custom_e2e/models/another_all_of_sub_model.py @@ -0,0 +1,54 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +import attr + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="AnotherAllOfSubModel") + + +@attr.s(auto_attribs=True) +class AnotherAllOfSubModel: + """ """ + + another_sub_property: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + another_sub_property = self.another_sub_property + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if another_sub_property is not UNSET: + field_dict["another_sub_property"] = another_sub_property + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + another_sub_property = d.pop("another_sub_property", UNSET) + + another_all_of_sub_model = cls( + another_sub_property=another_sub_property, + ) + + another_all_of_sub_model.additional_properties = d + return another_all_of_sub_model + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/golden-record-custom/custom_e2e/models/model_from_all_of.py b/end_to_end_tests/golden-record-custom/custom_e2e/models/model_from_all_of.py new file mode 100644 index 000000000..9e9e87737 --- /dev/null +++ b/end_to_end_tests/golden-record-custom/custom_e2e/models/model_from_all_of.py @@ -0,0 +1,61 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +import attr + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ModelFromAllOf") + + +@attr.s(auto_attribs=True) +class ModelFromAllOf: + """ """ + + another_sub_property: Union[Unset, str] = UNSET + a_sub_property: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + another_sub_property = self.another_sub_property + a_sub_property = self.a_sub_property + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if another_sub_property is not UNSET: + field_dict["another_sub_property"] = another_sub_property + if a_sub_property is not UNSET: + field_dict["a_sub_property"] = a_sub_property + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + another_sub_property = d.pop("another_sub_property", UNSET) + + a_sub_property = d.pop("a_sub_property", UNSET) + + model_from_all_of = cls( + another_sub_property=another_sub_property, + a_sub_property=a_sub_property, + ) + + model_from_all_of.additional_properties = d + return model_from_all_of + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py b/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py index d3ca924b3..382ddc401 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py @@ -1,12 +1,15 @@ """ Contains all the data models used in inputs/outputs """ from .a_model import AModel +from .all_of_sub_model import AllOfSubModel from .an_enum import AnEnum from .an_int_enum import AnIntEnum +from .another_all_of_sub_model import AnotherAllOfSubModel from .body_upload_file_tests_upload_post import BodyUploadFileTestsUploadPost from .different_enum import DifferentEnum from .free_form_model import FreeFormModel from .http_validation_error import HTTPValidationError +from .model_from_all_of import ModelFromAllOf from .model_with_additional_properties_inlined import ModelWithAdditionalPropertiesInlined from .model_with_additional_properties_inlined_additional_property import ( ModelWithAdditionalPropertiesInlinedAdditionalProperty, diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/all_of_sub_model.py b/end_to_end_tests/golden-record/my_test_api_client/models/all_of_sub_model.py new file mode 100644 index 000000000..baa59d06a --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/all_of_sub_model.py @@ -0,0 +1,54 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +import attr + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="AllOfSubModel") + + +@attr.s(auto_attribs=True) +class AllOfSubModel: + """ """ + + a_sub_property: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + a_sub_property = self.a_sub_property + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if a_sub_property is not UNSET: + field_dict["a_sub_property"] = a_sub_property + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + a_sub_property = d.pop("a_sub_property", UNSET) + + all_of_sub_model = cls( + a_sub_property=a_sub_property, + ) + + all_of_sub_model.additional_properties = d + return all_of_sub_model + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/another_all_of_sub_model.py b/end_to_end_tests/golden-record/my_test_api_client/models/another_all_of_sub_model.py new file mode 100644 index 000000000..3949852c6 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/another_all_of_sub_model.py @@ -0,0 +1,54 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +import attr + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="AnotherAllOfSubModel") + + +@attr.s(auto_attribs=True) +class AnotherAllOfSubModel: + """ """ + + another_sub_property: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + another_sub_property = self.another_sub_property + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if another_sub_property is not UNSET: + field_dict["another_sub_property"] = another_sub_property + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + another_sub_property = d.pop("another_sub_property", UNSET) + + another_all_of_sub_model = cls( + another_sub_property=another_sub_property, + ) + + another_all_of_sub_model.additional_properties = d + return another_all_of_sub_model + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/model_from_all_of.py b/end_to_end_tests/golden-record/my_test_api_client/models/model_from_all_of.py new file mode 100644 index 000000000..9e9e87737 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/model_from_all_of.py @@ -0,0 +1,61 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +import attr + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ModelFromAllOf") + + +@attr.s(auto_attribs=True) +class ModelFromAllOf: + """ """ + + another_sub_property: Union[Unset, str] = UNSET + a_sub_property: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + another_sub_property = self.another_sub_property + a_sub_property = self.a_sub_property + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if another_sub_property is not UNSET: + field_dict["another_sub_property"] = another_sub_property + if a_sub_property is not UNSET: + field_dict["a_sub_property"] = a_sub_property + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + another_sub_property = d.pop("another_sub_property", UNSET) + + a_sub_property = d.pop("a_sub_property", UNSET) + + model_from_all_of = cls( + another_sub_property=another_sub_property, + a_sub_property=a_sub_property, + ) + + model_from_all_of.additional_properties = d + return model_from_all_of + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/openapi.json b/end_to_end_tests/openapi.json index 9e3d78908..eb127b69e 100644 --- a/end_to_end_tests/openapi.json +++ b/end_to_end_tests/openapi.json @@ -844,6 +844,32 @@ } ] } + }, + "ModelFromAllOf": { + "title": "ModelFromAllOf", + "type": "object", + "allOf": [ + {"$ref": "#/components/schemas/AllOfSubModel"}, + {"$ref": "#/components/schemas/AnotherAllOfSubModel"} + ] + }, + "AllOfSubModel": { + "title": "AllOfSubModel", + "type": "object", + "properties": { + "a_sub_property": { + "type": "string" + } + } + }, + "AnotherAllOfSubModel": { + "title": "AnotherAllOfSubModel", + "type": "object", + "properties": { + "another_sub_property": { + "type": "string" + } + } } } } diff --git a/openapi_python_client/parser/properties/__init__.py b/openapi_python_client/parser/properties/__init__.py index 427276692..f6289ce7c 100644 --- a/openapi_python_client/parser/properties/__init__.py +++ b/openapi_python_client/parser/properties/__init__.py @@ -250,13 +250,23 @@ def build_model_property( required_properties: List[Property] = [] optional_properties: List[Property] = [] relative_imports: Set[str] = set() + references: List[oai.Reference] = [] class_name = data.title or name if parent_name: class_name = f"{utils.pascal_case(parent_name)}{utils.pascal_case(class_name)}" ref = Reference.from_ref(class_name) - for key, value in (data.properties or {}).items(): + all_props = data.properties or {} + if not isinstance(data, oai.Reference) and data.allOf: + for sub_prop in data.allOf: + if isinstance(sub_prop, oai.Reference): + references += [sub_prop] + else: + all_props.update(sub_prop.properties or {}) + required_set.update(sub_prop.required or []) + + for key, value in all_props.items(): prop_required = key in required_set prop, schemas = property_from_data( name=key, required=prop_required, data=value, schemas=schemas, parent_name=class_name @@ -292,6 +302,7 @@ def build_model_property( prop = ModelProperty( reference=ref, + references=references, required_properties=required_properties, optional_properties=optional_properties, relative_imports=relative_imports, @@ -449,9 +460,6 @@ def _property_from_data( ) if data.anyOf or data.oneOf: return build_union_property(data=data, name=name, required=required, schemas=schemas, parent_name=parent_name) - if not data.type: - return NoneProperty(name=name, required=required, nullable=False, default=None), schemas - if data.type == "string": return _string_based_property(name=name, required=required, data=data), schemas elif data.type == "number": @@ -486,8 +494,10 @@ def _property_from_data( ) elif data.type == "array": return build_list_property(data=data, name=name, required=required, schemas=schemas, parent_name=parent_name) - elif data.type == "object": + elif data.type == "object" or data.allOf: return build_model_property(data=data, name=name, schemas=schemas, required=required, parent_name=parent_name) + elif not data.type: + return NoneProperty(name=name, required=required, nullable=False, default=None), schemas return PropertyError(data=data, detail=f"unknown type {data.type}"), schemas @@ -544,6 +554,16 @@ def build_schemas(*, components: Dict[str, Union[oai.Reference, oai.Schema]]) -> schemas = schemas_or_err processing = True # We made some progress this round, do another after it's done to_process = next_round - schemas.errors.extend(errors) + resolve_errors: List[PropertyError] = [] + models = list(schemas.models.values()) + for model in models: + schemas_or_err = model.resolve_references(components=components, schemas=schemas) + if isinstance(schemas_or_err, PropertyError): + resolve_errors.append(schemas_or_err) + else: + schemas = schemas_or_err + + schemas.errors.extend(errors) + schemas.errors.extend(resolve_errors) return schemas diff --git a/openapi_python_client/parser/properties/model_property.py b/openapi_python_client/parser/properties/model_property.py index 084017a41..d197c3ee7 100644 --- a/openapi_python_client/parser/properties/model_property.py +++ b/openapi_python_client/parser/properties/model_property.py @@ -1,9 +1,13 @@ -from typing import ClassVar, List, Set, Union +from collections.abc import Iterable +from typing import ClassVar, Dict, List, Set, Union import attr +from ... import schema as oai +from ..errors import PropertyError from ..reference import Reference from .property import Property +from .schemas import Schemas @attr.s(auto_attribs=True, frozen=True) @@ -11,7 +15,7 @@ class ModelProperty(Property): """ A property which refers to another Schema """ reference: Reference - + references: List[oai.Reference] required_properties: List[Property] optional_properties: List[Property] description: str @@ -20,6 +24,49 @@ class ModelProperty(Property): template: ClassVar[str] = "model_property.pyi" + def resolve_references( + self, components: Dict[str, Union[oai.Reference, oai.Schema]], schemas: Schemas + ) -> Union[Schemas, PropertyError]: + from ..properties import property_from_data + + required_set = set() + props = {} + while self.references: + reference = self.references.pop() + source_name = Reference.from_ref(reference.ref).class_name + referenced_prop = components[source_name] + assert isinstance(referenced_prop, oai.Schema) + for p, val in (referenced_prop.properties or {}).items(): + props[p] = (val, source_name) + for sub_prop in referenced_prop.allOf or []: + if isinstance(sub_prop, oai.Reference): + self.references.append(sub_prop) + else: + for p, val in (sub_prop.properties or {}).items(): + props[p] = (val, source_name) + if isinstance(referenced_prop.required, Iterable): + for sub_prop_name in referenced_prop.required: + required_set.add(sub_prop_name) + + for key, (value, source_name) in (props or {}).items(): + required = key in required_set + prop, schemas = property_from_data( + name=key, required=required, data=value, schemas=schemas, parent_name=source_name + ) + if isinstance(prop, PropertyError): + return prop + if required: + self.required_properties.append(prop) + # Remove the optional version + new_optional_props = [op for op in self.optional_properties if op.name != prop.name] + self.optional_properties.clear() + self.optional_properties.extend(new_optional_props) + elif not any(ep for ep in (self.optional_properties + self.required_properties) if ep.name == prop.name): + self.optional_properties.append(prop) + self.relative_imports.update(prop.get_imports(prefix="..")) + + return schemas + def get_type_string(self, no_optional: bool = False) -> str: """ Get a string representation of type that should be used when declaring this property """ type_string = self.reference.class_name diff --git a/openapi_python_client/parser/properties/schemas.py b/openapi_python_client/parser/properties/schemas.py index 338938673..a299a8181 100644 --- a/openapi_python_client/parser/properties/schemas.py +++ b/openapi_python_client/parser/properties/schemas.py @@ -5,8 +5,10 @@ import attr from ..errors import ParseError + +# Avoid circular import with forward reference +from . import model_property from .enum_property import EnumProperty -from .model_property import ModelProperty @attr.s(auto_attribs=True, frozen=True) @@ -14,5 +16,5 @@ class Schemas: """ Structure for containing all defined, shareable, and resuabled schemas (attr classes and Enums) """ enums: Dict[str, EnumProperty] = attr.ib(factory=dict) - models: Dict[str, ModelProperty] = attr.ib(factory=dict) + models: Dict[str, "model_property.ModelProperty"] = attr.ib(factory=dict) errors: List[ParseError] = attr.ib(factory=list) diff --git a/tests/test_parser/test_properties/test_init.py b/tests/test_parser/test_properties/test_init.py index a7ea05881..2f3c13676 100644 --- a/tests/test_parser/test_properties/test_init.py +++ b/tests/test_parser/test_properties/test_init.py @@ -586,6 +586,7 @@ def test_property_from_data_ref_model(self): nullable=False, default=None, reference=Reference(class_name=class_name, module_name="my_model"), + references=[], required_properties=[], optional_properties=[], description="", @@ -602,6 +603,7 @@ def test_property_from_data_ref_model(self): nullable=False, default=None, reference=Reference(class_name=class_name, module_name="my_model"), + references=[], required_properties=[], optional_properties=[], description="", @@ -988,19 +990,25 @@ def test__string_based_property_unsupported_format(self, mocker): def test_build_schemas(mocker): build_model_property = mocker.patch(f"{MODULE_NAME}.build_model_property") in_data = {"1": mocker.MagicMock(enum=None), "2": mocker.MagicMock(enum=None), "3": mocker.MagicMock(enum=None)} + model_1 = mocker.MagicMock() schemas_1 = mocker.MagicMock() model_2 = mocker.MagicMock() schemas_2 = mocker.MagicMock(errors=[]) - error = PropertyError() + schemas_2.models = {"1": model_1, "2": model_2} + error_1 = PropertyError() schemas_3 = mocker.MagicMock() + schemas_4 = mocker.MagicMock(errors=[]) + model_1.resolve_references.return_value = schemas_4 + error_2 = PropertyError() + model_2.resolve_references.return_value = error_2 # This loops through one for each, then again to retry the error build_model_property.side_effect = [ (model_1, schemas_1), (model_2, schemas_2), - (error, schemas_3), - (error, schemas_3), + (error_1, schemas_3), + (error_1, schemas_3), ] from openapi_python_client.parser.properties import Schemas, build_schemas @@ -1016,8 +1024,12 @@ def test_build_schemas(mocker): ] ) # schemas_3 was the last to come back from build_model_property, but it should be ignored because it's an error - assert result == schemas_2 - assert result.errors == [error] + model_1.resolve_references.assert_called_once_with(components=in_data, schemas=schemas_2) + # schemas_4 came from resolving model_1 + model_2.resolve_references.assert_called_once_with(components=in_data, schemas=schemas_4) + # resolving model_2 resulted in err, so no schemas_5 + assert result == schemas_4 + assert result.errors == [error_1, error_2] def test_build_parse_error_on_reference(): @@ -1091,6 +1103,7 @@ def test_build_model_property(additional_properties_schema, expected_additional_ nullable=False, default=None, reference=Reference(class_name="ParentMyModel", module_name="parent_my_model"), + references=[], required_properties=[StringProperty(name="req", required=True, nullable=False, default=None)], optional_properties=[DateTimeProperty(name="opt", required=False, nullable=False, default=None)], description=data.description, diff --git a/tests/test_parser/test_properties/test_model_property.py b/tests/test_parser/test_properties/test_model_property.py index 1024ef179..421f40d48 100644 --- a/tests/test_parser/test_properties/test_model_property.py +++ b/tests/test_parser/test_properties/test_model_property.py @@ -23,6 +23,7 @@ def test_get_type_string(no_optional, nullable, required, expected): nullable=nullable, default=None, reference=Reference(class_name="MyClass", module_name="my_module"), + references=[], description="", optional_properties=[], required_properties=[], @@ -42,6 +43,7 @@ def test_get_imports(): nullable=True, default=None, reference=Reference(class_name="MyClass", module_name="my_module"), + references=[], description="", optional_properties=[], required_properties=[], @@ -57,3 +59,143 @@ def test_get_imports(): "from typing import Dict", "from typing import cast", } + + +def test_resolve_references(mocker): + import openapi_python_client.schema as oai + from openapi_python_client.parser.properties import build_model_property + + schemas = { + "RefA": oai.Schema.construct( + title=mocker.MagicMock(), + description=mocker.MagicMock(), + required=["String"], + properties={ + "String": oai.Schema.construct(type="string"), + "Enum": oai.Schema.construct(type="string", enum=["aValue"]), + "DateTime": oai.Schema.construct(type="string", format="date-time"), + }, + ), + "RefB": oai.Schema.construct( + title=mocker.MagicMock(), + description=mocker.MagicMock(), + required=["DateTime"], + properties={ + "Int": oai.Schema.construct(type="integer"), + "DateTime": oai.Schema.construct(type="string", format="date-time"), + "Float": oai.Schema.construct(type="number", format="float"), + }, + ), + # Intentionally no properties defined + "RefC": oai.Schema.construct( + title=mocker.MagicMock(), + description=mocker.MagicMock(), + ), + } + + model_schema = oai.Schema.construct( + allOf=[ + oai.Reference.construct(ref="#/components/schemas/RefA"), + oai.Reference.construct(ref="#/components/schemas/RefB"), + oai.Reference.construct(ref="#/components/schemas/RefC"), + oai.Schema.construct( + title=mocker.MagicMock(), + description=mocker.MagicMock(), + required=["Float"], + properties={ + "String": oai.Schema.construct(type="string"), + "Float": oai.Schema.construct(type="number", format="float"), + }, + ), + ] + ) + + components = {**schemas, "Model": model_schema} + + from openapi_python_client.parser.properties import Schemas + + schemas_holder = Schemas() + model, schemas_holder = build_model_property( + data=model_schema, name="Model", required=True, schemas=schemas_holder, parent_name=None + ) + model.resolve_references(components, schemas_holder) + assert sorted(p.name for p in model.required_properties) == ["DateTime", "Float", "String"] + assert all(p.required for p in model.required_properties) + assert sorted(p.name for p in model.optional_properties) == ["Enum", "Int"] + assert all(not p.required for p in model.optional_properties) + + +def test_resolve_references_nested_allof(mocker): + import openapi_python_client.schema as oai + from openapi_python_client.parser.properties import build_model_property + + schemas = { + "RefA": oai.Schema.construct( + title=mocker.MagicMock(), + description=mocker.MagicMock(), + required=["String"], + properties={ + "String": oai.Schema.construct(type="string"), + "Enum": oai.Schema.construct(type="string", enum=["aValue"]), + "DateTime": oai.Schema.construct(type="string", format="date-time"), + }, + ), + "RefB": oai.Schema.construct( + title=mocker.MagicMock(), + description=mocker.MagicMock(), + required=["DateTime"], + properties={ + "Int": oai.Schema.construct(type="integer"), + "DateTime": oai.Schema.construct(type="string", format="date-time"), + "Float": oai.Schema.construct(type="number", format="float"), + }, + ), + # Intentionally no properties defined + "RefC": oai.Schema.construct( + title=mocker.MagicMock(), + description=mocker.MagicMock(), + ), + } + + model_schema = oai.Schema.construct( + type="object", + properties={ + "Key": oai.Schema.construct( + allOf=[ + oai.Reference.construct(ref="#/components/schemas/RefA"), + oai.Reference.construct(ref="#/components/schemas/RefB"), + oai.Reference.construct(ref="#/components/schemas/RefC"), + oai.Schema.construct( + title=mocker.MagicMock(), + description=mocker.MagicMock(), + required=["Float"], + properties={ + "String": oai.Schema.construct(type="string"), + "Float": oai.Schema.construct(type="number", format="float"), + }, + ), + ] + ), + }, + ) + + components = {**schemas, "Model": model_schema} + + from openapi_python_client.parser.properties import ModelProperty, Schemas + + schemas_holder = Schemas() + model, schemas_holder = build_model_property( + data=model_schema, name="Model", required=True, schemas=schemas_holder, parent_name=None + ) + model.resolve_references(components, schemas_holder) + assert sorted(p.name for p in model.required_properties) == [] + assert sorted(p.name for p in model.optional_properties) == ["Key"] + assert all(not p.required for p in model.optional_properties) + + key_property = model.optional_properties[0] + assert isinstance(key_property, ModelProperty) + key_property.resolve_references(components, schemas_holder) + assert sorted(p.name for p in key_property.required_properties) == ["DateTime", "Float", "String"] + assert all(p.required for p in key_property.required_properties) + assert sorted(p.name for p in key_property.optional_properties) == ["Enum", "Int"] + assert all(not p.required for p in key_property.optional_properties)