diff --git a/qh/endpoint.py b/qh/endpoint.py index d58607d..677a226 100644 --- a/qh/endpoint.py +++ b/qh/endpoint.py @@ -276,6 +276,14 @@ def task_wrapper(**kwargs): else: result = func(**transformed_params) + # If the function returns a Response (or any subclass — + # StreamingResponse, FileResponse, PlainTextResponse, …), + # pass it through unchanged. This lets endpoints emit non- + # JSON payloads (PDFs, file downloads, streams) without + # having to bypass qh. + if isinstance(result, Response): + return result + # Apply egress transformation output = apply_egress_transform(result, default_egress) diff --git a/qh/tests/test_mk_app.py b/qh/tests/test_mk_app.py index d504d60..3d66ceb 100644 --- a/qh/tests/test_mk_app.py +++ b/qh/tests/test_mk_app.py @@ -233,5 +233,56 @@ def add(x: int, y: int) -> int: assert response.json() == 8 +def test_response_passthrough_returns_unchanged(): + """A function that returns a fastapi.Response bypasses JSON encoding. + + This is the seam reelee uses for PDF export (chunk 9): the endpoint + returns a Response with raw bytes and a content-type header, qh + passes it through verbatim. + """ + from fastapi import Response + + PDF_BYTES = b"%PDF-1.4 fake but valid header" + + def export_pdf() -> Response: + return Response( + content=PDF_BYTES, + media_type="application/pdf", + headers={ + "content-disposition": 'attachment; filename="picture-book.pdf"' + }, + ) + + app = mk_app([export_pdf]) + client = TestClient(app) + response = client.post("/export_pdf") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert ( + response.headers["content-disposition"] + == 'attachment; filename="picture-book.pdf"' + ) + assert response.content == PDF_BYTES + + +def test_response_passthrough_supports_streaming(): + """StreamingResponse subclasses also pass through.""" + from fastapi.responses import StreamingResponse + + def stream_text() -> StreamingResponse: + def gen(): + for chunk in ("hello ", "from ", "qh"): + yield chunk.encode() + + return StreamingResponse(gen(), media_type="text/plain") + + app = mk_app([stream_text]) + client = TestClient(app) + response = client.post("/stream_text") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/plain") + assert response.text == "hello from qh" + + if __name__ == '__main__': pytest.main([__file__, '-v'])