From e8bfe047f8825ca62b3257e8ae300168a6284e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Thu, 31 Oct 2024 05:05:49 +0100 Subject: [PATCH] v3x - boolean schemas as defined in https://json-schema.org/draft/2020-12/json-schema-core#section-4.3.2 true: {} false: {not: {}} previously boolean schemas were restricted to additionalProperties and there was inconsistent support for the expanded version of boolean schemas --- aiopenapi3/model.py | 68 +++++++++++++++++++++--------- aiopenapi3/openapi.py | 8 ++-- aiopenapi3/v30/schemas.py | 12 +++++- aiopenapi3/v31/components.py | 2 +- aiopenapi3/v31/root.py | 3 +- aiopenapi3/v31/schemas.py | 13 +++++- tests/conftest.py | 5 +++ tests/fixtures/schema-boolean.yaml | 66 +++++++++++++++++++++++++++++ tests/schema_test.py | 13 ++++++ 9 files changed, 164 insertions(+), 26 deletions(-) create mode 100644 tests/fixtures/schema-boolean.yaml diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index be458a02..41c4f645 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -177,9 +177,9 @@ def _createAnnotations( self.root = v elif _type == "object": if ( - schema.additionalProperties - and isinstance(schema.additionalProperties, (SchemaBase, ReferenceBase)) - and not schema.properties + not schema.properties + and schema.additionalProperties is not None + and not Model.booleanFalse(schema.additionalProperties) ): """ https://swagger.io/docs/specification/data-models/dictionaries/ @@ -412,7 +412,7 @@ def get_patternProperties(self_): classinfo.properties["aio3_patternProperties"].default = property(mkx()) - if not schema.additionalProperties: + if Model.booleanFalse(schema.additionalProperties): def mkx(): def validate_patternProperties(self_): @@ -473,21 +473,16 @@ def createConfigDict(schema: "SchemaType"): arbitrary_types_allowed_ = False extra_ = "allow" - if schema.additionalProperties is not None: - if isinstance(schema.additionalProperties, bool): - if not schema.additionalProperties: - extra_ = "forbid" - else: - arbitrary_types_allowed_ = True - elif isinstance(schema.additionalProperties, (SchemaBase, ReferenceBase)): - """ - we allow arbitrary types if additionalProperties has no properties - """ - assert schema.additionalProperties.properties is not None - if len(schema.additionalProperties.properties) == 0: - arbitrary_types_allowed_ = True - else: - raise TypeError(schema.additionalProperties) + if Model.booleanFalse(schema.additionalProperties): + extra_ = "forbid" + elif Model.booleanTrue(schema.additionalProperties) or ( + isinstance(schema.additionalProperties, (SchemaBase, ReferenceBase)) + and len(schema.additionalProperties.properties) == 0 + ): + """ + we allow arbitrary types if additionalProperties has no properties + """ + arbitrary_types_allowed_ = True if getattr(schema, "patternProperties", None): extra_ = "allow" @@ -671,6 +666,41 @@ def is_nullable(schema: "SchemaType") -> bool: def is_type_any(schema: "SchemaType"): return schema.type is None + @staticmethod + def booleanTrue(schema: Optional[Union["SchemaType", bool]]) -> bool: + """ + ACCEPT all? + :param schema: + :return: True if Schema is {} or True or None + """ + if schema is None: + return True + if isinstance(schema, bool): + return schema is True + elif isinstance(schema, (SchemaBase, ReferenceBase)): + """matches Any - {}""" + return len(schema.model_fields_set) == 0 + else: + raise ValueError(schema) + + @staticmethod + def booleanFalse(schema: Optional[Union["SchemaType", bool]]) -> bool: + """ + REJECT all? + :param schema: + :return: True if Schema is {'not':{}} or False + """ + + if schema is None: + return False + if isinstance(schema, bool): + return schema is False + elif isinstance(schema, (SchemaBase, ReferenceBase)): + """match {'not':{}}""" + return (v := getattr(schema, "not_", False)) and Model.booleanTrue(v) + else: + raise ValueError(schema) + @staticmethod def createField(schema: "SchemaType", _type=None, args=None): if args is None: diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 18181921..cb41ccdc 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -34,7 +34,7 @@ from .base import RootBase, ReferenceBase, SchemaBase, OperationBase, DiscriminatorBase from .request import RequestBase from .v30.paths import Operation -from .model import is_basemodel +from .model import is_basemodel, Model if typing.TYPE_CHECKING: @@ -428,6 +428,7 @@ def _init_operationindex(self, use_operation_tags: bool) -> bool: @staticmethod def _get_combined_attributes(schema): """Combine attributes from the schema.""" + is_array = Model.is_type_any(schema) or Model.is_type(schema, "array") return ( getattr(schema, "oneOf", []) # Swagger compat + ( @@ -438,8 +439,9 @@ def _get_combined_attributes(schema): + getattr(schema, "anyOf", []) # Swagger compat + schema.allOf + list(schema.properties.values()) - + ([schema.items] if schema.type == "array" and schema.items and not isinstance(schema, list) else []) - + (schema.items if schema.type == "array" and schema.items and isinstance(schema, list) else []) + + ([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 []) ) @classmethod diff --git a/aiopenapi3/v30/schemas.py b/aiopenapi3/v30/schemas.py index 9e70efb9..c3a99f1a 100644 --- a/aiopenapi3/v30/schemas.py +++ b/aiopenapi3/v30/schemas.py @@ -48,7 +48,7 @@ class Schema(ObjectExtended, SchemaBase): not_: Optional[Union["Schema", Reference]] = Field(default=None, alias="not") items: Optional[Union["Schema", Reference]] = Field(default=None) properties: dict[str, Union["Schema", Reference]] = Field(default_factory=dict) - additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) + additionalProperties: Optional[Union["Schema", Reference]] = Field(default=None) description: Optional[str] = Field(default=None) format: Optional[str] = Field(default=None) default: Optional[Any] = Field(default=None) @@ -63,6 +63,16 @@ class Schema(ObjectExtended, SchemaBase): model_config = ConfigDict(extra="forbid") + @model_validator(mode="before") + @classmethod + def is_boolean_schema(cls, data: Any) -> Any: + if not isinstance(data, bool): + return data + if data: + return {} + else: + return {"not": {}} + @model_validator(mode="after") @classmethod def validate_Schema_number_type(cls, s: "Schema"): diff --git a/aiopenapi3/v31/components.py b/aiopenapi3/v31/components.py index 8b7cdd96..d6bb514a 100644 --- a/aiopenapi3/v31/components.py +++ b/aiopenapi3/v31/components.py @@ -20,7 +20,7 @@ class Components(ObjectExtended): .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#components-object """ - schemas: dict[str, Union[Schema, bool]] = Field(default_factory=dict) + schemas: dict[str, Union[Schema]] = Field(default_factory=dict) responses: dict[str, Union[Response, Reference]] = Field(default_factory=dict) parameters: dict[str, Union[Parameter, Reference]] = Field(default_factory=dict) examples: dict[str, Union[Example, Reference]] = Field(default_factory=dict) diff --git a/aiopenapi3/v31/root.py b/aiopenapi3/v31/root.py index 2440d5c4..fe11b22e 100644 --- a/aiopenapi3/v31/root.py +++ b/aiopenapi3/v31/root.py @@ -35,7 +35,8 @@ class Root(ObjectExtended, RootBase): externalDocs: dict[Any, Any] = Field(default_factory=dict) @model_validator(mode="after") - def validate_Root(cls, r: "Root"): + @classmethod + def validate_Root(cls, r: "Root") -> "Self": assert r.paths or r.components or r.webhooks return r diff --git a/aiopenapi3/v31/schemas.py b/aiopenapi3/v31/schemas.py index 28dadddd..9e74097a 100644 --- a/aiopenapi3/v31/schemas.py +++ b/aiopenapi3/v31/schemas.py @@ -76,7 +76,7 @@ class Schema(ObjectExtended, SchemaBase): """ properties: dict[str, "Schema"] = Field(default_factory=dict) patternProperties: dict[str, "Schema"] = Field(default_factory=dict) - additionalProperties: Optional[Union[bool, "Schema"]] = Field(default=None) + additionalProperties: Optional["Schema"] = Field(default=None) propertyNames: Optional["Schema"] = Field(default=None) """ @@ -162,7 +162,18 @@ class Schema(ObjectExtended, SchemaBase): externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' example: Optional[Any] = Field(default=None) + @model_validator(mode="before") + @classmethod + def is_boolean_schema(cls, data: Any) -> Any: + if not isinstance(data, bool): + return data + if data: + return {} + else: + return {"not": {}} + @model_validator(mode="after") + @classmethod def validate_Schema_number_type(cls, s: "Schema"): if s.type == "integer": for i in ["minimum", "maximum"]: diff --git a/tests/conftest.py b/tests/conftest.py index 7acded85..67c50b95 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -440,6 +440,11 @@ def with_schema_additionalProperties_and_named_properties(): yield _get_parsed_yaml("schema-additionalProperties-and-named-properties" ".yaml") +@pytest.fixture +def with_schema_boolean(openapi_version): + yield _get_parsed_yaml("schema-boolean.yaml", openapi_version) + + @pytest.fixture def with_schema_empty(openapi_version): yield _get_parsed_yaml("schema-empty.yaml", openapi_version) diff --git a/tests/fixtures/schema-boolean.yaml b/tests/fixtures/schema-boolean.yaml new file mode 100644 index 00000000..5538fcb8 --- /dev/null +++ b/tests/fixtures/schema-boolean.yaml @@ -0,0 +1,66 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Example + license: + name: MIT + description: | + https://github.com/swagger-api/swagger-parser/issues/1770 +servers: + - url: http://api.example.xyz/v1 +paths: + /person/display/{personId}: + get: + parameters: + - name: personId + in: path + required: true + description: The id of the person to retrieve + schema: + type: string + operationId: list + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/BooleanTrue" +components: + schemas: + BooleanTrue: true + ArrayWithTrueItems: + type: array + items: true + ObjectWithTrueProperty: + properties: + someProp: true + ObjectWithTrueAdditionalProperties: + additionalProperties: true + AllOfWithTrue: + allOf: + - true + AnyOfWithTrue: + anyOf: + - true + OneOfWithTrue: + oneOf: + - true + NotWithTrue: + not: true + UnevaluatedItemsTrue: + unevaluatedItems: true + UnevaluatedPropertiesTrue: + unevaluatedProperties: true + PrefixitemsWithNoAdditionalItemsAllowed: + $schema: https://json-schema.org/draft/2020-12/schema + prefixItems: + - {} + - {} + - {} + items: false + PrefixitemsWithBooleanSchemas: + $schema: https://json-schema.org/draft/2020-12/schema + prefixItems: + - true + - false diff --git a/tests/schema_test.py b/tests/schema_test.py index 241b7e40..53e51caf 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -741,3 +741,16 @@ def test_schema_allof_oneof_combined(with_schema_allof_oneof_combined): t.model_validate({"token": "1", "cmd": "invalid", "data": {"delay": 0}}) with pytest.raises(ValidationError): t.model_validate({"token": "1", "cmd": "shutdown", "data": {"delay": "invalid"}}) + + +def test_schema_boolean(with_schema_boolean): + v = copy.deepcopy(with_schema_boolean) + if v["openapi"] == "3.0.3": + for i in [ + "PrefixitemsWithNoAdditionalItemsAllowed", + "PrefixitemsWithBooleanSchemas", + "UnevaluatedItemsTrue", + "UnevaluatedPropertiesTrue", + ]: + del v["components"]["schemas"][i] + OpenAPI("/", v)