From 8454f854171603cab3112ece66a24e5071ed75cd Mon Sep 17 00:00:00 2001 From: Hampus Sunner Date: Mon, 25 May 2026 11:48:56 +0200 Subject: [PATCH 1/4] fix: include additionalProperties in schema traversal for model_rebuild _get_combined_attributes omitted additionalProperties, so inline schemas used as map value types (e.g. nullable+allOf+$ref) were never registered in the types dict and never had model_rebuild called. This left generated Pydantic models with unresolved ForwardRefs (__pydantic_complete__=False). Fixes the case: additionalProperties: nullable: true allOf: - $ref: '#/components/schemas/SomeModel' --- README.md | 2 +- src/aiopenapi3/openapi.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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/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 From 75da5389ede9de449d9cca4c5be8ef8efef2cc96 Mon Sep 17 00:00:00 2001 From: Hampus Sunner Date: Mon, 25 May 2026 12:10:43 +0200 Subject: [PATCH 2/4] test: add regression test for additionalProperties nullable+allOf+ref --- tests/conftest.py | 5 +++ ...a-additionalProperties-nullable-allof.yaml | 38 +++++++++++++++++++ tests/schema_test.py | 25 ++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 tests/fixtures/schema-additionalProperties-nullable-allof.yaml 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..7d017c2e --- /dev/null +++ b/tests/fixtures/schema-additionalProperties-nullable-allof.yaml @@ -0,0 +1,38 @@ +openapi: "3.0.3" +info: + version: 1.0.0 + title: additionalProperties nullable allOf + +components: + schemas: + LinkOccasion: + type: object + properties: + unitTestId: + type: integer + + RelationsModel: + type: object + properties: + unitPartNumber: + type: string + linkOccasions: + type: array + items: + $ref: "#/components/schemas/LinkOccasion" + +paths: + /relations: + get: + operationId: getRelations + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: + nullable: true + allOf: + - $ref: "#/components/schemas/RelationsModel" diff --git a/tests/schema_test.py b/tests/schema_test.py index 05db6be8..67150495 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -332,6 +332,31 @@ 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) + + schema = api.paths["/relations"].get.responses["200"].content["application/json"].schema_ + value_type = schema.additionalProperties.get_type() + + assert value_type.__pydantic_complete__ is True + + # non-null value + obj = value_type.model_validate({"unitPartNumber": "ABC", "linkOccasions": [{"unitTestId": 1}]}) + assert obj.unitPartNumber == "ABC" + assert obj.linkOccasions[0].unitTestId == 1 + + # map model accepts null values for the map entries + map_type = schema.get_type() + result = map_type.model_validate({"key1": {"unitPartNumber": "ABC", "linkOccasions": []}, "key2": None}) + assert result.root["key2"] is None + assert result.root["key1"].unitPartNumber == "ABC" + + def test_schema_with_patternProperties(with_schema_patternProperties): api = OpenAPI("/", with_schema_patternProperties) A = api.components.schemas["A"].get_type() From 24816ce1e37a12a8396377ab4ff2b97ed8eef2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 26 May 2026 09:46:55 +0200 Subject: [PATCH 3/4] schema - nullable additionalProperties nullable additionalProperties have to be RootModels --- src/aiopenapi3/model.py | 6 +++++- .../schema-additionalProperties-nullable-allof.yaml | 10 +++++++++- tests/schema_test.py | 8 ++++---- 3 files changed, 18 insertions(+), 6 deletions(-) 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/tests/fixtures/schema-additionalProperties-nullable-allof.yaml b/tests/fixtures/schema-additionalProperties-nullable-allof.yaml index 7d017c2e..18416d9c 100644 --- a/tests/fixtures/schema-additionalProperties-nullable-allof.yaml +++ b/tests/fixtures/schema-additionalProperties-nullable-allof.yaml @@ -10,7 +10,8 @@ components: properties: unitTestId: type: integer - + required: + - unitTestId RelationsModel: type: object properties: @@ -20,6 +21,9 @@ components: type: array items: $ref: "#/components/schemas/LinkOccasion" + required: + - unitPartNumber + - linkOccasions paths: /relations: @@ -31,8 +35,12 @@ paths: content: application/json: schema: + title: base type: object additionalProperties: + title: addi +# type: object +# additionalProperties: false nullable: true allOf: - $ref: "#/components/schemas/RelationsModel" diff --git a/tests/schema_test.py b/tests/schema_test.py index 67150495..cc2b9d05 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -340,21 +340,21 @@ def test_schema_additionalProperties_nullable_allof(with_schema_additionalProper """ api = OpenAPI("/", with_schema_additionalProperties_nullable_allof) - schema = api.paths["/relations"].get.responses["200"].content["application/json"].schema_ + schema = api._["getRelations"].return_value() value_type = schema.additionalProperties.get_type() assert value_type.__pydantic_complete__ is True # non-null value obj = value_type.model_validate({"unitPartNumber": "ABC", "linkOccasions": [{"unitTestId": 1}]}) - assert obj.unitPartNumber == "ABC" - assert obj.linkOccasions[0].unitTestId == 1 + assert obj.root.unitPartNumber == "ABC" + assert obj.root.linkOccasions[0].unitTestId == 1 # map model accepts null values for the map entries map_type = schema.get_type() result = map_type.model_validate({"key1": {"unitPartNumber": "ABC", "linkOccasions": []}, "key2": None}) assert result.root["key2"] is None - assert result.root["key1"].unitPartNumber == "ABC" + assert result.root["key1"].root.unitPartNumber == "ABC" def test_schema_with_patternProperties(with_schema_patternProperties): From ce6e0084d00d1102819c21c364fbb2af6350421c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 26 May 2026 11:26:19 +0200 Subject: [PATCH 4/4] tests/schema - nullable additionalProperties add test for implicit/explicit nullable & oneOf --- ...a-additionalProperties-nullable-allof.yaml | 62 ++++++++++++------- tests/schema_test.py | 22 +++---- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/tests/fixtures/schema-additionalProperties-nullable-allof.yaml b/tests/fixtures/schema-additionalProperties-nullable-allof.yaml index 18416d9c..6538497c 100644 --- a/tests/fixtures/schema-additionalProperties-nullable-allof.yaml +++ b/tests/fixtures/schema-additionalProperties-nullable-allof.yaml @@ -5,42 +5,60 @@ info: components: schemas: - LinkOccasion: + A: type: object + nullable: false + additionalProperties: false properties: - unitTestId: - type: integer - required: - - unitTestId - RelationsModel: - type: object - properties: - unitPartNumber: + a: type: string - linkOccasions: - type: array - items: - $ref: "#/components/schemas/LinkOccasion" required: - - unitPartNumber - - linkOccasions + - a paths: - /relations: + /implicit-nullable-object-allOf: get: - operationId: getRelations + operationId: implicit-allOf responses: "200": description: ok content: application/json: schema: - title: base type: object additionalProperties: - title: addi -# type: object -# additionalProperties: false + # type: object - not set nullable: true allOf: - - $ref: "#/components/schemas/RelationsModel" + - $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 cc2b9d05..f93136ba 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -340,21 +340,17 @@ def test_schema_additionalProperties_nullable_allof(with_schema_additionalProper """ api = OpenAPI("/", with_schema_additionalProperties_nullable_allof) - schema = api._["getRelations"].return_value() - value_type = schema.additionalProperties.get_type() + 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 + assert value_type.__pydantic_complete__ is True - # non-null value - obj = value_type.model_validate({"unitPartNumber": "ABC", "linkOccasions": [{"unitTestId": 1}]}) - assert obj.root.unitPartNumber == "ABC" - assert obj.root.linkOccasions[0].unitTestId == 1 - - # map model accepts null values for the map entries - map_type = schema.get_type() - result = map_type.model_validate({"key1": {"unitPartNumber": "ABC", "linkOccasions": []}, "key2": None}) - assert result.root["key2"] is None - assert result.root["key1"].root.unitPartNumber == "ABC" + # 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):