Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions qh/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions qh/tests/test_mk_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Loading