From 0be31869b5d333f1bd56c1309c07a18b35afeb94 Mon Sep 17 00:00:00 2001 From: Jiang Date: Fri, 31 Jul 2026 18:39:36 +0800 Subject: [PATCH] fix(api): encode untyped datetime responses --- app/api/v1/rest_router.py | 20 ++++++++++++++++++ tests/api/test_openapi_contract.py | 33 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/app/api/v1/rest_router.py b/app/api/v1/rest_router.py index 496a2d9..232ad2d 100644 --- a/app/api/v1/rest_router.py +++ b/app/api/v1/rest_router.py @@ -8,8 +8,10 @@ from functools import wraps from typing import Any, Generic, TypeVar, get_args, get_origin from fastapi import APIRouter, Depends, Query +from fastapi.encoders import jsonable_encoder from fastapi.routing import APIRoute from pydantic import BaseModel, JsonValue, create_model +from starlette.responses import Response from app.api.problem_details import ProblemDetails from app.api.v1.router import api_router as handler_api_router @@ -263,6 +265,21 @@ def _with_pagination(endpoint): return wrapper +def _with_jsonable_response(endpoint): + """Normalize untyped handler results before JsonValue validation.""" + + @wraps(endpoint) + async def wrapper(*args, **kwargs): + result = endpoint(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + if isinstance(result, Response): + return result + return jsonable_encoder(result) + + return wrapper + + def _adapt_route(route: APIRoute) -> APIRoute: methods = route.methods or set() if len(methods) != 1: @@ -286,6 +303,9 @@ def _adapt_route(route: APIRoute) -> APIRoute: endpoint = _with_header_project_context(route.endpoint, route.name) response_model = route.response_model + has_untyped_response = response_model is None + if has_untyped_response: + endpoint = _with_jsonable_response(endpoint) if get_origin(response_model) is list: item_type = get_args(response_model)[0] if get_args(response_model) else JsonValue response_model = Page[item_type] diff --git a/tests/api/test_openapi_contract.py b/tests/api/test_openapi_contract.py index 623e98a..3d2d169 100644 --- a/tests/api/test_openapi_contract.py +++ b/tests/api/test_openapi_contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 @@ -282,3 +283,35 @@ def test_rest_runtime_wraps_handler_paginated_list() -> None: "limit": 2, "offset": 1, } + + +def test_rest_runtime_json_encodes_untyped_datetime_response() -> None: + source_router = APIRouter() + + @source_router.get("/simulation-result") + async def simulation_result(): + return { + "result": [ + { + "time": datetime(2026, 7, 30, 4, tzinfo=timezone.utc), + "id": "4277", + } + ] + } + + app = FastAPI(redirect_slashes=False) + app.include_router(build_rest_router(source_router.routes), prefix="/api/v1") + + response = TestClient(app, raise_server_exceptions=False).get( + "/api/v1/simulation-result" + ) + + assert response.status_code == 200 + assert response.json() == { + "result": [ + { + "time": "2026-07-30T04:00:00+00:00", + "id": "4277", + } + ] + }