diff --git a/CHANGELOG.md b/CHANGELOG.md index 968c689..893bce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ The **third number** is for emergencies when we need to start branches for older +## [v24.1.0](https://github.com/tinche/uapi/compare/v23.3.0...HEAD) - UNRELEASED + +### Added + +- `typing.Any` is now supported in the OpenAPI schema, rendering to an empty schema. + ([#58](https://github.com/Tinche/uapi/pull/58)) +- Dictionaries are now supported in the OpenAPI schema, rendering to object schemas with `additionalProperties`. + ([#58](https://github.com/Tinche/uapi/pull/58)) + ## [v23.3.0](https://github.com/tinche/uapi/compare/v23.2.0...v23.3.0) - 2023-12-20 ### Changed diff --git a/docs/openapi.md b/docs/openapi.md index f161637..c117997 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -46,6 +46,10 @@ _uapi_ comes with OpenAPI schema support for the following types: - bytes (`type: string, format: binary`) - dates (`type: string, format: date`) - datetimes (`type: string, format: date-time`) +- lists (`type: array`) +- dictionaries (`type: object`, with `additionalProperties`) +- attrs classes (`type: object`) +- `typing.Any` (empty schema) ## Operation Summaries and Descriptions diff --git a/src/uapi/openapi.py b/src/uapi/openapi.py index 7331fb9..02f6fd6 100644 --- a/src/uapi/openapi.py +++ b/src/uapi/openapi.py @@ -2,13 +2,14 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence +from contextlib import suppress from datetime import date, datetime from enum import Enum, unique from typing import Any, ClassVar, Literal, TypeAlias from attrs import Factory, define, field, frozen, has from cattrs import override -from cattrs._compat import get_args, is_generic, is_sequence +from cattrs._compat import get_args, is_generic, is_mapping, is_sequence from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn from cattrs.preconf.json import make_converter @@ -27,6 +28,11 @@ class Reference: @frozen class Schema: + """The generic schema base class. + + Consider using a specialized version (like `IntegerSchema`) instead. + """ + @unique class Type(Enum): OBJECT = "object" @@ -37,7 +43,7 @@ class Type(Enum): NULL = "null" ARRAY = "array" - type: Type + type: Type | None = None properties: dict[str, AnySchema | Reference] | None = None format: str | None = None additionalProperties: bool | Schema | IntegerSchema | Reference = False @@ -198,15 +204,37 @@ def get_schema_for_type( self, type: Any ) -> Reference | Schema | IntegerSchema | ArraySchema: # First check inline types. + # TODO: Use the rules to build this, instead of duplicating + # the logic here. if type in self.PYTHON_PRIMITIVES_TO_OPENAPI: return self.PYTHON_PRIMITIVES_TO_OPENAPI[type] + if type is Any: + return Schema() if is_sequence(type): # Arrays get created inline. arg = get_args(type)[0] inner = self.get_schema_for_type(arg) if not isinstance(inner, ArraySchema): return ArraySchema(inner) - raise Exception("Nested arrays are unsupported.") + raise Exception("Nested arrays are unsupported") + + mapping = False + # TODO: remove this when cattrs 24.1 releases + with suppress(TypeError): + mapping = is_mapping(type) + if mapping: + # Dicts also get created inline. + args = get_args(type) + + key_type, value_type = args if len(args) == 2 else (Any, Any) + # OpenAPI doesn't support anything else. + if key_type not in (Any, str): + raise Exception(f"Can't handle {type}") + + value_schema = self.get_schema_for_type(value_type) + if not isinstance(value_schema, ArraySchema): + return Schema(Schema.Type.OBJECT, additionalProperties=value_schema) + raise Exception(f"Can't handle {type}") name = self._name_for(type) if name not in self.components and type not in self._build_queue: @@ -233,6 +261,7 @@ def default_build_rules(cls) -> list[tuple[Predicate, BuildHook]]: cls.PYTHON_PRIMITIVES_TO_OPENAPI.__contains__, lambda t, _: cls.PYTHON_PRIMITIVES_TO_OPENAPI[t], ), + (lambda t: t is Any, lambda _, __: Schema()), (has, build_attrs_schema), ] @@ -248,6 +277,8 @@ def _structure_schemas(val, _): if "oneOf" in val: return converter.structure(val, OneOfSchema) + if "type" not in val: + return Schema() type = Schema.Type(val["type"]) if type is Schema.Type.ARRAY: return converter.structure(val, ArraySchema) @@ -259,6 +290,8 @@ def _structure_schemas(val, _): def _structure_schema_or_ref(val, _) -> Schema | IntegerSchema | Reference: if "$ref" in val: return converter.structure(val, Reference) + if "type" not in val: + return Schema() type = Schema.Type(val["type"]) if type is Schema.Type.INTEGER: return converter.structure(val, IntegerSchema) diff --git a/tests/apps.py b/tests/apps.py index 5a55211..122af44 100644 --- a/tests/apps.py +++ b/tests/apps.py @@ -156,6 +156,8 @@ async def header_renamed( ) -> str: return test_header + # Models and loaders. + @app.put("/custom-loader") async def custom_loader(body: CustomReqBody[NestedModel]) -> Ok[str]: return Ok(str(body.simple_model.an_int)) @@ -191,6 +193,13 @@ async def generic_model(m: ReqBody[GenericModel[int]]) -> GenericModel[SimpleMod """OpenAPI should handle generic models.""" return GenericModel(SimpleModel(1)) + @app.get("/generic-model-dicts") + async def generic_model_dict( + m: ReqBody[GenericModel[dict[str, str]]] + ) -> GenericModel[dict[str, str]]: + """OpenAPI should handle generic models with dicts.""" + return GenericModel({}) + @app.get("/response-model") async def response_model() -> ResponseModel: return ResponseModel([]) @@ -378,6 +387,8 @@ def non_str_header_no_default(test_header: Header[int]) -> str: def header_renamed(test_header: Annotated[str, HeaderSpec("test_header")]) -> str: return test_header + # Models and loaders. + @app.put("/custom-loader") def custom_loader(body: CustomReqBody[NestedModel]) -> Ok[str]: return Ok(str(body.simple_model.an_int)) @@ -413,6 +424,13 @@ def generic_model(m: ReqBody[GenericModel[int]]) -> GenericModel[SimpleModel]: """OpenAPI should handle generic models.""" return GenericModel(SimpleModel(1)) + @app.get("/generic-model-dicts") + def generic_model_dict( + m: ReqBody[GenericModel[dict[str, str]]] + ) -> GenericModel[dict[str, str]]: + """OpenAPI should handle generic models with dicts.""" + return GenericModel({}) + @app.get("/response-model") def response_model() -> ResponseModel: return ResponseModel([]) diff --git a/tests/openapi/test_openapi_attrs.py b/tests/openapi/test_openapi_attrs.py index a5fbbc2..705b08d 100644 --- a/tests/openapi/test_openapi_attrs.py +++ b/tests/openapi/test_openapi_attrs.py @@ -560,3 +560,41 @@ def handler2(m: ReqBody[Model2]) -> None: properties={"b": Schema(Schema.Type.NUMBER, format="double")}, required=["b"], ) + + +def test_generic_dicts(app: App) -> None: + spec: OpenAPI = app.make_openapi_spec() + + op = spec.paths["/generic-model-dicts"] + assert op is not None + assert op.get is not None + + assert op.get.parameters == [] + assert op.get.requestBody == RequestBody( + { + "application/json": MediaType( + Reference("#/components/schemas/GenericModel[dict]") + ) + }, + required=True, + ) + + assert op.get.responses["200"] + assert op.get.responses["200"].content["application/json"].schema == Reference( + "#/components/schemas/GenericModel[dict]" + ) + + assert spec.components.schemas["GenericModel[dict]"] == Schema( + Schema.Type.OBJECT, + properties={ + "a": Schema( + Schema.Type.OBJECT, additionalProperties=Schema(Schema.Type.STRING) + ), + "b": ArraySchema( + Schema( + Schema.Type.OBJECT, additionalProperties=Schema(Schema.Type.STRING) + ) + ), + }, + required=["a"], + )