Skip to content

Commit

Permalink
v3x - boolean schemas
Browse files Browse the repository at this point in the history
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
  • Loading branch information
commonism committed Oct 31, 2024
1 parent 72cedc9 commit 2aab9ab
Show file tree
Hide file tree
Showing 9 changed files with 164 additions and 26 deletions.
68 changes: 49 additions & 19 deletions aiopenapi3/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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_):
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions aiopenapi3/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
+ (
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion aiopenapi3/v30/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"):
Expand Down
2 changes: 1 addition & 1 deletion aiopenapi3/v31/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion aiopenapi3/v31/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion aiopenapi3/v31/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

"""
Expand Down Expand Up @@ -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"]:
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions tests/fixtures/schema-boolean.yaml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions tests/schema_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

0 comments on commit 2aab9ab

Please sign in to comment.