diff --git a/README.md b/README.md index ba8a6151..e8f5e618 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ This project is a fork of [Dorthu/openapi3](https://github.com/Dorthu/openapi3/) * recursive schemas (A.a -> A) * request body model creation via pydantic * pydantic compatible "format"-type coercion (e.g. datetime.interval) - * additionalProperties (limited to string-to-any dictionaries without properties) + * additionalProperties (string-to-any dictionaries, including inline schemas with `allOf`/`nullable`) * response body & header parsing via pydantic * blocking and nonblocking (asyncio) interface via [httpx](https://www.python-httpx.org/) * SOCKS5 via socksio diff --git a/src/aiopenapi3/model.py b/src/aiopenapi3/model.py index 64f682a6..15a9779e 100644 --- a/src/aiopenapi3/model.py +++ b/src/aiopenapi3/model.py @@ -618,9 +618,10 @@ def createAnnotation( @staticmethod def types(schema: "SchemaType") -> typing.Generator[str, None, None]: + nullable = getattr(schema, "nullable", False) if isinstance(schema.type, str): yield schema.type - if getattr(schema, "nullable", False): + if nullable: yield "null" else: typesfilter: set[str] = set() @@ -631,6 +632,9 @@ def types(schema: "SchemaType") -> typing.Generator[str, None, None]: values = set(SCHEMA_TYPES) typesfilter = set() + if nullable: + typesfilter.add("null") + if (const := getattr(schema, "const", None)) is not None: typesfilter.add(cast(str, TYPES_SCHEMA_MAP.get(type(const)))) diff --git a/src/aiopenapi3/openapi.py b/src/aiopenapi3/openapi.py index 6e26fbcc..00156fb0 100644 --- a/src/aiopenapi3/openapi.py +++ b/src/aiopenapi3/openapi.py @@ -449,6 +449,7 @@ def _init_operationindex(self, use_operation_tags: bool) -> bool: def _get_combined_attributes(schema): """Combine attributes from the schema.""" is_array = Model.is_type_any(schema) or Model.is_type(schema, "array") + additional = getattr(schema, "additionalProperties", None) return ( getattr(schema, "oneOf", []) # Swagger compat + ( @@ -462,6 +463,7 @@ def _get_combined_attributes(schema): + ([schema.items] if is_array and schema.items is not None and not isinstance(schema, list) else []) + (schema.items if is_array and schema.items is not None and isinstance(schema, list) else []) + (getattr(schema, "prefixItems", []) or [] if is_array else []) + + ([additional] if isinstance(additional, (SchemaBase, ReferenceBase)) else []) ) @classmethod diff --git a/tests/conftest.py b/tests/conftest.py index 3e572219..26bfea31 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -449,6 +449,11 @@ def with_schema_additionalProperties_and_named_properties(): yield _get_parsed_yaml("schema-additionalProperties-and-named-properties.yaml") +@pytest.fixture +def with_schema_additionalProperties_nullable_allof(): + yield _get_parsed_yaml("schema-additionalProperties-nullable-allof.yaml") + + @pytest.fixture def with_schema_date_types(): yield _get_parsed_yaml("schema-date-types.yaml") diff --git a/tests/fixtures/schema-additionalProperties-nullable-allof.yaml b/tests/fixtures/schema-additionalProperties-nullable-allof.yaml new file mode 100644 index 00000000..6538497c --- /dev/null +++ b/tests/fixtures/schema-additionalProperties-nullable-allof.yaml @@ -0,0 +1,64 @@ +openapi: "3.0.3" +info: + version: 1.0.0 + title: additionalProperties nullable allOf + +components: + schemas: + A: + type: object + nullable: false + additionalProperties: false + properties: + a: + type: string + required: + - a + +paths: + /implicit-nullable-object-allOf: + get: + operationId: implicit-allOf + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: + # type: object - not set + nullable: true + allOf: + - $ref: "#/components/schemas/A" + + /explicit-nullable-object-allOf: + get: + operationId: explicit-allOf + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: + type: object + nullable: true + allOf: + - $ref: "#/components/schemas/A" + + /explicit-object-oneOf-nullable: + get: + operationId: explicit-oneOf + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: + oneOf: + - $ref: "#/components/schemas/A" + - nullable: true diff --git a/tests/schema_test.py b/tests/schema_test.py index 05db6be8..f93136ba 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -332,6 +332,27 @@ def test_schema_with_additionalProperties_and_named_properties(with_schema_addit assert b.aio3_additionalProperties["foo"] == "bar" +def test_schema_additionalProperties_nullable_allof(with_schema_additionalProperties_nullable_allof): + """ + Inline additionalProperties schema with nullable+allOf+$ref must produce a fully + resolved Pydantic model (regression test for _get_combined_attributes omitting + additionalProperties from schema traversal). + """ + api = OpenAPI("/", with_schema_additionalProperties_nullable_allof) + + for op in ["implicit-allOf", "explicit-allOf", "explicit-oneOf"]: + schema = api._[op].return_value() + value_type = schema.additionalProperties.get_type() + + assert value_type.__pydantic_complete__ is True + + # map model accepts null values for the map entries + map_type = schema.get_type() + result = map_type.model_validate({"obj": {"a": "ABC"}, "none": None}) + assert result.root["none"] is None + assert result.root["obj"].root.a == "ABC" + + def test_schema_with_patternProperties(with_schema_patternProperties): api = OpenAPI("/", with_schema_patternProperties) A = api.components.schemas["A"].get_type()