diff --git a/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/pyproject.toml b/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/pyproject.toml index 2ce9dd1a..e913f8a8 100644 --- a/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/pyproject.toml +++ b/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "fastapi", + "python-multipart", "pytest", "pytest-asyncio<1.2.0", ] diff --git a/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/_client.py b/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/_client.py index 644a7a44..35617ce2 100644 --- a/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/_client.py +++ b/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/_client.py @@ -6,11 +6,20 @@ BASE_URL = "http://testserver" +def _with_content_length(headers, body): + """Ensure Content-Length is present when a body is provided.""" + hdrs = dict(headers or {}) + if body is not None and not any(k.lower() == "content-length" for k in hdrs): + length = len(body.encode() if isinstance(body, str) else body) + hdrs["Content-Length"] = str(length) + return hdrs + + async def fetch(app, path, env=None, method="GET", headers=None, body=None): request = Request( f"{BASE_URL}{path}", method=method, - headers=dict(headers or {}), + headers=_with_content_length(headers, body), body=body, ) return await asgi.fetch(app, request, env or {}) @@ -24,3 +33,34 @@ async def read_json(response): async def get_json(app, path, **kwargs): response = await fetch(app, path, **kwargs) return response, await read_json(response) + + +async def post_json(app, path, data, **kwargs): + return await fetch( + app, + path, + method="POST", + headers={"Content-Type": "application/json"}, + body=_json.dumps(data), + **kwargs, + ) + + +def build_multipart(files, boundary="----WebTestBoundary"): + """Build a multipart/form-data body from a list of (name, filename, content) tuples.""" + lines = [] + for name, filename, content in files: + if isinstance(content, bytes): + content = content.decode() + lines.append(f"--{boundary}") + lines.append( + f'Content-Disposition: form-data; name="{name}"; filename="{filename}"' + ) + lines.append("Content-Type: application/octet-stream") + lines.append("") + lines.append(content) + lines.append(f"--{boundary}--") + lines.append("") + body = "\r\n".join(lines) + content_type = f"multipart/form-data; boundary={boundary}" + return body, content_type diff --git a/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/test_sync_handlers.py b/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/test_sync_handlers.py new file mode 100644 index 00000000..be6c04a2 --- /dev/null +++ b/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/test_sync_handlers.py @@ -0,0 +1,128 @@ +"""Tests for FastAPI features that rely on anyio.to_thread.run_sync. + +The workers runtime is single-threaded, so ``anyio.to_thread.run_sync`` is +patched to run the callable inline. Without the patch every test in this file +would fail with ``RuntimeError: can't start new thread``. + +Covered call-sites: +- Sync ``def`` route handlers (``starlette.routing`` / ``fastapi.routing``) +- Sync dependencies (``fastapi.dependencies.utils``) +- Sync ``BackgroundTask`` (``starlette.background``) +- Sync-iterator ``StreamingResponse`` (``starlette.concurrency.iterate_in_threadpool``) +- ``UploadFile.read`` (``starlette.datastructures``) +""" + +import pytest +from _client import build_multipart, fetch, get_json, read_json + +# -- sync route handlers ----------------------------------------------------- + + +@pytest.mark.asyncio +async def test_sync_handler_returns_json(fastapi_app): + """A plain ``def`` route handler returns a JSON response.""" + resp, data = await get_json(fastapi_app, "/sync/hello") + assert resp.status == 200 + assert data["message"] == "sync hello" + + +@pytest.mark.asyncio +async def test_sync_post_handler(fastapi_app): + """A sync POST handler receives the request and responds.""" + resp = await fetch(fastapi_app, "/sync/echo", method="POST", body="hi") + assert resp.status == 200 + data = await read_json(resp) + assert data["method"] == "POST" + + +# -- sync dependency ---------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_sync_dependency(fastapi_app): + """An async handler that depends on a sync ``def`` dependency.""" + resp, data = await get_json(fastapi_app, "/sync/dep") + assert resp.status == 200 + assert data["greeting"] == "hello from sync dep" + + +# -- sync background task ----------------------------------------------------- + + +@pytest.mark.asyncio +async def test_sync_background_task(fastapi_app): + """A sync function passed to ``BackgroundTask`` runs to completion.""" + resp, data = await get_json(fastapi_app, "/sync/background/run") + assert resp.status == 200 + assert data["submitted"] is True + + # The background task runs inline (patched), so by the time the response + # is fully sent the side-effect should already be visible. + resp2, data2 = await get_json(fastapi_app, "/sync/background/check") + assert resp2.status == 200 + assert data2["bg_ran"] is True + + +# -- sync-iterator streaming response ---------------------------------------- + + +@pytest.mark.asyncio +async def test_sync_streaming_response(fastapi_app): + """StreamingResponse backed by a sync generator delivers all chunks.""" + resp = await fetch(fastapi_app, "/sync/stream") + assert resp.status == 200 + body = await resp.text() + for i in range(5): + assert f"chunk-{i}" in body + + +@pytest.mark.asyncio +async def test_sync_streaming_content_type(fastapi_app): + """StreamingResponse preserves the declared media type.""" + resp = await fetch(fastapi_app, "/sync/stream") + assert resp.status == 200 + assert "text/plain" in resp.headers.get("content-type") + + +# -- file upload (UploadFile) ------------------------------------------------- + + +@pytest.mark.asyncio +async def test_single_file_upload(fastapi_app): + """A single file upload is received and its content is echoed back.""" + body, content_type = build_multipart([("file", "greet.txt", "hello world")]) + resp = await fetch( + fastapi_app, + "/upload/single", + method="POST", + headers={"Content-Type": content_type}, + body=body, + ) + assert resp.status == 200 + data = await read_json(resp) + assert data["filename"] == "greet.txt" + assert data["size"] > 0 + assert "hello world" in data["text"] + + +@pytest.mark.asyncio +async def test_multiple_file_upload(fastapi_app): + """Multiple files are received and their metadata is echoed back.""" + body, content_type = build_multipart( + [ + ("files", "one.txt", "first"), + ("files", "two.txt", "second"), + ] + ) + resp = await fetch( + fastapi_app, + "/upload/multiple", + method="POST", + headers={"Content-Type": content_type}, + body=body, + ) + assert resp.status == 200 + data = await read_json(resp) + assert len(data) == 2 + assert [f["filename"] for f in data] == ["one.txt", "two.txt"] + assert all(f["size"] > 0 for f in data) diff --git a/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/worker.py b/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/worker.py index cad56eb8..cc0b7ff9 100644 --- a/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/worker.py +++ b/packages/runtime-sdk/tests/web-frameworks-test/fastapi-tests/src/worker.py @@ -3,11 +3,12 @@ from pathlib import Path import pytest -from fastapi import FastAPI, Request -from fastapi.responses import FileResponse +from fastapi import Depends, FastAPI, File, Request, UploadFile +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.responses import Response as FastAPIResponse from fastapi.staticfiles import StaticFiles from pyodide.webloop import WebLoop +from starlette.background import BackgroundTask import asgi from workers import Response, WorkerEntrypoint @@ -24,6 +25,16 @@ async def _noop(*args): app = FastAPI() +# --------------------------------------------------------------------------- # +# Shared mutable state used to verify side-effects (e.g. background tasks). +# --------------------------------------------------------------------------- # +_side_effects: dict = {} + + +# --------------------------------------------------------------------------- # +# Routes exercising the anyio.to_thread.run_sync patch +# --------------------------------------------------------------------------- # + @app.get("/api/hello") async def api_hello(): @@ -35,6 +46,87 @@ async def health(): return {"ok": True} +# -- sync route handler (dispatched via run_in_threadpool) ------------------- +@app.get("/sync/hello") +def sync_hello(): + """A plain `def` (non-async) route handler.""" + return {"message": "sync hello"} + + +@app.post("/sync/echo") +def sync_echo(request: Request): + """Sync POST handler that reads the body.""" + # request.body() is async; the sync handler can still return a dict. + return {"method": request.method} + + +# -- sync dependency --------------------------------------------------------- +def _get_greeting(): + """A plain `def` dependency (not async).""" + return "hello from sync dep" + + +@app.get("/sync/dep") +async def sync_dep_route(greeting: str = Depends(_get_greeting)): + """Async handler with a sync dependency.""" + return {"greeting": greeting} + + +# -- sync background task ---------------------------------------------------- +def _bg_task(): + """Sync function executed as a BackgroundTask.""" + _side_effects["bg_ran"] = True + + +@app.get("/sync/background/run") +async def sync_background_run(): + """Fire a sync background task and return immediately.""" + _side_effects.pop("bg_ran", None) + return JSONResponse({"submitted": True}, background=BackgroundTask(_bg_task)) + + +@app.get("/sync/background/check") +async def sync_background_check(): + """Return whether the background task has executed.""" + return {"bg_ran": _side_effects.get("bg_ran", False)} + + +# -- sync iterator streaming response --------------------------------------- +def _sync_chunks(): + """A plain generator (not async) yielding text chunks.""" + for i in range(5): + yield f"chunk-{i}\n" + + +@app.get("/sync/stream") +async def sync_stream(): + """StreamingResponse backed by a sync iterator.""" + return StreamingResponse(_sync_chunks(), media_type="text/plain") + + +# -- file upload (UploadFile.read/write/seek go through run_in_threadpool) --- +@app.post("/upload/single") +async def upload_single(file: UploadFile = File(...)): # noqa: B008 + """Accept a single file upload and echo its metadata + content.""" + content = await file.read() + return { + "filename": file.filename, + "content_type": file.content_type, + "size": len(content), + "text": content.decode(errors="replace"), + } + + +@app.post("/upload/multiple") +async def upload_multiple(files: list[UploadFile] = File(...)): # noqa: B008 + """Accept multiple file uploads and echo their metadata.""" + result = [] + for f in files: + data = await f.read() + result.append({"filename": f.filename, "size": len(data)}) + return result + + @app.get("/native-file") async def native_file(): """Serve a single bundled file with FastAPI's native FileResponse."""