Skip to content

Commit

Permalink
Dict and any support (#58)
Browse files Browse the repository at this point in the history
* Dict and any support

* Tweak changelog

* Fix tests
  • Loading branch information
Tinche committed Dec 27, 2023
1 parent 0c650d5 commit 8660e25
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 3 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ The **third number** is for emergencies when we need to start branches for older

<!-- changelog follows -->

## [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
Expand Down
4 changes: 4 additions & 0 deletions docs/openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 36 additions & 3 deletions src/uapi/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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),
]

Expand All @@ -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)
Expand All @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions tests/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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([])
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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([])
Expand Down
38 changes: 38 additions & 0 deletions tests/openapi/test_openapi_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)

0 comments on commit 8660e25

Please sign in to comment.