From efea2792b285db7f33d1a72431b4117b79905c83 Mon Sep 17 00:00:00 2001 From: Lim Yu Xi Date: Sat, 6 Jun 2026 12:17:43 +0800 Subject: [PATCH 01/10] fix: generate OpenAPI schemas for forms and models --- python/turboapi/openapi.py | 496 ++++++++++++++++++++++++++++++----- tests/test_fastapi_parity.py | 56 ++++ 2 files changed, 486 insertions(+), 66 deletions(-) diff --git a/python/turboapi/openapi.py b/python/turboapi/openapi.py index 0fb39a02..5b69ac7a 100644 --- a/python/turboapi/openapi.py +++ b/python/turboapi/openapi.py @@ -4,8 +4,55 @@ interactive API documentation at /docs (Swagger UI) and /redoc (ReDoc). """ +import copy import inspect -from typing import Any, Union, get_args, get_origin +import json +import re +import types +from typing import Annotated, Any, Union, get_args, get_origin + +from .datastructures import ( + Body, + Cookie, + File, + Form, + Header, + Path as PathMarker, + Query, + UploadFile, +) +from .security import Depends, SecurityBase, get_depends + +_BODY_METHODS = {"POST", "PUT", "PATCH", "DELETE"} +_PARAM_MARKER_TYPES = (Body, Cookie, File, Form, Header, PathMarker, Query) + +_VALIDATION_ERROR_SCHEMAS = { + "ValidationError": { + "title": "ValidationError", + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + "required": ["loc", "msg", "type"], + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, +} def generate_openapi_schema(app) -> dict: @@ -25,8 +72,9 @@ def generate_openapi_schema(app) -> dict: "description": getattr(app, "description", ""), }, "paths": {}, - "components": {"schemas": {}}, + "components": {"schemas": copy.deepcopy(_VALIDATION_ERROR_SCHEMAS)}, } + components = schema["components"]["schemas"] routes = app.registry.get_routes() for route in routes: @@ -35,7 +83,7 @@ def generate_openapi_schema(app) -> dict: handler = route.handler # Generate operation - operation = _generate_operation(handler, route) + operation = _generate_operation(handler, route, components) # Add to paths openapi_path = _convert_path(path) @@ -51,15 +99,20 @@ def _convert_path(path: str) -> str: return path -def _generate_operation(handler, route) -> dict: +def _generate_operation(handler, route, components: dict[str, Any]) -> dict: """Generate OpenAPI operation object from handler.""" + response_schema = {} + response_model = getattr(route, "response_model", None) + if response_model is not None: + response_schema = _type_to_schema(response_model, components) + operation: dict[str, Any] = { "summary": _get_summary(handler), "operationId": f"{route.method.value.lower()}_{handler.__name__}", "responses": { "200": { "description": "Successful Response", - "content": {"application/json": {"schema": {}}}, + "content": {"application/json": {"schema": response_schema}}, }, "422": { "description": "Validation Error", @@ -72,63 +125,105 @@ def _generate_operation(handler, route) -> dict: }, } - # Extract parameters from signature sig = inspect.signature(handler) parameters = [] - request_body_props = {} - - import re - + body_entries: list[dict[str, Any]] = [] path_params = set(re.findall(r"\{([^}]+)\}", route.path)) + method = route.method.value.upper() for param_name, param in sig.parameters.items(): - annotation = param.annotation - param_schema = _type_to_schema(annotation) + if _is_dependency_parameter(param): + continue + + annotation, marker = _resolve_annotation_and_marker(param) + param_schema = _schema_for_param(annotation, marker, param, components) + required = _is_required_param(param, marker) - if param_name in path_params: + if param_name in path_params or isinstance(marker, PathMarker): + parameters.append(_build_parameter(param_name, "path", True, param_schema, marker)) + elif isinstance(marker, Query): + parameters.append( + _build_parameter( + _parameter_alias(param_name, marker), + "query", + required, + param_schema, + marker, + ) + ) + elif isinstance(marker, Header): + parameters.append( + _build_parameter( + _parameter_alias(param_name, marker, location="header"), + "header", + required, + param_schema, + marker, + ) + ) + elif isinstance(marker, Cookie): parameters.append( + _build_parameter( + _parameter_alias(param_name, marker, location="cookie"), + "cookie", + required, + param_schema, + marker, + ) + ) + elif _is_form_or_file_param(annotation, marker): + media_type = getattr(marker, "media_type", None) or "multipart/form-data" + if _is_file_param(annotation, marker): + media_type = "multipart/form-data" + body_entries.append( + { + "name": _parameter_alias(param_name, marker), + "schema": param_schema, + "required": required, + "media_type": media_type, + "direct": False, + } + ) + elif isinstance(marker, Body): + body_entries.append( + { + "name": _parameter_alias(param_name, marker), + "schema": param_schema, + "required": required, + "media_type": marker.media_type, + "direct": _is_model_class(annotation) and not marker.embed, + } + ) + elif method in _BODY_METHODS and _is_model_class(annotation): + body_entries.append( { "name": param_name, - "in": "path", - "required": True, "schema": param_schema, + "required": required, + "media_type": "application/json", + "direct": True, + } + ) + elif method in _BODY_METHODS: + body_entries.append( + { + "name": param_name, + "schema": param_schema, + "required": required, + "media_type": "application/json", + "direct": False, } ) - elif route.method.value.upper() in ("POST", "PUT", "PATCH"): - # Body parameter - request_body_props[param_name] = param_schema - if param.default is not inspect.Parameter.empty: - request_body_props[param_name]["default"] = param.default else: - # Query parameter - query_param = { - "name": param_name, - "in": "query", - "schema": param_schema, - } - if param.default is inspect.Parameter.empty: - query_param["required"] = True - else: - query_param["required"] = False - if param.default is not None: - query_param["schema"]["default"] = param.default - parameters.append(query_param) + parameters.append( + _build_parameter(param_name, "query", required, param_schema, marker) + ) if parameters: operation["parameters"] = parameters - if request_body_props: - operation["requestBody"] = { - "required": True, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": request_body_props, - } - } - }, - } + if body_entries: + operation["requestBody"] = _build_request_body(body_entries) # Add tags if hasattr(route, "tags") and route.tags: @@ -147,10 +242,16 @@ def _get_summary(handler) -> str: return name.replace("_", " ").title() -def _type_to_schema(annotation) -> dict: +def _type_to_schema(annotation, components: dict[str, Any] | None = None) -> dict: """Convert Python type annotation to OpenAPI schema.""" + annotation, _metadata = _unwrap_annotated(annotation) + if annotation is inspect.Parameter.empty or annotation is Any: return {} + if annotation is type(None): + return {"type": "null"} + if _is_model_class(annotation): + return _register_model_schema(annotation, components) if annotation is str: return {"type": "string"} if annotation is int: @@ -165,37 +266,300 @@ def _type_to_schema(annotation) -> dict: return {"type": "object"} if annotation is bytes: return {"type": "string", "format": "binary"} + if _is_upload_file_type(annotation): + return {"type": "string", "format": "binary"} - # Handle typing generics origin = get_origin(annotation) - if origin is list: + if origin in (list, tuple, set, frozenset): args = get_args(annotation) - items_schema = _type_to_schema(args[0]) if args else {} + items_schema = _type_to_schema(args[0], components) if args else {} return {"type": "array", "items": items_schema} if origin is dict: + args = get_args(annotation) + schema = {"type": "object"} + if len(args) == 2: + schema["additionalProperties"] = _type_to_schema(args[1], components) + return schema + + if _is_union_type(annotation): + args = get_args(annotation) + schemas = [_type_to_schema(arg, components) for arg in args] + if len(schemas) == 1: + return schemas[0] + return {"anyOf": schemas} + + if inspect.isclass(annotation): return {"type": "object"} - # Handle Optional[X] / Union[X, None] — get_origin returns Union, not type(None) - if origin is Union: + return {} + + +def _unwrap_annotated(annotation) -> tuple[Any, tuple[Any, ...]]: + """Return the underlying annotation plus all Annotated metadata.""" + metadata: list[Any] = [] + while get_origin(annotation) is Annotated: args = get_args(annotation) - non_none = [a for a in args if a is not type(None)] - if len(non_none) == 1: - inner = _type_to_schema(non_none[0]) - inner["nullable"] = True - return inner - return {"nullable": True} - # Handle bare NoneType annotation - if annotation is type(None): - return {"nullable": True} + if not args: + break + annotation = args[0] + metadata.extend(args[1:]) + return annotation, tuple(metadata) + + +def _resolve_annotation_and_marker(param: inspect.Parameter) -> tuple[Any, Any | None]: + annotation, metadata = _unwrap_annotated(param.annotation) - # Try to get schema from Satya/Pydantic models + for item in metadata: + if isinstance(item, _PARAM_MARKER_TYPES): + return annotation, item + + if isinstance(param.default, _PARAM_MARKER_TYPES): + return annotation, param.default + + return annotation, None + + +def _is_dependency_parameter(param: inspect.Parameter) -> bool: + if isinstance(param.default, (Depends, SecurityBase)): + return True + return get_depends(param) is not None + + +def _is_required_param(param: inspect.Parameter, marker: Any | None) -> bool: + default = _effective_default(param, marker) + return default is inspect.Parameter.empty or default is ... + + +def _effective_default(param: inspect.Parameter, marker: Any | None) -> Any: + if isinstance(param.default, _PARAM_MARKER_TYPES): + return param.default.default + if param.default is not inspect.Parameter.empty: + return param.default + if marker is not None and hasattr(marker, "default"): + return marker.default + return inspect.Parameter.empty + + +def _schema_for_param( + annotation, marker: Any | None, param: inspect.Parameter, components: dict[str, Any] +) -> dict: + if _is_file_param(annotation, marker): + schema = {"type": "string", "format": "binary"} + else: + schema = _type_to_schema(annotation, components) + + schema = dict(schema) + _apply_marker_metadata(schema, marker) + + default = _effective_default(param, marker) + if default is not inspect.Parameter.empty and default is not ... and _is_jsonable(default): + schema["default"] = default + + return schema + + +def _apply_marker_metadata(schema: dict[str, Any], marker: Any | None) -> None: + if marker is None: + return + + for attr, openapi_name in ( + ("title", "title"), + ("description", "description"), + ("min_length", "minLength"), + ("max_length", "maxLength"), + ("regex", "pattern"), + ("gt", "exclusiveMinimum"), + ("ge", "minimum"), + ("lt", "exclusiveMaximum"), + ("le", "maximum"), + ): + value = getattr(marker, attr, None) + if value is not None: + schema[openapi_name] = value + + +def _build_parameter( + name: str, location: str, required: bool, schema: dict[str, Any], marker: Any | None +) -> dict[str, Any]: + parameter = { + "name": name, + "in": location, + "required": True if location == "path" else required, + "schema": schema, + } + description = getattr(marker, "description", None) + if description: + parameter["description"] = description + return parameter + + +def _build_request_body(body_entries: list[dict[str, Any]]) -> dict[str, Any]: + if any(entry["media_type"] == "multipart/form-data" for entry in body_entries): + for entry in body_entries: + entry["media_type"] = "multipart/form-data" + + content: dict[str, Any] = {} + media_types = sorted({entry["media_type"] for entry in body_entries}) + for media_type in media_types: + entries = [entry for entry in body_entries if entry["media_type"] == media_type] + if len(entries) == 1 and entries[0]["direct"]: + body_schema = entries[0]["schema"] + else: + body_schema = { + "type": "object", + "properties": {entry["name"]: entry["schema"] for entry in entries}, + } + required = [entry["name"] for entry in entries if entry["required"]] + if required: + body_schema["required"] = required + content[media_type] = {"schema": body_schema} + + return {"required": any(entry["required"] for entry in body_entries), "content": content} + + +def _parameter_alias(param_name: str, marker: Any | None, *, location: str | None = None) -> str: + alias = getattr(marker, "alias", None) + if alias: + return alias + if location == "header" and getattr(marker, "convert_underscores", True): + return param_name.replace("_", "-") + return param_name + + +def _is_form_or_file_param(annotation, marker: Any | None) -> bool: + return isinstance(marker, (Form, File)) or _is_upload_file_type(annotation) + + +def _is_file_param(annotation, marker: Any | None) -> bool: + return isinstance(marker, File) or _is_upload_file_type(annotation) + + +def _is_upload_file_type(annotation) -> bool: try: - if hasattr(annotation, "__fields__") or hasattr(annotation, "model_fields"): - return {"$ref": f"#/components/schemas/{annotation.__name__}"} - except (TypeError, AttributeError): - pass + return inspect.isclass(annotation) and issubclass(annotation, UploadFile) + except TypeError: + return False - return {} + +def _is_union_type(annotation) -> bool: + origin = get_origin(annotation) + return origin is Union or origin is types.UnionType or isinstance(annotation, types.UnionType) + + +def _is_model_class(annotation) -> bool: + try: + return inspect.isclass(annotation) and ( + hasattr(annotation, "model_json_schema") + or hasattr(annotation, "schema") + or hasattr(annotation, "model_fields") + or hasattr(annotation, "__fields__") + ) + except TypeError: + return False + + +def _register_model_schema(model_class, components: dict[str, Any] | None) -> dict[str, str]: + name = getattr(model_class, "__name__", "Model") + if components is not None and name not in components: + components[name] = {} + model_schema = _model_to_schema(model_class, components) + components[name].update(model_schema) + return {"$ref": f"#/components/schemas/{name}"} + + +def _model_to_schema(model_class, components: dict[str, Any] | None) -> dict[str, Any]: + schema: dict[str, Any] | None = None + + if hasattr(model_class, "model_json_schema"): + try: + schema = model_class.model_json_schema(ref_template="#/components/schemas/{model}") + except TypeError: + schema = model_class.model_json_schema() + except Exception: + schema = None + elif hasattr(model_class, "schema"): + try: + schema = model_class.schema(ref_template="#/components/schemas/{model}") + except TypeError: + schema = model_class.schema() + except Exception: + schema = None + + if not isinstance(schema, dict): + schema = _schema_from_annotations(model_class, components) + + schema = copy.deepcopy(schema) + _move_defs_to_components(schema, components) + _rewrite_component_refs(schema) + return schema + + +def _schema_from_annotations(model_class, components: dict[str, Any] | None) -> dict[str, Any]: + properties = {} + required = [] + annotations = getattr(model_class, "__annotations__", {}) + fields = getattr(model_class, "model_fields", getattr(model_class, "__fields__", {})) + + for field_name, annotation in annotations.items(): + properties[field_name] = _type_to_schema(annotation, components) + if _model_field_is_required(model_class, field_name, fields): + required.append(field_name) + + schema: dict[str, Any] = { + "title": getattr(model_class, "__name__", "Model"), + "type": "object", + "properties": properties, + } + if required: + schema["required"] = required + return schema + + +def _model_field_is_required(model_class, field_name: str, fields: Any) -> bool: + if isinstance(fields, dict) and field_name in fields: + field = fields[field_name] + is_required = getattr(field, "is_required", None) + if callable(is_required): + return bool(is_required()) + if isinstance(is_required, bool): + return is_required + default = getattr(field, "default", inspect.Parameter.empty) + return default is inspect.Parameter.empty or default is ... + return not hasattr(model_class, field_name) + + +def _move_defs_to_components(schema: dict[str, Any], components: dict[str, Any] | None) -> None: + if components is None: + return + for defs_key in ("$defs", "definitions"): + defs = schema.pop(defs_key, None) + if isinstance(defs, dict): + for name, value in defs.items(): + _rewrite_component_refs(value) + components.setdefault(name, value) + + +def _rewrite_component_refs(value: Any) -> None: + if isinstance(value, dict): + ref = value.get("$ref") + if isinstance(ref, str): + if ref.startswith("#/$defs/"): + value["$ref"] = "#/components/schemas/" + ref.rsplit("/", 1)[-1] + elif ref.startswith("#/definitions/"): + value["$ref"] = "#/components/schemas/" + ref.rsplit("/", 1)[-1] + for item in value.values(): + _rewrite_component_refs(item) + elif isinstance(value, list): + for item in value: + _rewrite_component_refs(item) + + +def _is_jsonable(value: Any) -> bool: + try: + json.dumps(value) + return True + except (TypeError, ValueError): + return False # HTML templates for Swagger UI and ReDoc diff --git a/tests/test_fastapi_parity.py b/tests/test_fastapi_parity.py index d7e741aa..a4122291 100644 --- a/tests/test_fastapi_parity.py +++ b/tests/test_fastapi_parity.py @@ -7,6 +7,7 @@ import json import os import tempfile +from typing import Annotated import pytest from turboapi import ( @@ -533,6 +534,61 @@ def create_item(name: str, price: float): operation = schema["paths"]["/items"]["post"] assert "requestBody" in operation + def test_openapi_skips_dependencies_and_supports_forms_and_dhi_models(self): + from dhi import BaseModel + + class SearchRequest(BaseModel): + query: str + limit: int = 10 + + class SearchResponse(BaseModel): + count: int + + def get_session(): + return {"session": True} + + SessionDep = Annotated[dict, Depends(get_session)] + app = TurboAPI(title="OpenAPICompat") + + @app.post("/login") + def login(username: str = Form(), password: str = Form()): + return {"username": username} + + @app.post("/search", response_model=SearchResponse) + def search( + session: SessionDep, + request: SearchRequest, + include_archived: bool = Query(default=False), + ): + return SearchResponse(count=request.limit) + + schema = app.openapi() + json.dumps(schema) + + login_body = schema["paths"]["/login"]["post"]["requestBody"] + form_schema = login_body["content"]["application/x-www-form-urlencoded"]["schema"] + assert form_schema["properties"]["username"] == {"type": "string"} + assert form_schema["properties"]["password"] == {"type": "string"} + assert form_schema["required"] == ["username", "password"] + + search_operation = schema["paths"]["/search"]["post"] + assert search_operation["requestBody"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/SearchRequest" + } + assert search_operation["parameters"] == [ + { + "name": "include_archived", + "in": "query", + "required": False, + "schema": {"type": "boolean", "default": False}, + } + ] + assert search_operation["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/SearchResponse" + } + assert "SearchRequest" in schema["components"]["schemas"] + assert "SearchResponse" in schema["components"]["schemas"] + def test_app_openapi_method(self): app = TurboAPI(title="AppOpenAPI") From d0ad94e44a8f0280b1171d90923d489bfb398590 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:59:58 +0800 Subject: [PATCH 02/10] fix: disambiguate OpenAPI model component names --- python/turboapi/openapi.py | 113 +++++++++++++++++++++++++++-------- tests/test_fastapi_parity.py | 40 +++++++++++++ 2 files changed, 127 insertions(+), 26 deletions(-) diff --git a/python/turboapi/openapi.py b/python/turboapi/openapi.py index 5b69ac7a..c7dbba1a 100644 --- a/python/turboapi/openapi.py +++ b/python/turboapi/openapi.py @@ -17,10 +17,12 @@ File, Form, Header, - Path as PathMarker, Query, UploadFile, ) +from .datastructures import ( + Path as PathMarker, +) from .security import Depends, SecurityBase, get_depends _BODY_METHODS = {"POST", "PUT", "PATCH", "DELETE"} @@ -55,6 +57,15 @@ } +class _SchemaContext: + """Tracks component names generated for a single OpenAPI schema.""" + + def __init__(self, components: dict[str, Any]): + self.components = components + self.model_component_names: dict[type, str] = {} + self.component_models: dict[str, type] = {} + + def generate_openapi_schema(app) -> dict: """Generate OpenAPI 3.1.0 schema from app routes. @@ -75,6 +86,7 @@ def generate_openapi_schema(app) -> dict: "components": {"schemas": copy.deepcopy(_VALIDATION_ERROR_SCHEMAS)}, } components = schema["components"]["schemas"] + schema_context = _SchemaContext(components) routes = app.registry.get_routes() for route in routes: @@ -83,7 +95,7 @@ def generate_openapi_schema(app) -> dict: handler = route.handler # Generate operation - operation = _generate_operation(handler, route, components) + operation = _generate_operation(handler, route, schema_context) # Add to paths openapi_path = _convert_path(path) @@ -99,12 +111,12 @@ def _convert_path(path: str) -> str: return path -def _generate_operation(handler, route, components: dict[str, Any]) -> dict: +def _generate_operation(handler, route, schema_context: _SchemaContext) -> dict: """Generate OpenAPI operation object from handler.""" response_schema = {} response_model = getattr(route, "response_model", None) if response_model is not None: - response_schema = _type_to_schema(response_model, components) + response_schema = _type_to_schema(response_model, schema_context) operation: dict[str, Any] = { "summary": _get_summary(handler), @@ -136,7 +148,7 @@ def _generate_operation(handler, route, components: dict[str, Any]) -> dict: continue annotation, marker = _resolve_annotation_and_marker(param) - param_schema = _schema_for_param(annotation, marker, param, components) + param_schema = _schema_for_param(annotation, marker, param, schema_context) required = _is_required_param(param, marker) if param_name in path_params or isinstance(marker, PathMarker): @@ -242,7 +254,7 @@ def _get_summary(handler) -> str: return name.replace("_", " ").title() -def _type_to_schema(annotation, components: dict[str, Any] | None = None) -> dict: +def _type_to_schema(annotation, schema_context: _SchemaContext | None = None) -> dict: """Convert Python type annotation to OpenAPI schema.""" annotation, _metadata = _unwrap_annotated(annotation) @@ -251,7 +263,7 @@ def _type_to_schema(annotation, components: dict[str, Any] | None = None) -> dic if annotation is type(None): return {"type": "null"} if _is_model_class(annotation): - return _register_model_schema(annotation, components) + return _register_model_schema(annotation, schema_context) if annotation is str: return {"type": "string"} if annotation is int: @@ -272,18 +284,18 @@ def _type_to_schema(annotation, components: dict[str, Any] | None = None) -> dic origin = get_origin(annotation) if origin in (list, tuple, set, frozenset): args = get_args(annotation) - items_schema = _type_to_schema(args[0], components) if args else {} + items_schema = _type_to_schema(args[0], schema_context) if args else {} return {"type": "array", "items": items_schema} if origin is dict: args = get_args(annotation) schema = {"type": "object"} if len(args) == 2: - schema["additionalProperties"] = _type_to_schema(args[1], components) + schema["additionalProperties"] = _type_to_schema(args[1], schema_context) return schema if _is_union_type(annotation): args = get_args(annotation) - schemas = [_type_to_schema(arg, components) for arg in args] + schemas = [_type_to_schema(arg, schema_context) for arg in args] if len(schemas) == 1: return schemas[0] return {"anyOf": schemas} @@ -341,12 +353,12 @@ def _effective_default(param: inspect.Parameter, marker: Any | None) -> Any: def _schema_for_param( - annotation, marker: Any | None, param: inspect.Parameter, components: dict[str, Any] + annotation, marker: Any | None, param: inspect.Parameter, schema_context: _SchemaContext ) -> dict: if _is_file_param(annotation, marker): schema = {"type": "string", "format": "binary"} else: - schema = _type_to_schema(annotation, components) + schema = _type_to_schema(annotation, schema_context) schema = dict(schema) _apply_marker_metadata(schema, marker) @@ -458,16 +470,61 @@ def _is_model_class(annotation) -> bool: return False -def _register_model_schema(model_class, components: dict[str, Any] | None) -> dict[str, str]: - name = getattr(model_class, "__name__", "Model") - if components is not None and name not in components: - components[name] = {} - model_schema = _model_to_schema(model_class, components) - components[name].update(model_schema) +def _register_model_schema(model_class, schema_context: _SchemaContext | None) -> dict[str, str]: + if schema_context is None: + name = _component_base_name(model_class) + return {"$ref": f"#/components/schemas/{name}"} + + name = _component_name_for_model(model_class, schema_context) + if name not in schema_context.components: + schema_context.components[name] = {} + model_schema = _model_to_schema(model_class, schema_context) + schema_context.components[name].update(model_schema) return {"$ref": f"#/components/schemas/{name}"} -def _model_to_schema(model_class, components: dict[str, Any] | None) -> dict[str, Any]: +def _component_name_for_model(model_class, schema_context: _SchemaContext) -> str: + existing_name = schema_context.model_component_names.get(model_class) + if existing_name is not None: + return existing_name + + base_name = _component_base_name(model_class) + existing_model = schema_context.component_models.get(base_name) + if base_name not in schema_context.components or existing_model is model_class: + name = base_name + else: + name = _unique_component_name(model_class, base_name, schema_context) + + schema_context.model_component_names[model_class] = name + schema_context.component_models[name] = model_class + return name + + +def _component_base_name(model_class) -> str: + return _sanitize_component_name(getattr(model_class, "__name__", "Model")) + + +def _unique_component_name(model_class, base_name: str, schema_context: _SchemaContext) -> str: + module = getattr(model_class, "__module__", "") + qualname = getattr(model_class, "__qualname__", base_name) + qualified_name = ".".join(part for part in (module, qualname) if part) + candidate = _sanitize_component_name(qualified_name) + if candidate == base_name: + candidate = f"{base_name}_2" + + name = candidate + index = 2 + while name in schema_context.components or name in schema_context.component_models: + name = f"{candidate}_{index}" + index += 1 + return name + + +def _sanitize_component_name(name: str) -> str: + return re.sub(r"[^a-zA-Z0-9.\-_]+", "_", name).strip("._-") or "Model" + + +def _model_to_schema(model_class, schema_context: _SchemaContext | None) -> dict[str, Any]: schema: dict[str, Any] | None = None if hasattr(model_class, "model_json_schema"): @@ -486,22 +543,24 @@ def _model_to_schema(model_class, components: dict[str, Any] | None) -> dict[str schema = None if not isinstance(schema, dict): - schema = _schema_from_annotations(model_class, components) + schema = _schema_from_annotations(model_class, schema_context) schema = copy.deepcopy(schema) - _move_defs_to_components(schema, components) + _move_defs_to_components(schema, schema_context) _rewrite_component_refs(schema) return schema -def _schema_from_annotations(model_class, components: dict[str, Any] | None) -> dict[str, Any]: +def _schema_from_annotations( + model_class, schema_context: _SchemaContext | None +) -> dict[str, Any]: properties = {} required = [] annotations = getattr(model_class, "__annotations__", {}) fields = getattr(model_class, "model_fields", getattr(model_class, "__fields__", {})) for field_name, annotation in annotations.items(): - properties[field_name] = _type_to_schema(annotation, components) + properties[field_name] = _type_to_schema(annotation, schema_context) if _model_field_is_required(model_class, field_name, fields): required.append(field_name) @@ -528,15 +587,17 @@ def _model_field_is_required(model_class, field_name: str, fields: Any) -> bool: return not hasattr(model_class, field_name) -def _move_defs_to_components(schema: dict[str, Any], components: dict[str, Any] | None) -> None: - if components is None: +def _move_defs_to_components( + schema: dict[str, Any], schema_context: _SchemaContext | None +) -> None: + if schema_context is None: return for defs_key in ("$defs", "definitions"): defs = schema.pop(defs_key, None) if isinstance(defs, dict): for name, value in defs.items(): _rewrite_component_refs(value) - components.setdefault(name, value) + schema_context.components.setdefault(name, value) def _rewrite_component_refs(value: Any) -> None: diff --git a/tests/test_fastapi_parity.py b/tests/test_fastapi_parity.py index a4122291..c2577476 100644 --- a/tests/test_fastapi_parity.py +++ b/tests/test_fastapi_parity.py @@ -589,6 +589,46 @@ def search( assert "SearchRequest" in schema["components"]["schemas"] assert "SearchResponse" in schema["components"]["schemas"] + def test_openapi_disambiguates_same_named_model_components(self): + from dhi import BaseModel + + FirstItem = type("Item", (BaseModel,), {"__annotations__": {"name": str}}) + SecondItem = type("Item", (BaseModel,), {"__annotations__": {"count": int}}) + + app = TurboAPI(title="OpenAPIModels") + + @app.post("/first", response_model=FirstItem) + def create_first(item: FirstItem): + return item + + @app.post("/second", response_model=SecondItem) + def create_second(item: SecondItem): + return item + + schema = app.openapi() + first_request_ref = schema["paths"]["/first"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"]["$ref"] + second_request_ref = schema["paths"]["/second"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"]["$ref"] + first_response_ref = schema["paths"]["/first"]["post"]["responses"]["200"]["content"][ + "application/json" + ]["schema"]["$ref"] + second_response_ref = schema["paths"]["/second"]["post"]["responses"]["200"]["content"][ + "application/json" + ]["schema"]["$ref"] + + assert first_request_ref == first_response_ref + assert second_request_ref == second_response_ref + assert first_request_ref != second_request_ref + + first_component = first_request_ref.rsplit("/", 1)[-1] + second_component = second_request_ref.rsplit("/", 1)[-1] + components = schema["components"]["schemas"] + assert components[first_component]["properties"] == {"name": {"type": "string"}} + assert components[second_component]["properties"] == {"count": {"type": "integer"}} + def test_app_openapi_method(self): app = TurboAPI(title="AppOpenAPI") From 0049fb5eca0c66f486af1cfec7ad19fca424a3e2 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:07:25 +0800 Subject: [PATCH 03/10] fix: disambiguate nested OpenAPI definitions --- python/turboapi/openapi.py | 71 ++++++++++++++++++++++++++++++------ tests/test_fastapi_parity.py | 56 ++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/python/turboapi/openapi.py b/python/turboapi/openapi.py index c7dbba1a..75920cdf 100644 --- a/python/turboapi/openapi.py +++ b/python/turboapi/openapi.py @@ -512,12 +512,33 @@ def _unique_component_name(model_class, base_name: str, schema_context: _SchemaC if candidate == base_name: candidate = f"{base_name}_2" - name = candidate + reserved_names = set(schema_context.components) | set(schema_context.component_models) + return _next_available_component_name(candidate, reserved_names) + + +def _component_name_for_definition( + name: str, value: Any, schema_context: _SchemaContext, reserved_names: set[str] +) -> str: + base_name = _sanitize_component_name(name) + if ( + base_name in schema_context.components + and base_name not in schema_context.component_models + and schema_context.components[base_name] == value + ): + return base_name + return _next_available_component_name(base_name, reserved_names) + + +def _next_available_component_name(base_name: str, reserved_names: set[str]) -> str: + if base_name not in reserved_names: + return base_name + index = 2 - while name in schema_context.components or name in schema_context.component_models: - name = f"{candidate}_{index}" + while True: + candidate = f"{base_name}_{index}" + if candidate not in reserved_names: + return candidate index += 1 - return name def _sanitize_component_name(name: str) -> str: @@ -592,27 +613,55 @@ def _move_defs_to_components( ) -> None: if schema_context is None: return + + defs_groups = [] for defs_key in ("$defs", "definitions"): defs = schema.pop(defs_key, None) if isinstance(defs, dict): - for name, value in defs.items(): - _rewrite_component_refs(value) - schema_context.components.setdefault(name, value) + defs_groups.append((defs_key, defs)) + if not defs_groups: + return -def _rewrite_component_refs(value: Any) -> None: + component_names: dict[tuple[str, str], str] = {} + ref_rewrites: dict[str, str] = {} + reserved_names = set(schema_context.components) | set(schema_context.component_models) + + for defs_key, defs in defs_groups: + for name, value in defs.items(): + component_name = _component_name_for_definition( + name, value, schema_context, reserved_names + ) + component_names[(defs_key, name)] = component_name + reserved_names.add(component_name) + ref_rewrites[f"#/{defs_key}/{name}"] = f"#/components/schemas/{component_name}" + + _rewrite_component_refs(schema, ref_rewrites) + for defs_key, defs in defs_groups: + for name, value in defs.items(): + component_name = component_names[(defs_key, name)] + component_schema = copy.deepcopy(value) + _rewrite_component_refs(component_schema, ref_rewrites) + schema_context.components.setdefault(component_name, component_schema) + + +def _rewrite_component_refs( + value: Any, ref_rewrites: dict[str, str] | None = None +) -> None: if isinstance(value, dict): ref = value.get("$ref") if isinstance(ref, str): - if ref.startswith("#/$defs/"): + if ref_rewrites and ref in ref_rewrites: + value["$ref"] = ref_rewrites[ref] + elif ref.startswith("#/$defs/"): value["$ref"] = "#/components/schemas/" + ref.rsplit("/", 1)[-1] elif ref.startswith("#/definitions/"): value["$ref"] = "#/components/schemas/" + ref.rsplit("/", 1)[-1] for item in value.values(): - _rewrite_component_refs(item) + _rewrite_component_refs(item, ref_rewrites) elif isinstance(value, list): for item in value: - _rewrite_component_refs(item) + _rewrite_component_refs(item, ref_rewrites) def _is_jsonable(value: Any) -> bool: diff --git a/tests/test_fastapi_parity.py b/tests/test_fastapi_parity.py index c2577476..64d0f653 100644 --- a/tests/test_fastapi_parity.py +++ b/tests/test_fastapi_parity.py @@ -629,6 +629,62 @@ def create_second(item: SecondItem): assert components[first_component]["properties"] == {"name": {"type": "string"}} assert components[second_component]["properties"] == {"count": {"type": "integer"}} + def test_openapi_disambiguates_same_named_nested_model_definitions(self): + class FirstParent: + @classmethod + def model_json_schema(cls, ref_template=None): + return { + "title": "FirstParent", + "type": "object", + "properties": {"child": {"$ref": "#/$defs/Nested"}}, + "$defs": { + "Nested": { + "title": "Nested", + "type": "object", + "properties": {"name": {"type": "string"}}, + } + }, + } + + class SecondParent: + @classmethod + def model_json_schema(cls, ref_template=None): + return { + "title": "SecondParent", + "type": "object", + "properties": {"child": {"$ref": "#/$defs/Nested"}}, + "$defs": { + "Nested": { + "title": "Nested", + "type": "object", + "properties": {"count": {"type": "integer"}}, + } + }, + } + + app = TurboAPI(title="OpenAPINestedModels") + + @app.post("/first-nested", response_model=FirstParent) + def create_first_nested(item: FirstParent): + return item + + @app.post("/second-nested", response_model=SecondParent) + def create_second_nested(item: SecondParent): + return item + + schema = app.openapi() + components = schema["components"]["schemas"] + first_nested_ref = components["FirstParent"]["properties"]["child"]["$ref"] + second_nested_ref = components["SecondParent"]["properties"]["child"]["$ref"] + + assert first_nested_ref != second_nested_ref + first_nested_component = first_nested_ref.rsplit("/", 1)[-1] + second_nested_component = second_nested_ref.rsplit("/", 1)[-1] + assert components[first_nested_component]["properties"] == {"name": {"type": "string"}} + assert components[second_nested_component]["properties"] == { + "count": {"type": "integer"} + } + def test_app_openapi_method(self): app = TurboAPI(title="AppOpenAPI") From 3cc77977e8499cbec0a9f45ece60659e49cb8289 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:44:04 +0800 Subject: [PATCH 04/10] fix: rewrite component refs for renamed definitions --- python/turboapi/openapi.py | 4 +++- tests/test_fastapi_parity.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/python/turboapi/openapi.py b/python/turboapi/openapi.py index 75920cdf..d6a24f61 100644 --- a/python/turboapi/openapi.py +++ b/python/turboapi/openapi.py @@ -634,7 +634,9 @@ def _move_defs_to_components( ) component_names[(defs_key, name)] = component_name reserved_names.add(component_name) - ref_rewrites[f"#/{defs_key}/{name}"] = f"#/components/schemas/{component_name}" + component_ref = f"#/components/schemas/{component_name}" + ref_rewrites[f"#/{defs_key}/{name}"] = component_ref + ref_rewrites[f"#/components/schemas/{name}"] = component_ref _rewrite_component_refs(schema, ref_rewrites) for defs_key, defs in defs_groups: diff --git a/tests/test_fastapi_parity.py b/tests/test_fastapi_parity.py index 64d0f653..846cb834 100644 --- a/tests/test_fastapi_parity.py +++ b/tests/test_fastapi_parity.py @@ -636,7 +636,7 @@ def model_json_schema(cls, ref_template=None): return { "title": "FirstParent", "type": "object", - "properties": {"child": {"$ref": "#/$defs/Nested"}}, + "properties": {"child": {"$ref": "#/components/schemas/Nested"}}, "$defs": { "Nested": { "title": "Nested", @@ -652,7 +652,7 @@ def model_json_schema(cls, ref_template=None): return { "title": "SecondParent", "type": "object", - "properties": {"child": {"$ref": "#/$defs/Nested"}}, + "properties": {"child": {"$ref": "#/components/schemas/Nested"}}, "$defs": { "Nested": { "title": "Nested", From a1d73e620315433188a1b75ccf086ec6ca92e383 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:08:45 +0800 Subject: [PATCH 05/10] fix: align OpenAPI refs with runtime support --- python/turboapi/openapi.py | 19 +++----- tests/test_fastapi_parity.py | 93 ++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 12 deletions(-) diff --git a/python/turboapi/openapi.py b/python/turboapi/openapi.py index d6a24f61..972ff193 100644 --- a/python/turboapi/openapi.py +++ b/python/turboapi/openapi.py @@ -322,6 +322,11 @@ def _resolve_annotation_and_marker(param: inspect.Parameter) -> tuple[Any, Any | annotation, metadata = _unwrap_annotated(param.annotation) for item in metadata: + # Runtime form/file parsing currently only honors default-value markers + # (or a direct UploadFile annotation). Do not advertise Annotated + # Form/File metadata as form uploads until request handling supports it. + if isinstance(item, (Form, File)): + continue if isinstance(item, _PARAM_MARKER_TYPES): return annotation, item @@ -516,16 +521,8 @@ def _unique_component_name(model_class, base_name: str, schema_context: _SchemaC return _next_available_component_name(candidate, reserved_names) -def _component_name_for_definition( - name: str, value: Any, schema_context: _SchemaContext, reserved_names: set[str] -) -> str: +def _component_name_for_definition(name: str, reserved_names: set[str]) -> str: base_name = _sanitize_component_name(name) - if ( - base_name in schema_context.components - and base_name not in schema_context.component_models - and schema_context.components[base_name] == value - ): - return base_name return _next_available_component_name(base_name, reserved_names) @@ -629,9 +626,7 @@ def _move_defs_to_components( for defs_key, defs in defs_groups: for name, value in defs.items(): - component_name = _component_name_for_definition( - name, value, schema_context, reserved_names - ) + component_name = _component_name_for_definition(name, reserved_names) component_names[(defs_key, name)] = component_name reserved_names.add(component_name) component_ref = f"#/components/schemas/{component_name}" diff --git a/tests/test_fastapi_parity.py b/tests/test_fastapi_parity.py index 846cb834..112ab18b 100644 --- a/tests/test_fastapi_parity.py +++ b/tests/test_fastapi_parity.py @@ -685,6 +685,99 @@ def create_second_nested(item: SecondParent): "count": {"type": "integer"} } + def test_openapi_disambiguates_nested_definitions_after_ref_rewriting(self): + class FirstParent: + @classmethod + def model_json_schema(cls, ref_template=None): + return { + "title": "FirstParent", + "type": "object", + "properties": {"child": {"$ref": "#/components/schemas/Child"}}, + "$defs": { + "Child": { + "title": "Child", + "type": "object", + "properties": { + "inner": {"$ref": "#/components/schemas/Inner"} + }, + }, + "Inner": { + "title": "Inner", + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + }, + } + + class SecondParent: + @classmethod + def model_json_schema(cls, ref_template=None): + return { + "title": "SecondParent", + "type": "object", + "properties": {"child": {"$ref": "#/components/schemas/Child"}}, + "$defs": { + "Child": { + "title": "Child", + "type": "object", + "properties": { + "inner": {"$ref": "#/components/schemas/Inner"} + }, + }, + "Inner": { + "title": "Inner", + "type": "object", + "properties": {"count": {"type": "integer"}}, + }, + }, + } + + app = TurboAPI(title="OpenAPINestedRefRewrite") + + @app.post("/first-child", response_model=FirstParent) + def create_first_child(item: FirstParent): + return item + + @app.post("/second-child", response_model=SecondParent) + def create_second_child(item: SecondParent): + return item + + schema = app.openapi() + components = schema["components"]["schemas"] + first_child_ref = components["FirstParent"]["properties"]["child"]["$ref"] + second_child_ref = components["SecondParent"]["properties"]["child"]["$ref"] + + assert first_child_ref != second_child_ref + first_child_component = first_child_ref.rsplit("/", 1)[-1] + second_child_component = second_child_ref.rsplit("/", 1)[-1] + first_inner_ref = components[first_child_component]["properties"]["inner"]["$ref"] + second_inner_ref = components[second_child_component]["properties"]["inner"]["$ref"] + + assert first_inner_ref != second_inner_ref + first_inner_component = first_inner_ref.rsplit("/", 1)[-1] + second_inner_component = second_inner_ref.rsplit("/", 1)[-1] + assert components[first_inner_component]["properties"] == {"name": {"type": "string"}} + assert components[second_inner_component]["properties"] == { + "count": {"type": "integer"} + } + + def test_openapi_does_not_document_unsupported_annotated_form_metadata(self): + app = TurboAPI(title="OpenAPIAnnotatedForm") + + @app.post("/annotated-form") + def annotated_form(username: Annotated[str, Form()]): + return {"username": username} + + schema = app.openapi() + content = schema["paths"]["/annotated-form"]["post"]["requestBody"]["content"] + + assert "application/x-www-form-urlencoded" not in content + assert content["application/json"]["schema"] == { + "type": "object", + "properties": {"username": {"type": "string"}}, + "required": ["username"], + } + def test_app_openapi_method(self): app = TurboAPI(title="AppOpenAPI") From a20712ed3fafa4bf4c83de08f900bfecf6f6977d Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:18:08 +0800 Subject: [PATCH 06/10] fix: avoid unsupported annotated OpenAPI markers --- python/turboapi/openapi.py | 8 +++----- tests/test_fastapi_parity.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/python/turboapi/openapi.py b/python/turboapi/openapi.py index 972ff193..26850907 100644 --- a/python/turboapi/openapi.py +++ b/python/turboapi/openapi.py @@ -322,13 +322,11 @@ def _resolve_annotation_and_marker(param: inspect.Parameter) -> tuple[Any, Any | annotation, metadata = _unwrap_annotated(param.annotation) for item in metadata: - # Runtime form/file parsing currently only honors default-value markers + # Runtime parameter parsing currently only honors default-value markers # (or a direct UploadFile annotation). Do not advertise Annotated - # Form/File metadata as form uploads until request handling supports it. - if isinstance(item, (Form, File)): - continue + # parameter marker metadata until request handling supports it. if isinstance(item, _PARAM_MARKER_TYPES): - return annotation, item + continue if isinstance(param.default, _PARAM_MARKER_TYPES): return annotation, param.default diff --git a/tests/test_fastapi_parity.py b/tests/test_fastapi_parity.py index 112ab18b..7f341e01 100644 --- a/tests/test_fastapi_parity.py +++ b/tests/test_fastapi_parity.py @@ -778,6 +778,25 @@ def annotated_form(username: Annotated[str, Form()]): "required": ["username"], } + def test_openapi_does_not_document_unsupported_annotated_param_metadata(self): + app = TurboAPI(title="OpenAPIAnnotatedParams") + + @app.get("/annotated-query") + def annotated_query(q: Annotated[int, Query(default=10, alias="item-query")]): + return {"q": q} + + schema = app.openapi() + parameters = schema["paths"]["/annotated-query"]["get"]["parameters"] + + assert parameters == [ + { + "name": "q", + "in": "query", + "required": True, + "schema": {"type": "integer"}, + } + ] + def test_app_openapi_method(self): app = TurboAPI(title="AppOpenAPI") From 505d7bfefa844b3b4c55aa6130a4db192fcc79bf Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:27:29 +0800 Subject: [PATCH 07/10] fix: avoid unsupported cookie and body docs --- python/turboapi/openapi.py | 16 ++++++---------- tests/test_fastapi_parity.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/python/turboapi/openapi.py b/python/turboapi/openapi.py index 26850907..d354e9d3 100644 --- a/python/turboapi/openapi.py +++ b/python/turboapi/openapi.py @@ -174,15 +174,9 @@ def _generate_operation(handler, route, schema_context: _SchemaContext) -> dict: ) ) elif isinstance(marker, Cookie): - parameters.append( - _build_parameter( - _parameter_alias(param_name, marker, location="cookie"), - "cookie", - required, - param_schema, - marker, - ) - ) + # Runtime request handling does not bind Cookie() route parameters yet. + # Avoid advertising cookie params until parsing support exists. + continue elif _is_form_or_file_param(annotation, marker): media_type = getattr(marker, "media_type", None) or "multipart/form-data" if _is_file_param(annotation, marker): @@ -203,7 +197,9 @@ def _generate_operation(handler, route, schema_context: _SchemaContext) -> dict: "schema": param_schema, "required": required, "media_type": marker.media_type, - "direct": _is_model_class(annotation) and not marker.embed, + # RequestBodyParser currently validates single model parameters + # against the whole JSON body and does not inspect Body.embed. + "direct": _is_model_class(annotation), } ) elif method in _BODY_METHODS and _is_model_class(annotation): diff --git a/tests/test_fastapi_parity.py b/tests/test_fastapi_parity.py index 7f341e01..0a21d451 100644 --- a/tests/test_fastapi_parity.py +++ b/tests/test_fastapi_parity.py @@ -797,6 +797,37 @@ def annotated_query(q: Annotated[int, Query(default=10, alias="item-query")]): } ] + def test_openapi_does_not_document_unsupported_cookie_route_params(self): + app = TurboAPI(title="OpenAPICookieParams") + + @app.get("/cookie-route") + def cookie_route(session_id: str = Cookie()): + return {"session_id": session_id} + + schema = app.openapi() + operation = schema["paths"]["/cookie-route"]["get"] + + assert "parameters" not in operation + + def test_openapi_body_embed_model_matches_current_runtime_binding(self): + from dhi import BaseModel + + class Item(BaseModel): + name: str + + app = TurboAPI(title="OpenAPIBodyEmbed") + + @app.post("/body-embed") + def body_embed(item: Item = Body(embed=True)): + return item + + schema = app.openapi() + body_schema = schema["paths"]["/body-embed"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] + + assert body_schema == {"$ref": "#/components/schemas/Item"} + def test_app_openapi_method(self): app = TurboAPI(title="AppOpenAPI") From c386c195837ff0da48fcd355ac95afa120354cdd Mon Sep 17 00:00:00 2001 From: Lim Yu Xi Date: Tue, 9 Jun 2026 14:36:03 +0800 Subject: [PATCH 08/10] fix: avoid unsupported annotated model body docs --- python/turboapi/openapi.py | 34 +++++++++++++++++++++++++--------- tests/test_fastapi_parity.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/python/turboapi/openapi.py b/python/turboapi/openapi.py index d354e9d3..7995bcea 100644 --- a/python/turboapi/openapi.py +++ b/python/turboapi/openapi.py @@ -148,9 +148,24 @@ def _generate_operation(handler, route, schema_context: _SchemaContext) -> dict: continue annotation, marker = _resolve_annotation_and_marker(param) - param_schema = _schema_for_param(annotation, marker, param, schema_context) required = _is_required_param(param, marker) + if method in _BODY_METHODS and _is_unsupported_annotated_model_body( + param.annotation, marker + ): + body_entries.append( + { + "name": param_name, + "schema": {}, + "required": required, + "media_type": "application/json", + "direct": False, + } + ) + continue + + param_schema = _schema_for_param(annotation, marker, param, schema_context) + if param_name in path_params or isinstance(marker, PathMarker): parameters.append(_build_parameter(param_name, "path", True, param_schema, marker)) elif isinstance(marker, Query): @@ -315,14 +330,7 @@ def _unwrap_annotated(annotation) -> tuple[Any, tuple[Any, ...]]: def _resolve_annotation_and_marker(param: inspect.Parameter) -> tuple[Any, Any | None]: - annotation, metadata = _unwrap_annotated(param.annotation) - - for item in metadata: - # Runtime parameter parsing currently only honors default-value markers - # (or a direct UploadFile annotation). Do not advertise Annotated - # parameter marker metadata until request handling supports it. - if isinstance(item, _PARAM_MARKER_TYPES): - continue + annotation, _metadata = _unwrap_annotated(param.annotation) if isinstance(param.default, _PARAM_MARKER_TYPES): return annotation, param.default @@ -330,6 +338,14 @@ def _resolve_annotation_and_marker(param: inspect.Parameter) -> tuple[Any, Any | return annotation, None +def _is_unsupported_annotated_model_body(annotation: Any, marker: Any | None) -> bool: + if marker is not None: + return False + + annotation, metadata = _unwrap_annotated(annotation) + return _is_model_class(annotation) and any(isinstance(item, Body) for item in metadata) + + def _is_dependency_parameter(param: inspect.Parameter) -> bool: if isinstance(param.default, (Depends, SecurityBase)): return True diff --git a/tests/test_fastapi_parity.py b/tests/test_fastapi_parity.py index 0a21d451..842f629e 100644 --- a/tests/test_fastapi_parity.py +++ b/tests/test_fastapi_parity.py @@ -828,6 +828,38 @@ def body_embed(item: Item = Body(embed=True)): assert body_schema == {"$ref": "#/components/schemas/Item"} + def test_openapi_does_not_document_unsupported_annotated_model_body(self): + from dhi import BaseModel + + class Item(BaseModel): + name: str + + app = TurboAPI(title="OpenAPIAnnotatedModelBody") + + @app.post("/annotated-model-body") + def annotated_model_body(item: Annotated[Item, Body()]): + return item + + @app.post("/annotated-model-body-embed") + def annotated_model_body_embed(item: Annotated[Item, Body(embed=True)]): + return item + + schema = app.openapi() + + for path in ("/annotated-model-body", "/annotated-model-body-embed"): + body_schema = schema["paths"][path]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] + + assert body_schema != {"$ref": "#/components/schemas/Item"} + assert body_schema == { + "type": "object", + "properties": {"item": {}}, + "required": ["item"], + } + + assert "Item" not in schema["components"]["schemas"] + def test_app_openapi_method(self): app = TurboAPI(title="AppOpenAPI") From e2abd6f2ead999308e54ba0fc665dc6c019ad87b Mon Sep 17 00:00:00 2001 From: Lim Yu Xi Date: Tue, 9 Jun 2026 14:54:32 +0800 Subject: [PATCH 09/10] fix: align openapi docs with runtime params --- python/turboapi/openapi.py | 43 ++++++++++++++++++++++++++---------- tests/test_fastapi_parity.py | 22 +++++++++++++----- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/python/turboapi/openapi.py b/python/turboapi/openapi.py index 7995bcea..08d95ce1 100644 --- a/python/turboapi/openapi.py +++ b/python/turboapi/openapi.py @@ -164,18 +164,31 @@ def _generate_operation(handler, route, schema_context: _SchemaContext) -> dict: ) continue - param_schema = _schema_for_param(annotation, marker, param, schema_context) + if isinstance(marker, Cookie): + # Runtime request handling does not bind Cookie() route parameters yet. + # Avoid advertising cookie params or registering component schemas for + # them until parsing support exists. + continue + + if isinstance(marker, Query): + # Runtime query parsing binds by Python parameter name and does not + # consume Query.alias, Query.default, or validation metadata. + param_schema = _schema_for_param( + annotation, None, param, schema_context, include_default=False + ) + else: + param_schema = _schema_for_param(annotation, marker, param, schema_context) if param_name in path_params or isinstance(marker, PathMarker): parameters.append(_build_parameter(param_name, "path", True, param_schema, marker)) elif isinstance(marker, Query): parameters.append( _build_parameter( - _parameter_alias(param_name, marker), + param_name, "query", - required, + True, param_schema, - marker, + None, ) ) elif isinstance(marker, Header): @@ -188,10 +201,6 @@ def _generate_operation(handler, route, schema_context: _SchemaContext) -> dict: marker, ) ) - elif isinstance(marker, Cookie): - # Runtime request handling does not bind Cookie() route parameters yet. - # Avoid advertising cookie params until parsing support exists. - continue elif _is_form_or_file_param(annotation, marker): media_type = getattr(marker, "media_type", None) or "multipart/form-data" if _is_file_param(annotation, marker): @@ -368,7 +377,12 @@ def _effective_default(param: inspect.Parameter, marker: Any | None) -> Any: def _schema_for_param( - annotation, marker: Any | None, param: inspect.Parameter, schema_context: _SchemaContext + annotation, + marker: Any | None, + param: inspect.Parameter, + schema_context: _SchemaContext, + *, + include_default: bool = True, ) -> dict: if _is_file_param(annotation, marker): schema = {"type": "string", "format": "binary"} @@ -378,9 +392,14 @@ def _schema_for_param( schema = dict(schema) _apply_marker_metadata(schema, marker) - default = _effective_default(param, marker) - if default is not inspect.Parameter.empty and default is not ... and _is_jsonable(default): - schema["default"] = default + if include_default: + default = _effective_default(param, marker) + if ( + default is not inspect.Parameter.empty + and default is not ... + and _is_jsonable(default) + ): + schema["default"] = default return schema diff --git a/tests/test_fastapi_parity.py b/tests/test_fastapi_parity.py index 842f629e..ba13443a 100644 --- a/tests/test_fastapi_parity.py +++ b/tests/test_fastapi_parity.py @@ -558,7 +558,7 @@ def login(username: str = Form(), password: str = Form()): def search( session: SessionDep, request: SearchRequest, - include_archived: bool = Query(default=False), + include_archived: bool = Query(default=False, alias="archived"), ): return SearchResponse(count=request.limit) @@ -579,8 +579,8 @@ def search( { "name": "include_archived", "in": "query", - "required": False, - "schema": {"type": "boolean", "default": False}, + "required": True, + "schema": {"type": "boolean"}, } ] assert search_operation["responses"]["200"]["content"]["application/json"]["schema"] == { @@ -798,16 +798,28 @@ def annotated_query(q: Annotated[int, Query(default=10, alias="item-query")]): ] def test_openapi_does_not_document_unsupported_cookie_route_params(self): + from dhi import BaseModel + + class Session(BaseModel): + id: str + app = TurboAPI(title="OpenAPICookieParams") @app.get("/cookie-route") def cookie_route(session_id: str = Cookie()): return {"session_id": session_id} + @app.get("/cookie-model-route") + def cookie_model_route(session: Session = Cookie()): + return session + schema = app.openapi() - operation = schema["paths"]["/cookie-route"]["get"] + cookie_operation = schema["paths"]["/cookie-route"]["get"] + cookie_model_operation = schema["paths"]["/cookie-model-route"]["get"] - assert "parameters" not in operation + assert "parameters" not in cookie_operation + assert "parameters" not in cookie_model_operation + assert "Session" not in schema["components"]["schemas"] def test_openapi_body_embed_model_matches_current_runtime_binding(self): from dhi import BaseModel From 7758e55ee3e871de0e4fe7d2d366c154d1ee896d Mon Sep 17 00:00:00 2001 From: Lim Yu Xi Date: Tue, 9 Jun 2026 16:43:52 +0800 Subject: [PATCH 10/10] fix: align openapi edge case docs --- python/turboapi/openapi.py | 20 +++++++++++++++++- tests/test_fastapi_parity.py | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/python/turboapi/openapi.py b/python/turboapi/openapi.py index 08d95ce1..bfdecfe3 100644 --- a/python/turboapi/openapi.py +++ b/python/turboapi/openapi.py @@ -149,6 +149,7 @@ def _generate_operation(handler, route, schema_context: _SchemaContext) -> dict: annotation, marker = _resolve_annotation_and_marker(param) required = _is_required_param(param, marker) + body_required = _is_required_body_param(param, marker) if method in _BODY_METHODS and _is_unsupported_annotated_model_body( param.annotation, marker @@ -176,6 +177,14 @@ def _generate_operation(handler, route, schema_context: _SchemaContext) -> dict: param_schema = _schema_for_param( annotation, None, param, schema_context, include_default=False ) + elif isinstance(marker, Form): + # Runtime form parsing passes field values through as raw strings. + param_schema = _schema_for_param(str, marker, param, schema_context) + elif isinstance(marker, Body): + # Runtime body parsing does not unwrap Body.default values. + param_schema = _schema_for_param( + annotation, marker, param, schema_context, include_default=False + ) else: param_schema = _schema_for_param(annotation, marker, param, schema_context) @@ -219,7 +228,7 @@ def _generate_operation(handler, route, schema_context: _SchemaContext) -> dict: { "name": _parameter_alias(param_name, marker), "schema": param_schema, - "required": required, + "required": body_required, "media_type": marker.media_type, # RequestBodyParser currently validates single model parameters # against the whole JSON body and does not inspect Body.embed. @@ -366,6 +375,15 @@ def _is_required_param(param: inspect.Parameter, marker: Any | None) -> bool: return default is inspect.Parameter.empty or default is ... +def _is_required_body_param(param: inspect.Parameter, marker: Any | None) -> bool: + if isinstance(marker, Body): + # Runtime body parsing does not unwrap Body.default; missing values + # become the marker object, not the marker's default value. + return True + + return _is_required_param(param, marker) + + def _effective_default(param: inspect.Parameter, marker: Any | None) -> Any: if isinstance(param.default, _PARAM_MARKER_TYPES): return param.default.default diff --git a/tests/test_fastapi_parity.py b/tests/test_fastapi_parity.py index ba13443a..448eced3 100644 --- a/tests/test_fastapi_parity.py +++ b/tests/test_fastapi_parity.py @@ -840,6 +840,45 @@ def body_embed(item: Item = Body(embed=True)): assert body_schema == {"$ref": "#/components/schemas/Item"} + def test_openapi_documents_form_fields_as_runtime_raw_strings(self): + app = TurboAPI(title="OpenAPIFormRawStrings") + + @app.post("/form-raw-strings") + def form_raw_strings(age: int = Form(), active: bool = Form()): + return {"age": age, "active": active} + + schema = app.openapi() + body_schema = schema["paths"]["/form-raw-strings"]["post"]["requestBody"]["content"][ + "application/x-www-form-urlencoded" + ]["schema"] + + assert body_schema == { + "type": "object", + "properties": { + "age": {"type": "string"}, + "active": {"type": "string"}, + }, + "required": ["age", "active"], + } + + def test_openapi_does_not_document_unsupported_body_marker_defaults(self): + app = TurboAPI(title="OpenAPIBodyMarkerDefaults") + + @app.post("/body-marker-defaults") + def body_marker_defaults(count: int = Body(default=10)): + return {"count": count} + + schema = app.openapi() + request_body = schema["paths"]["/body-marker-defaults"]["post"]["requestBody"] + body_schema = request_body["content"]["application/json"]["schema"] + + assert request_body["required"] is True + assert body_schema == { + "type": "object", + "properties": {"count": {"type": "integer"}}, + "required": ["count"], + } + def test_openapi_does_not_document_unsupported_annotated_model_body(self): from dhi import BaseModel