11import inspect
22from collections .abc import Callable , Sequence
33from 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
68from lilya ._internal ._responses import BaseHandler
79from lilya ._utils import is_function
1214from lilya .contrib .security .base import SecurityBase , SecurityScheme
1315
1416SUCCESSFUL_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
17104class OpenAPIMethod :
@@ -37,6 +124,9 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
37124
38125@lru_cache
39126def 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 ):
0 commit comments