From 0407047c6b8c293326e12fbeafb29a8f6371050a Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sat, 4 Oct 2025 18:05:15 +0100 Subject: [PATCH 1/7] Fix mixed str/non-str Literal validation --- tests/test_generator.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_generator.py b/tests/test_generator.py index 005c80d..527a888 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -354,6 +354,32 @@ async def handler(request: web.Request, *, query: QueryArgs) -> APIResponse[int] assert result[1]["type"] == "string_type" +async def test_query_literal(aiohttp_client: AiohttpClient) -> None: + schema_gen = SchemaGenerator() + + class QueryArgs(TypedDict): + foo: Literal[42, "spam"] + + @schema_gen.api() + async def handler(request: web.Request, *, query: QueryArgs) -> APIResponse[int]: + return APIResponse(query["foo"]) + + app = web.Application() + schema_gen.setup(app) + app.router.add_get("/foo", handler) + + client = await aiohttp_client(app) + async with client.get("/foo", params={"foo": 42}) as resp: + assert resp.status == 200 + result = await resp.json() + assert result == 42 + + async with client.get("/foo", params={"foo": "spam"}) as resp: + assert resp.status == 200 + result = await resp.json() + assert result == "spam" + + async def test_query_pydantic_annotations(aiohttp_client: AiohttpClient) -> None: schema_gen = SchemaGenerator() From bad71dbf63add49d4785a346f5ecbbdee054bd5f Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sat, 4 Oct 2025 18:08:11 +0100 Subject: [PATCH 2/7] Update tests/test_generator.py --- tests/test_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_generator.py b/tests/test_generator.py index 527a888..2ab8850 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -361,7 +361,7 @@ class QueryArgs(TypedDict): foo: Literal[42, "spam"] @schema_gen.api() - async def handler(request: web.Request, *, query: QueryArgs) -> APIResponse[int]: + async def handler(request: web.Request, *, query: QueryArgs) -> APIResponse[int | str]: return APIResponse(query["foo"]) app = web.Application() From 5f7bc5db34c86c0dfb89363c25b1318b84686139 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sat, 4 Oct 2025 18:10:52 +0100 Subject: [PATCH 3/7] Update generator.py --- aiohttp_apischema/generator.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/aiohttp_apischema/generator.py b/aiohttp_apischema/generator.py index 7663443..c0fe4e3 100644 --- a/aiohttp_apischema/generator.py +++ b/aiohttp_apischema/generator.py @@ -352,16 +352,8 @@ async def _on_startup(self, app: web.Application) -> None: # Strip qualifiers (Required/NotRequired) from param_type. # TODO(PY311): (remove tuple) Annotated[insp.type, *insp.metadata] param_type = Annotated[(insp.type, *insp.metadata)] if insp.metadata else insp.type - extracted_type = insp.type - while get_origin(extracted_type) is Literal: - extracted_type = get_args(extracted_type)[0] - try: - is_str = issubclass(extracted_type, str) # type: ignore[arg-type] - except TypeError: - is_str = isinstance(extracted_type, str) # Literal - # We also need to convert values to Json for runtime checking. - ann_type = param_type if is_str else Json[param_type] # type: ignore[misc,valid-type] + ann_type = param_type | Json[param_type] models.append((key, "validation", TypeAdapter(ann_type))) td[param_name] = Required[ann_type] if required else NotRequired[ann_type] endpoints["query"] = TypeAdapter(TypedDict(query.__name__, td)) # type: ignore[attr-defined,operator] From 015fb96181f6954e062211adbb41f5cac6e371be Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sat, 4 Oct 2025 18:16:07 +0100 Subject: [PATCH 4/7] Update generator.py --- aiohttp_apischema/generator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aiohttp_apischema/generator.py b/aiohttp_apischema/generator.py index c0fe4e3..8da0d03 100644 --- a/aiohttp_apischema/generator.py +++ b/aiohttp_apischema/generator.py @@ -352,9 +352,10 @@ async def _on_startup(self, app: web.Application) -> None: # Strip qualifiers (Required/NotRequired) from param_type. # TODO(PY311): (remove tuple) Annotated[insp.type, *insp.metadata] param_type = Annotated[(insp.type, *insp.metadata)] if insp.metadata else insp.type + models.append((key, "validation", TypeAdapter(ann_type))) + # We also need to convert values to Json for runtime checking. ann_type = param_type | Json[param_type] - models.append((key, "validation", TypeAdapter(ann_type))) td[param_name] = Required[ann_type] if required else NotRequired[ann_type] endpoints["query"] = TypeAdapter(TypedDict(query.__name__, td)) # type: ignore[attr-defined,operator] for code, model in endpoints["resps"].items(): From 564c1efb74bd0855de4eea93aaead5d08f0f851a Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sat, 4 Oct 2025 18:17:28 +0100 Subject: [PATCH 5/7] Update generator.py --- aiohttp_apischema/generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp_apischema/generator.py b/aiohttp_apischema/generator.py index 8da0d03..b8a28f4 100644 --- a/aiohttp_apischema/generator.py +++ b/aiohttp_apischema/generator.py @@ -352,7 +352,7 @@ async def _on_startup(self, app: web.Application) -> None: # Strip qualifiers (Required/NotRequired) from param_type. # TODO(PY311): (remove tuple) Annotated[insp.type, *insp.metadata] param_type = Annotated[(insp.type, *insp.metadata)] if insp.metadata else insp.type - models.append((key, "validation", TypeAdapter(ann_type))) + models.append((key, "validation", TypeAdapter(param_type))) # We also need to convert values to Json for runtime checking. ann_type = param_type | Json[param_type] From 3be702e458bf43defea8bba5c2dea929d0f2e610 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sat, 4 Oct 2025 18:21:05 +0100 Subject: [PATCH 6/7] Update test_generator.py --- tests/test_generator.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/test_generator.py b/tests/test_generator.py index 2ab8850..815fd33 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -314,16 +314,9 @@ async def handler(request: web.Request, *, query: QueryArgs) -> APIResponse[int] "prefixItems": [{"type": "string"}, {"type": "integer"}, {"type": "number"}]} paths = {"/foo": {"get": { "operationId": "handler", - "parameters": [{"name": "foo", "in": "query", "required": True, "schema": { - "contentMediaType": "application/json", - "contentSchema": {"type": "integer"}, "type": "string"}}, - {"name": "bar", "in": "query", "required": False, "schema": { - "contentMediaType": "application/json", - "contentSchema": bar, "type": "string"}}, - {"name": "baz", "in": "query", "required": True, "schema": { - "contentMediaType": "application/json", - "contentSchema": {"$ref": "#/components/schemas/Baz"}, - "type": "string"}}, + "parameters": [{"name": "foo", "in": "query", "required": True, "schema": {"type": "integer"}}, + {"name": "bar", "in": "query", "required": False, "schema": bar}, + {"name": "baz", "in": "query", "required": True, "schema": {"$ref": "#/components/schemas/Baz"}}, {"name": "spam", "in": "query", "required": False, "schema": { "type": "string", "const": "eggz"}}], "responses": { From ac06c7f9cea98b90b9fb7afc77f35a8403d2e86e Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sat, 4 Oct 2025 18:23:00 +0100 Subject: [PATCH 7/7] Update test_generator.py --- tests/test_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_generator.py b/tests/test_generator.py index 815fd33..86308ce 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -393,7 +393,7 @@ async def handler(request: web.Request, *, query: QueryArgs) -> APIResponse[int] schema = await resp.json() param = schema["paths"]["/foo"]["get"]["parameters"][0] - assert param["schema"]["contentSchema"]["description"] == "Some description" + assert param["schema"]["description"] == "Some description" assert param["schema"]["default"] == 42 async with client.get("/foo") as resp: