From 73510a511307c9f40b2351268458051a437c2883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Wed, 27 Dec 2023 16:50:42 +0100 Subject: [PATCH] Dict and any support --- CHANGELOG.md | 7 ++++++ docs/openapi.md | 4 +++ src/uapi/openapi.py | 35 +++++++++++++++++++++++--- tests/apps.py | 18 ++++++++++++++ tests/openapi/test_openapi_attrs.py | 38 +++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 968c689..d73d079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ 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. +- Dictionaries are now supported in the OpenAPI schema, rendering to object schemas with `additionalProperties`. + ## [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..613de67 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), ] diff --git a/tests/apps.py b/tests/apps.py index 5a55211..29e81d5 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]: + """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"], + )