Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dict and any support #58

Merged
merged 3 commits into from
Dec 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"],
)
Loading