Skip to content

Commit b8529fb

Browse files
authored
OpenAPI and BaseTemplateController (#361)
* render_template now accepts kwargs * fix openapi request bodies * Fix testing
1 parent e385935 commit b8529fb

11 files changed

Lines changed: 337 additions & 51 deletions

File tree

docs/en/docs/contrib/openapi.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,33 @@ and **not** list `user_id` again under `in: query`.
192192

193193
Always use distinct names for path and query parameters to avoid confusion. For example, use `user_id` in the path and `userIdQuery` in the query.
194194

195+
### Request Bodies
196+
197+
Use `request_body` in `@openapi(...)` to document inbound payloads.
198+
199+
```python
200+
{!> ../../../docs_src/openapi/request_body.py !}
201+
```
202+
203+
You can provide `request_body` as:
204+
205+
* A model annotation (for example `Item`).
206+
* A list annotation (for example `list[Item]` or `[Item]`).
207+
* A JSON Schema dictionary.
208+
* A complete OpenAPI `requestBody` object (with `content`).
209+
210+
For writing methods (`POST`, `PUT`, `PATCH`), the request body is now emitted even if you omit explicit `responses`.
211+
212+
#### File Uploads (Multipart)
213+
214+
For upload endpoints, model file fields as `bytes` and set `media_type` to `multipart/form-data`:
215+
216+
```python
217+
{!> ../../../docs_src/openapi/request_body_upload.py !}
218+
```
219+
220+
When the generated schema contains `format: binary`, Lilya defaults the request body media type to `multipart/form-data` if you don't set `media_type`.
221+
195222
---
196223

197224
### Response Models

docs/en/docs/release-notes.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ hide:
55

66
# Release Notes
77

8+
## 0.24.3
9+
10+
### Fixed
11+
12+
- `lilya.contrib.openapi` now emits `requestBody` for writing methods (`POST`, `PUT`, `PATCH`) even when `responses` are not explicitly declared.
13+
- `@openapi(request_body=...)` now supports multipart upload request bodies correctly, including binary fields (`format: binary`) and proper `multipart/form-data` content generation.
14+
- `BaseTemplateController.render_template()` now accepts **kwargs.
15+
816
## 0.24.2
917

1018
### Added

docs_src/openapi/request_body.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from pydantic import BaseModel
2+
3+
from lilya.contrib.openapi.decorator import openapi
4+
5+
6+
class CreateItemBody(BaseModel):
7+
name: str
8+
quantity: int
9+
10+
11+
@openapi(summary="Create item", request_body=CreateItemBody)
12+
async def create_item(request): ...
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from pydantic import BaseModel
2+
3+
from lilya.contrib.openapi.decorator import openapi
4+
from lilya.enums import MediaType
5+
6+
7+
class UploadBody(BaseModel):
8+
user: str
9+
file: bytes
10+
11+
12+
@openapi(
13+
summary="Upload file",
14+
request_body=UploadBody,
15+
media_type=MediaType.MULTIPART,
16+
)
17+
async def upload_file(request): ...

lilya/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.24.2"
1+
__version__ = "0.24.3"

lilya/contrib/openapi/decorator.py

Lines changed: 106 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import inspect
22
from collections.abc import Callable, Sequence
33
from functools import lru_cache, wraps
4-
from typing import Annotated, Any, cast, get_args, get_origin
4+
from typing import Annotated, Any, cast, get_origin
5+
6+
from pydantic import TypeAdapter
57

68
from lilya._internal._responses import BaseHandler
79
from lilya._utils import is_function
@@ -12,6 +14,91 @@
1214
from lilya.contrib.security.base import SecurityBase, SecurityScheme
1315

1416
SUCCESSFUL_RESPONSE = "Successful response"
17+
DEFAULT_REQUEST_MEDIA_TYPE = "application/json"
18+
MULTIPART_REQUEST_MEDIA_TYPE = "multipart/form-data"
19+
REQUEST_BODY_SCHEMA_KEYS = {
20+
"$ref",
21+
"allOf",
22+
"anyOf",
23+
"format",
24+
"items",
25+
"oneOf",
26+
"properties",
27+
"required",
28+
"type",
29+
}
30+
31+
32+
def _is_openapi_request_body_dict(value: Any) -> bool:
33+
"""
34+
Determine whether a value is already an OpenAPI ``requestBody`` object.
35+
"""
36+
return isinstance(value, dict) and "content" in value and isinstance(value["content"], dict)
37+
38+
39+
def _is_json_schema_dict(value: Any) -> bool:
40+
"""
41+
Determine whether a dictionary looks like a JSON Schema object.
42+
"""
43+
return isinstance(value, dict) and any(key in value for key in REQUEST_BODY_SCHEMA_KEYS)
44+
45+
46+
def _schema_contains_binary_format(schema: Any) -> bool:
47+
"""
48+
Recursively detect whether a JSON Schema contains ``format: binary``.
49+
"""
50+
if isinstance(schema, dict):
51+
if schema.get("format") == "binary":
52+
return True
53+
return any(_schema_contains_binary_format(value) for value in schema.values())
54+
if isinstance(schema, list):
55+
return any(_schema_contains_binary_format(value) for value in schema)
56+
return False
57+
58+
59+
def _build_request_body_metadata(
60+
request_body: Any | None,
61+
media_type: str | None,
62+
) -> dict[str, Any]:
63+
"""
64+
Normalize the ``request_body`` decorator input into an OpenAPI ``requestBody`` object.
65+
66+
Supported forms are:
67+
- A Python annotation or model (e.g. ``MyModel`` or ``list[MyModel]``).
68+
- A single-item list/tuple shorthand (e.g. ``[MyModel]``).
69+
- A JSON Schema dictionary.
70+
- A complete OpenAPI ``requestBody`` dictionary containing ``content``.
71+
"""
72+
if request_body is None:
73+
return {}
74+
75+
if _is_openapi_request_body_dict(request_body):
76+
return request_body # type: ignore
77+
78+
if _is_json_schema_dict(request_body):
79+
schema = request_body
80+
else:
81+
if isinstance(request_body, (list, tuple)):
82+
if len(request_body) != 1:
83+
raise ValueError(
84+
"Request body list/tuple representation accepts a single model only. "
85+
"Example: request_body=[MyModel]."
86+
)
87+
annotation: Any = list[request_body[0]] # type: ignore
88+
elif get_origin(request_body) in (list, tuple, Sequence):
89+
annotation = request_body
90+
else:
91+
annotation = request_body
92+
93+
converted_annotation = convert_annotation_to_pydantic_model(annotation)
94+
schema = TypeAdapter(converted_annotation).json_schema()
95+
96+
selected_media_type = media_type or (
97+
MULTIPART_REQUEST_MEDIA_TYPE
98+
if _schema_contains_binary_format(schema)
99+
else DEFAULT_REQUEST_MEDIA_TYPE
100+
)
101+
return {"content": {selected_media_type: {"schema": schema}}}
15102

16103

17104
class OpenAPIMethod:
@@ -37,6 +124,9 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
37124

38125
@lru_cache
39126
def get_signature(func: Callable[..., Any]) -> inspect.Signature:
127+
"""
128+
Cache and return the function signature used by the response handler wrapper.
129+
"""
40130
return inspect.Signature.from_callable(func)
41131

42132

@@ -77,8 +167,8 @@ def openapi(
77167
str | None,
78168
Doc(
79169
"""
80-
The string indicating the content media type of the handler (e.g. "application/json").
81-
Used for OpenAPI.
170+
The media type used for the OpenAPI request body when `request_body` is provided
171+
(e.g. "application/json" or "multipart/form-data").
82172
"""
83173
),
84174
] = None,
@@ -152,7 +242,9 @@ def openapi(
152242
Any | None,
153243
Doc(
154244
"""
155-
Overridable request body for the payload to be sent.
245+
Overridable request body definition.
246+
It accepts a model annotation, a JSON Schema object, or a complete OpenAPI
247+
requestBody object (with `content`).
156248
"""
157249
),
158250
] = None,
@@ -169,6 +261,9 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
169261
return handler.handle_response(func, other_signature=signature)
170262

171263
def response_models(responses: dict[int, OpenAPIResponse] | None = None) -> Any:
264+
"""
265+
Convert OpenAPI response model declarations into internal response metadata.
266+
"""
172267
responses: dict[int, ResponseParam] = {} if responses is None else responses # type: ignore
173268

174269
if responses:
@@ -191,6 +286,9 @@ def response_models(responses: dict[int, OpenAPIResponse] | None = None) -> Any:
191286
def query_strings(
192287
query_dict: dict[str, Query] | dict[str, Any] | set[str] | None = None,
193288
) -> dict[str, Query]:
289+
"""
290+
Normalize query parameter declarations into ``Query`` instances.
291+
"""
194292
if query_dict is None:
195293
return {}
196294

@@ -211,40 +309,12 @@ def query_strings(
211309
"Query must be a dict or set or a dict of key-pair value of str and Query"
212310
)
213311

214-
def get_request_body(
215-
request_body: Any | None = None,
216-
) -> Any:
217-
if request_body is None:
218-
return {}
219-
220-
if isinstance(request_body, (list, tuple)):
221-
model: Any = request_body[0]
222-
annotation = list[model]
223-
elif get_origin(request_body) is list:
224-
annotation = request_body
225-
else:
226-
annotation = request_body
227-
228-
# 2. Convert to Pydantic model (handles encodings/nested types)
229-
converted_annotation = convert_annotation_to_pydantic_model(annotation)
230-
231-
# 3. Generate Schema
232-
# We must check if it's a list/sequence again on the converted annotation
233-
origin = get_origin(converted_annotation)
234-
235-
if origin in (list, tuple, Sequence):
236-
# It is a list: Extract args and get schema of the inner model
237-
args = get_args(converted_annotation)
238-
# Return as a list containing the schema, matching Lilya's expectation
239-
body_fields = [args[0].model_json_schema()]
240-
else:
241-
# It is a single model: Get schema directly
242-
body_fields = converted_annotation.model_json_schema()
243-
return body_fields
244-
245312
def handle_security_requirement(
246313
security_requirements: Sequence[Any] | None,
247314
) -> list[dict[str, Any]] | None:
315+
"""
316+
Normalize security requirements into OpenAPI security scheme declarations.
317+
"""
248318
security_schemes = []
249319
security_definitions: dict[str, dict[str, Any]] = {}
250320

@@ -291,7 +361,7 @@ def handle_security_requirement(
291361
"query": query_strings(query) or {},
292362
}
293363

294-
body_fields = get_request_body(request_body)
364+
body_fields = _build_request_body_metadata(request_body, media_type)
295365
wrapper.openapi_meta["request_body"] = body_fields
296366

297367
if is_function(func) and not inspect.ismethod(func):

lilya/contrib/openapi/utils.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,38 @@
1212
from lilya.controllers import Controller
1313
from lilya.enums import HTTPMethod
1414

15+
DEFAULT_REQUEST_MEDIA_TYPE = "application/json"
16+
17+
18+
def _normalize_request_body_operation(
19+
request_body: Any,
20+
media_type: str | None = None,
21+
) -> dict[str, Any]:
22+
"""
23+
Normalize request body metadata into a valid OpenAPI ``requestBody`` object.
24+
25+
This keeps backward compatibility with older metadata shapes where only a
26+
plain schema (or a one-item list for array schema) was stored.
27+
"""
28+
if not request_body:
29+
return {}
30+
31+
if isinstance(request_body, dict) and "content" in request_body:
32+
return request_body
33+
34+
if isinstance(request_body, list):
35+
schema_payload = {"type": "array", "items": request_body[0]}
36+
else:
37+
schema_payload = request_body
38+
39+
request_media_type = media_type or DEFAULT_REQUEST_MEDIA_TYPE
40+
return {"content": {request_media_type: {"schema": schema_payload}}}
41+
1542

1643
def extract_http_methods_from_endpoint(cls: type) -> list[str]:
44+
"""
45+
Extract HTTP method names implemented by a controller class.
46+
"""
1747
return [
1848
method.upper()
1949
for method in HTTPMethod.to_list()
@@ -37,6 +67,9 @@ def get_openapi(
3767
license: dict[str, Any] | None = None,
3868
webhooks: Sequence[Any] | None = None,
3969
) -> dict[str, Any]:
70+
"""
71+
Generate the OpenAPI specification dictionary for a Lilya application.
72+
"""
4073
info = {
4174
"title": title,
4275
"version": version,
@@ -158,6 +191,14 @@ def _gather_routes(routes_list: Sequence[Any], prefix: str) -> list[tuple[str, A
158191
else:
159192
operation["parameters"].append(query_param)
160193

194+
if m_lower in WRITING_METHODS:
195+
request_body = _normalize_request_body_operation(
196+
request_body=meta.get("request_body", {}),
197+
media_type=meta.get("media_type"),
198+
)
199+
if request_body:
200+
operation["requestBody"] = request_body
201+
161202
responses_obj = {}
162203
seen_responses = meta.get("responses", {})
163204
schema_generator = GenerateJsonSchema(ref_template=REF_TEMPLATE)
@@ -192,18 +233,6 @@ def _gather_routes(routes_list: Sequence[Any], prefix: str) -> list[tuple[str, A
192233
for name, schema_def in definitions.items():
193234
spec["components"]["schemas"][name] = schema_def # type: ignore
194235

195-
# For the request body
196-
request_body = meta.get("request_body", {})
197-
if m_lower in WRITING_METHODS and request_body:
198-
if isinstance(request_body, list):
199-
schema_payload = {"type": "array", "items": request_body[0]}
200-
else:
201-
schema_payload = request_body
202-
203-
operation["requestBody"] = {
204-
"content": {"application/json": {"schema": schema_payload}}
205-
}
206-
207236
for status_code_int, response in seen_responses.items():
208237
status_code = str(status_code_int)
209238
desc = getattr(response, "description", "") or ""

lilya/templating/controllers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ async def get_context_data(self, request: Request, **kwargs: Any) -> dict:
213213
return context
214214

215215
async def render_template(
216-
self, request: Request, context: dict[str, Any] | None = None
216+
self, request: Request, context: dict[str, Any] | None = None, **kwargs: Any
217217
) -> HTMLResponse:
218218
"""
219219
Renders the specified template with the given context into an HTMLResponse.

tests/app_settings/test_settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def test_dict_default():
7575
"ptpython_config_file": "~/.config/ptpython/config.py",
7676
"debug": False,
7777
"environment": "production",
78-
"version": "0.24.2",
78+
"version": settings.version,
7979
"include_in_schema": True,
8080
"default_route_pattern": "route_patterns",
8181
"enforce_return_annotation": False,

0 commit comments

Comments
 (0)