diff --git a/qh/endpoint.py b/qh/endpoint.py index 677a226..b7db4a3 100644 --- a/qh/endpoint.py +++ b/qh/endpoint.py @@ -7,6 +7,7 @@ from typing import Any, Callable, Dict, Optional, get_type_hints from fastapi import Request, Response, HTTPException from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder import inspect import json @@ -287,8 +288,13 @@ def task_wrapper(**kwargs): # Apply egress transformation output = apply_egress_transform(result, default_egress) - # Return JSON response - return JSONResponse(content=output) + # Return JSON response. Run the output through FastAPI's + # jsonable_encoder first: JSONResponse serializes with the stdlib + # json encoder, which only handles dict/list/scalar. jsonable_encoder + # recursively converts dataclasses, pydantic models, datetime, Enum, + # set, UUID, Decimal, … into JSON-compatible primitives. Plain + # dict/list/scalar outputs pass through unchanged. + return JSONResponse(content=jsonable_encoder(output)) except HTTPException: # Re-raise HTTP exceptions diff --git a/qh/tests/test_mk_app.py b/qh/tests/test_mk_app.py index 3d66ceb..2608192 100644 --- a/qh/tests/test_mk_app.py +++ b/qh/tests/test_mk_app.py @@ -284,5 +284,72 @@ def gen(): assert response.text == "hello from qh" +def test_dataclass_return_serializes(): + """A function returning a dataclass serializes (via jsonable_encoder). + + Regression for i2mint/qh#6: JSONResponse uses the stdlib json encoder, + which only handles dict/list/scalar — a dataclass instance raised + ``Object of type ... is not JSON serializable`` and 500'd. + """ + from dataclasses import dataclass + + @dataclass(frozen=True) + class Point: + x: int + y: int + + def make_point(x: int, y: int) -> Point: + return Point(x=x, y=y) + + app = mk_app([make_point]) + client = TestClient(app) + + response = client.post('/make_point', json={'x': 3, 'y': 5}) + assert response.status_code == 200 + assert response.json() == {'x': 3, 'y': 5} + + +def test_list_of_dataclasses_serializes(): + """A list of dataclasses serializes — the ef.EfService.search() shape.""" + from dataclasses import dataclass + + @dataclass(frozen=True) + class Hit: + label: str + score: float + + def top_hits() -> list[Hit]: + return [Hit(label='a', score=0.9), Hit(label='b', score=0.5)] + + app = mk_app([top_hits]) + client = TestClient(app) + + response = client.post('/top_hits', json={}) + assert response.status_code == 200 + assert response.json() == [ + {'label': 'a', 'score': 0.9}, + {'label': 'b', 'score': 0.5}, + ] + + +def test_rich_types_serialize(): + """datetime and Enum returns also serialize through jsonable_encoder.""" + from datetime import datetime + from enum import Enum + + class Color(Enum): + RED = 'red' + + def info() -> dict: + return {'when': datetime(2026, 5, 21, 12, 0, 0), 'color': Color.RED} + + app = mk_app([info]) + client = TestClient(app) + + response = client.post('/info', json={}) + assert response.status_code == 200 + assert response.json() == {'when': '2026-05-21T12:00:00', 'color': 'red'} + + if __name__ == '__main__': pytest.main([__file__, '-v'])