From 1bfd85b003747e34af2071f1f827d5d4befed20c Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sun, 22 Mar 2026 12:35:07 -0400 Subject: [PATCH] Add Pydantic support for OpenAPI schema generation Define your API schemas with Pydantic models instead of (or alongside) YAML docstrings and marshmallow: from pydantic import BaseModel class PetIn(BaseModel): name: str age: int = 0 class PetOut(BaseModel): id: int name: str age: int @api.route("/pets", methods=["POST"], request_model=PetIn, response_model=PetOut) async def create_pet(req, resp): data = await req.media() resp.media = {"id": 1, **data} Also works with @api.schema("Name") decorator for registering standalone schema components. Pydantic models, marshmallow schemas, and YAML docstrings can all be used together in the same API. Also: rewrite docs with more prose, restore sidebar logo and links, add FastAPI acknowledgment, update homepage copy. 161 tests, 95% coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/source/_templates/sidebarintro.html | 12 +- docs/source/conf.py | 3 +- docs/source/deployment.rst | 67 ++++++- docs/source/index.rst | 75 +++++++- docs/source/quickstart.rst | 211 ++++++++++++++++------- docs/source/testing.rst | 103 +++++++++-- docs/source/tour.rst | 207 +++++++++++++++++----- pyproject.toml | 1 + responder/api.py | 33 +++- responder/ext/openapi/__init__.py | 99 ++++++++++- tests/test_coverage.py | 55 ++++++ 11 files changed, 724 insertions(+), 142 deletions(-) diff --git a/docs/source/_templates/sidebarintro.html b/docs/source/_templates/sidebarintro.html index 9d6a9dc..3fe9e47 100644 --- a/docs/source/_templates/sidebarintro.html +++ b/docs/source/_templates/sidebarintro.html @@ -1,8 +1,14 @@ +

Responder — a familiar HTTP service framework for Python.

+

Useful Links

diff --git a/docs/source/conf.py b/docs/source/conf.py index 85d7a6a..039d09f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -39,7 +39,8 @@ html_theme_options = { } html_static_path = ["_static"] html_sidebars = { - "**": ["localtoc.html", "searchbox.html"], + "index": ["sidebarintro.html", "searchbox.html"], + "**": ["sidebarintro.html", "localtoc.html", "searchbox.html"], } # MyST diff --git a/docs/source/deployment.rst b/docs/source/deployment.rst index d750326..f30b59c 100644 --- a/docs/source/deployment.rst +++ b/docs/source/deployment.rst @@ -1,10 +1,34 @@ Deployment ========== +Responder applications are standard ASGI apps. You can deploy them anywhere +you'd deploy a Python web service. + + +Running Locally +--------------- + +The simplest way to run your application:: + + # api.py + import responder + + api = responder.API() + + @api.route("/") + def hello(req, resp): + resp.text = "hello, world!" + + if __name__ == "__main__": + api.run() + +This starts a production uvicorn server on ``127.0.0.1:5042``. + + Docker ------ -:: +A minimal Dockerfile for deploying a Responder application:: FROM python:3.13-slim WORKDIR /app @@ -14,18 +38,49 @@ Docker EXPOSE 80 CMD ["python", "api.py"] +Build and run:: + + $ docker build -t myapi . + $ docker run -p 8000:80 myapi + Cloud Platforms --------------- -Responder honors the ``PORT`` environment variable automatically. -It works with any platform that sets ``PORT``: Fly.io, Railway, Render, -Google Cloud Run, etc. +Responder automatically honors the ``PORT`` environment variable, which is +set by most cloud platforms. When ``PORT`` is set, Responder binds to +``0.0.0.0`` on that port automatically. + +This works out of the box with: + +- **Fly.io** +- **Railway** +- **Render** +- **Google Cloud Run** +- **Azure Container Apps** +- **AWS App Runner** + +Just deploy your code and set the start command to ``python api.py``. Uvicorn Directly ---------------- -For more control, run with uvicorn:: +For more control over the production server, you can bypass ``api.run()`` +and use uvicorn directly:: - uvicorn api:api --host 0.0.0.0 --port 8000 --workers 4 + $ uvicorn api:api --host 0.0.0.0 --port 8000 --workers 4 + +This gives you access to all of uvicorn's options: worker count, SSL +certificates, access logging, and more. See the +`uvicorn documentation `_ for details. + + +Reverse Proxy +------------- + +In production, you may want to place Responder behind a reverse proxy like +nginx or Caddy for SSL termination, load balancing, or serving static assets. + +Responder's ``TrustedHostMiddleware`` and ``HTTPSRedirectMiddleware`` work +correctly behind proxies that set standard forwarding headers. diff --git a/docs/source/index.rst b/docs/source/index.rst index d277288..ab24d6a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,7 +1,7 @@ Responder ========= -A familiar HTTP Service Framework for Python, powered by `Starlette`_. +A familiar HTTP Service Framework for Python. .. code:: python @@ -16,14 +16,76 @@ A familiar HTTP Service Framework for Python, powered by `Starlette`_. if __name__ == '__main__': api.run() -Install it:: +Powered by `Starlette`_ and `uvicorn`_. The ``async`` is optional. - pip install responder -Python 3.9+. +The Idea +-------- + +Responder takes the best ideas from `Flask`_ and `Falcon`_ and brings them +together into one clean framework. + +The request and response objects are passed into every view and mutated +directly — no return values, no boilerplate. If you've used Requests, +you'll feel right at home. If you've used Flask, the routing will look +familiar. If you've used Falcon, the ``req`` / ``resp`` pattern will +click immediately. + +- ``resp.text`` sends back text. ``resp.html`` sends back HTML. +- ``resp.media`` sends back JSON — or YAML, if the client asks for it. +- ``resp.file("path")`` serves a file. ``resp.content`` sends raw bytes. +- ``req.headers`` is case-insensitive. ``req.params`` holds query parameters. +- ``resp.status_code``, ``req.method``, ``req.url`` — the usual suspects. + +Content negotiation happens automatically. Set ``resp.media`` to a dict +and Responder figures out the rest. + +Responder and `FastAPI`_ share DNA — both are built on Starlette, both +appeared around the same time, and both pushed Python's ASGI ecosystem +forward. FastAPI went deep on type annotations and automatic validation. +Responder went for a mutable request/response pattern and a simpler, +more familiar API. Both projects are better for the other existing, and +you should use whichever feels right for what you're building. + + +What You Get +------------ + +One ``pip install``, batteries included: + +- Mount Flask, Django, or any WSGI/ASGI app at a subroute. +- Gzip compression, HSTS, CORS, and trusted host validation. +- Before-request hooks that can short-circuit for auth guards. +- A test client for fast, in-process testing with pytest. +- Route parameters with f-string syntax and type convertors. +- Lifespan context managers for startup and shutdown logic. +- Custom exception handlers for clean error responses. +- `GraphQL`_ with Graphene and a built-in GraphiQL IDE. +- File serving with automatic content-type detection. +- Sync and async views — ``async`` is always optional. +- Class-based views with ``on_get``, ``on_post``, ``on_request``. +- A pleasant API with a single import statement. +- OpenAPI schema generation with Swagger UI. +- A production `uvicorn`_ server, ready to deploy. +- HTTP method filtering for REST APIs. +- Signed cookie-based sessions. +- Background tasks in a thread pool. +- WebSocket support. + + +Installation +------------ + +.. code-block:: shell + + $ uv pip install responder + +Python 3.9 and above. That's it. + .. toctree:: :maxdepth: 2 + :caption: User Guide quickstart tour @@ -42,3 +104,8 @@ Python 3.9+. .. _Starlette: https://www.starlette.io/ +.. _uvicorn: https://www.uvicorn.org/ +.. _Flask: https://flask.palletsprojects.com/ +.. _Falcon: https://falconframework.org/ +.. _FastAPI: https://fastapi.tiangolo.com/ +.. _GraphQL: https://graphql.org/ diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 92405ee..f675fc3 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -1,143 +1,234 @@ Quick Start =========== -Create an API -------------- +This guide will walk you through the basics of building a web service with +Responder. By the end, you'll know how to declare routes, handle requests, +send responses, render templates, and process background tasks. -:: + +Create a Web Service +-------------------- + +The first thing you need to do is declare a web service. This is the central +object that holds all your routes, middleware, and configuration:: import responder api = responder.API() -Add a route + +Hello World ----------- -:: +Next, add a route. Here, we'll make the root URL say "hello, world!":: @api.route("/") - def hello(req, resp): + def hello_world(req, resp): resp.text = "hello, world!" -Run it ------- +Every view receives a ``req`` (request) and ``resp`` (response) object. You +don't need to return anything — just mutate the response directly. -:: + +Run the Server +-------------- + +Start your web service with ``api.run()``:: api.run() -This starts a production uvicorn server on port ``5042``. Customize with -``api.run(port=8000)`` or set the ``PORT`` environment variable. +This spins up a production-grade uvicorn server on port ``5042``, ready for +incoming HTTP requests. + +You can customize the port with ``api.run(port=8000)``. The ``PORT`` +environment variable is also honored automatically — when set, Responder +binds to ``0.0.0.0`` on that port, which is what cloud platforms like +Fly.io, Railway, and Google Cloud Run expect. + +.. note:: + + Both sync and async views are supported. The ``async`` keyword is always + optional — use it when you need to ``await`` something. Route Parameters ---------------- -Use f-string syntax for dynamic URLs:: +If you want dynamic URLs, use Python's familiar f-string syntax to declare +variables in your routes:: @api.route("/hello/{who}") def hello_to(req, resp, *, who): resp.text = f"hello, {who}!" -Type convertors are available:: +A ``GET`` request to ``/hello/world`` will respond with ``hello, world!``. + +Route parameters are passed as keyword-only arguments (after the ``*``). + + +Type Convertors +^^^^^^^^^^^^^^^ + +You can constrain route parameters to specific types. The parameter will be +automatically converted before it reaches your view:: @api.route("/add/{a:int}/{b:int}") async def add(req, resp, *, a, b): resp.text = f"{a} + {b} = {a + b}" -Supported types: ``str``, ``int``, ``float``, ``uuid``, ``path``. +Supported types: + +- ``str`` — matches any string without slashes (default) +- ``int`` — matches digits, converts to ``int`` +- ``float`` — matches decimal numbers, converts to ``float`` +- ``uuid`` — matches UUID strings like ``550e8400-e29b-41d4-a716-446655440000`` +- ``path`` — matches any string *including* slashes, useful for file paths -Responses ---------- +Sending Responses +----------------- -:: +Responder gives you several ways to send data back to the client. Just set +the appropriate property on the response object. - # Text - resp.text = "hello" +**Text and HTML**:: - # HTML - resp.html = "

hello

" + resp.text = "plain text response" + resp.html = "

HTML response

" - # JSON (default) - resp.media = {"hello": "world"} +**JSON** — the most common pattern for APIs. Set ``resp.media`` to any +JSON-serializable Python object:: - # Bytes - resp.content = b"\x00\x01\x02" + @api.route("/hello/{who}/json") + def hello_json(req, resp, *, who): + resp.media = {"hello": who} - # File - resp.file("report.pdf") +If the client sends an ``Accept: application/x-yaml`` header, the same data +will be returned as YAML instead. Content negotiation is automatic. + +**Files** — serve a file from disk with automatic content-type detection:: + + resp.file("reports/annual.pdf") + +**Raw bytes**:: + + resp.content = b"\x89PNG\r\n..." + +**Status codes and headers**:: - # Status code resp.status_code = 201 - - # Headers resp.headers["X-Custom"] = "value" - # Redirect - api.redirect(resp, location="/other") +**Redirects**:: + + api.redirect(resp, location="/new-url") -Requests --------- +Reading Requests +---------------- -:: +The request object gives you access to everything the client sent. - # Method (lowercase) - req.method # "get", "post", etc. +**Method and URL**:: + + req.method # "get", "post", etc. (lowercase) + req.full_url # "http://example.com/path?q=1" + req.url # parsed URL object + +**Headers** — case-insensitive, just like you'd expect:: - # Headers (case-insensitive) req.headers["Content-Type"] + req.headers["content-type"] # same thing - # Query parameters - req.params["q"] +**Query parameters**:: - # Path parameters - req.path_params["user_id"] + # GET /search?q=python&page=2 + req.params["q"] # "python" + req.params["page"] # "2" - # JSON body (must await) +**Path parameters** — also available on the request object:: + + req.path_params["user_id"] # same as the keyword argument + +**Request body** — for POST/PUT/PATCH requests, you need to ``await`` the +body content:: + + # JSON body data = await req.media() - # Raw body + # Form data + data = await req.media("form") + + # File uploads + files = await req.media("files") + + # Raw bytes body = await req.content - # Check content type - req.is_json # True/False + # Raw text + text = await req.text - # Client address - req.client # (host, port) +**Other useful properties**:: + + req.is_json # True if content type is JSON + req.cookies # dict of cookies + req.session # session data (dict) + req.client # (host, port) tuple + req.is_secure # True if HTTPS -Templates ---------- +Rendering Templates +------------------- -Responder includes Jinja2 templating:: +Responder includes built-in `Jinja2 `_ +support. Templates are loaded from the ``templates/`` directory by default. + +The simplest way is to use ``api.template()``:: @api.route("/hello/{name}/html") def hello_html(req, resp, *, name): resp.html = api.template("hello.html", name=name) -Or use the ``Templates`` class directly:: +You can also use the ``Templates`` class directly for more control:: from responder.templates import Templates templates = Templates(directory="templates") - resp.html = templates.render("page.html", title="Hello") + + @api.route("/page") + def page(req, resp): + resp.html = templates.render("page.html", title="Hello") + +Async rendering is supported too:: + + templates = Templates(directory="templates", enable_async=True) + resp.html = await templates.render_async("page.html", title="Hello") + +You can render template strings without a file:: + + resp.html = api.template_string("Hello, {{ name }}!", name="world") Background Tasks ---------------- -Process work in the background while responding immediately:: +Sometimes you want to accept a request, respond immediately, and do the +actual processing later. Responder makes this easy with background tasks:: - @api.route("/work") - async def work(req, resp): + @api.route("/incoming") + async def receive_incoming(req, resp): data = await req.media() @api.background.task - def process(data): + def process_data(data): + """This runs in a background thread.""" import time - time.sleep(10) + time.sleep(10) # simulate heavy work - process(data) - resp.media = {"status": "processing"} + process_data(data) + + # Respond immediately — processing continues in the background + resp.media = {"status": "accepted"} + +The ``@api.background.task`` decorator wraps any function to run in a thread +pool. The client gets an immediate response while the work continues. diff --git a/docs/source/testing.rst b/docs/source/testing.rst index 01756ab..3ae625a 100644 --- a/docs/source/testing.rst +++ b/docs/source/testing.rst @@ -1,12 +1,15 @@ Testing ======= -Responder includes a built-in test client powered by Starlette's TestClient. +Responder includes a built-in test client powered by Starlette's +``TestClient``. You don't need to start a server — tests run in-process, +making them fast and reliable. -Basic Test ----------- -``api.py``:: +Getting Started +--------------- + +Given a simple application in ``api.py``:: import responder @@ -19,15 +22,16 @@ Basic Test if __name__ == "__main__": api.run() -``test_api.py``:: +You can test it with pytest:: + # test_api.py import api as service def test_hello(): r = service.api.requests.get("/") assert r.text == "hello, world!" -Run with pytest:: +Run your tests:: $ pytest @@ -35,7 +39,8 @@ Run with pytest:: Using Fixtures -------------- -:: +For larger test suites, use pytest fixtures to share the API instance +across tests:: import pytest import api as service @@ -56,11 +61,47 @@ Using Fixtures r = api.requests.get(api.url_for(data)) assert r.json() == {"key": "value"} +The ``api.url_for()`` method generates a URL for a given route endpoint, +so you don't have to hard-code paths in your tests. + + +Testing JSON APIs +----------------- + +Send JSON data and check the response:: + + def test_create_item(api): + @api.route("/items") + async def create(req, resp): + data = await req.media() + resp.media = {"created": data} + resp.status_code = 201 + + r = api.requests.post(api.url_for(create), json={"name": "widget"}) + assert r.status_code == 201 + assert r.json() == {"created": {"name": "widget"}} + + +Testing File Uploads +-------------------- + +Send files using the ``files`` parameter:: + + def test_upload(api): + @api.route("/upload") + async def upload(req, resp): + files = await req.media("files") + resp.media = {"received": list(files.keys())} + + files = {"doc": ("report.pdf", b"content", "application/pdf")} + r = api.requests.post(api.url_for(upload), files=files) + assert r.json() == {"received": ["doc"]} + Testing WebSockets ------------------ -:: +Use Starlette's ``TestClient`` directly for WebSocket connections:: from starlette.testclient import TestClient @@ -76,17 +117,41 @@ Testing WebSockets assert ws.receive_text() == "hello" -Testing File Uploads --------------------- +Testing Error Handling +---------------------- -:: +To test error responses without pytest raising the exception, disable +server exception propagation:: - def test_upload(api): - @api.route("/upload") - async def upload(req, resp): - files = await req.media("files") - resp.media = {"name": list(files.keys())[0]} + from starlette.testclient import TestClient - files = {"doc": ("test.txt", b"content", "text/plain")} - r = api.requests.post(api.url_for(upload), files=files) - assert r.json() == {"name": "doc"} + def test_500(api): + @api.route("/fail") + def fail(req, resp): + raise ValueError("something broke") + + client = TestClient(api, raise_server_exceptions=False) + r = client.get(api.url_for(fail)) + assert r.status_code == 500 + + +Testing Lifespan Events +----------------------- + +The test client supports lifespan events. Use ``with`` to ensure startup +and shutdown hooks run:: + + def test_with_lifespan(api): + started = {"value": False} + + @api.on_event("startup") + async def on_startup(): + started["value"] = True + + @api.route("/") + def check(req, resp): + resp.media = {"started": started["value"]} + + with api.requests as session: + r = session.get("http://;/") + assert r.json() == {"started": True} diff --git a/docs/source/tour.rst b/docs/source/tour.rst index cad2bc9..12d67d2 100644 --- a/docs/source/tour.rst +++ b/docs/source/tour.rst @@ -1,11 +1,15 @@ Feature Tour ============ +This section walks through Responder's features in detail. Each section +includes working code examples you can copy into your application. + Method Filtering ---------------- -Restrict routes to specific HTTP methods:: +By default, a route matches all HTTP methods. If you want to restrict a +route to specific methods, pass the ``methods`` parameter:: @api.route("/items", methods=["GET"]) def list_items(req, resp): @@ -16,11 +20,15 @@ Restrict routes to specific HTTP methods:: data = await req.media() resp.media = {"created": data} +Note the ``check_existing=False`` — this allows you to register multiple +handlers for the same path with different methods. + Class-Based Views ----------------- -:: +For more complex resources, you can use class-based views. Responder will +dispatch to the appropriate method handler based on the HTTP method:: @api.route("/{greeting}") class GreetingResource: @@ -31,28 +39,36 @@ Class-Based Views resp.media = {"received": greeting} def on_request(self, req, resp, *, greeting): - """Called on every request method.""" + """Called on EVERY request, before the method-specific handler.""" resp.headers["X-Greeting"] = greeting +The ``on_request`` method is called for all HTTP methods, much like +middleware scoped to a single route. Method-specific handlers (``on_get``, +``on_post``, ``on_put``, ``on_delete``, etc.) are called after. + +No inheritance required — just define a class with the right method names. + Lifespan Events --------------- -Use a context manager for startup and shutdown:: +Modern applications often need to set up resources on startup (database +connections, caches, ML models) and tear them down on shutdown. Responder +supports the lifespan context manager pattern:: from contextlib import asynccontextmanager @asynccontextmanager async def lifespan(app): - # Startup + # Startup — runs before the first request print("connecting to database...") yield - # Shutdown + # Shutdown — runs after the server stops print("closing connections...") api = responder.API(lifespan=lifespan) -Or use event decorators:: +You can also use the traditional event decorator style:: @api.on_event("startup") async def startup(): @@ -62,42 +78,57 @@ Or use event decorators:: async def shutdown(): print("shutting down") +The context manager approach is preferred for new code — it makes the +startup/shutdown relationship explicit and keeps related code together. -File Serving ------------- -Serve files with automatic content-type detection:: +Serving Files +------------- + +Serve files from disk with automatic content-type detection. Responder +uses Python's ``mimetypes`` module to figure out the right ``Content-Type`` +header for you:: @api.route("/download") def download(req, resp): resp.file("reports/annual.pdf") +You can override the content type if needed:: + @api.route("/image") def image(req, resp): resp.file("photos/cat.jpg", content_type="image/jpeg") -Error Handling --------------- +Custom Error Handling +--------------------- -Register handlers for specific exception types:: +By default, unhandled exceptions result in a 500 Internal Server Error. +You can register custom handlers for specific exception types to return +structured error responses:: @api.exception_handler(ValueError) async def handle_value_error(req, resp, exc): resp.status_code = 400 resp.media = {"error": str(exc)} +Now, any route that raises a ``ValueError`` will return a clean 400 response +with a JSON error message instead of a generic 500 page. + Before-Request Hooks -------------------- -Run code before every request:: +Run code before every request. This is useful for logging, adding common +headers, or setting up per-request state:: @api.route(before_request=True) def add_headers(req, resp): resp.headers["X-API-Version"] = "3.1" -Short-circuit by setting a status code — the route handler will be skipped:: +**Short-circuiting:** If your hook sets ``resp.status_code``, the route +handler will be skipped entirely and the response will be sent immediately. +This is the pattern for authentication guards:: @api.route(before_request=True) def auth_check(req, resp): @@ -105,11 +136,20 @@ Short-circuit by setting a status code — the route handler will be skipped:: resp.status_code = 401 resp.media = {"error": "unauthorized"} +If the ``Authorization`` header is missing, the client gets a 401 response +and the actual route handler never runs. -WebSockets ----------- +WebSocket hooks work the same way:: -:: + @api.before_request(websocket=True) + async def ws_auth(ws): + await ws.accept() + + +WebSocket Support +----------------- + +Responder supports WebSockets for real-time, bidirectional communication:: @api.route("/ws", websocket=True) async def websocket(ws): @@ -119,46 +159,79 @@ WebSockets await ws.send_text(f"Hello {name}!") await ws.close() -Supported formats: ``send_text``, ``send_json``, ``send_bytes``. +You can send and receive in multiple formats: + +- ``send_text`` / ``receive_text`` — plain text +- ``send_json`` / ``receive_json`` — JSON objects +- ``send_bytes`` / ``receive_bytes`` — raw binary data GraphQL ------- -One-liner setup with `Graphene `_:: +Responder includes built-in GraphQL support via +`Graphene `_. Set up a full GraphQL endpoint +with a single method call:: import graphene class Query(graphene.ObjectType): hello = graphene.String(name=graphene.String(default_value="stranger")) + def resolve_hello(self, info, name): return f"Hello {name}" api.graphql("/graphql", schema=graphene.Schema(query=Query)) -Visiting ``/graphql`` in a browser renders the GraphiQL IDE. +Visiting ``/graphql`` in a browser renders the GraphiQL interactive IDE, +where you can explore your schema and test queries. Programmatic clients +can POST JSON queries to the same endpoint. + +You can access the Responder request and response objects in your resolvers +through ``info.context["request"]`` and ``info.context["response"]``. -OpenAPI -------- +OpenAPI Documentation +--------------------- -:: +Responder can generate an OpenAPI schema and serve interactive API +documentation automatically:: api = responder.API( - title="My API", + title="Pet Store", version="1.0", openapi="3.0.2", docs_route="/docs", ) -Visit ``/docs`` for interactive Swagger UI documentation. -The schema is served at ``/schema.yml``. +This gives you: + +- An OpenAPI schema at ``/schema.yml`` +- Interactive Swagger UI documentation at ``/docs`` + +Document your endpoints using YAML in docstrings:: + + @api.route("/pets") + def list_pets(req, resp): + """A list of pets. + --- + get: + description: Get all pets + responses: + 200: + description: A list of pets + """ + resp.media = [{"name": "Fido"}] + +You can choose from multiple documentation themes: +``swagger_ui`` (default), ``redoc``, ``rapidoc``, or ``elements``. -Mounting Apps -------------- +Mounting Other Apps +------------------- -Mount any WSGI or ASGI application at a subroute:: +Responder can mount any WSGI or ASGI application at a subroute. This means +you can gradually migrate from Flask, or run multiple frameworks side by side:: from flask import Flask @@ -170,26 +243,42 @@ Mount any WSGI or ASGI application at a subroute:: api.mount("/flask", flask_app) +Requests to ``/flask/`` will be handled by Flask. Everything else goes +through Responder. Both WSGI and ASGI apps are supported — Responder +wraps WSGI apps automatically. + Cookies ------- -:: +Reading and writing cookies is straightforward:: - # Read cookies - req.cookies["session_id"] + # Read cookies from the request + session_id = req.cookies.get("session_id") - # Set cookies + # Set a cookie on the response resp.cookies["hello"] = "world" - # With directives - resp.set_cookie("token", value="abc", max_age=3600, secure=True) +For more control over cookie directives, use ``set_cookie``:: + + resp.set_cookie( + "token", + value="abc123", + max_age=3600, + secure=True, + httponly=True, + path="/", + ) + +Supported directives: ``key``, ``value``, ``expires``, ``max_age``, +``domain``, ``path``, ``secure``, ``httponly``. -Sessions --------- +Cookie-Based Sessions +--------------------- -Built-in cookie-based sessions:: +Responder has built-in support for signed, cookie-based sessions. Just +read from and write to the ``session`` dictionary:: @api.route("/login") def login(req, resp): @@ -199,9 +288,15 @@ Built-in cookie-based sessions:: def profile(req, resp): resp.media = {"user": req.session.get("username")} -Set a secret key for production:: +The session data is stored in a cookie called ``Responder-Session``. It's +signed for tamper protection, so you can trust that the data originated +from your server. - api = responder.API(secret_key="your-secret-key") +.. warning:: + + For production use, always set a secret key:: + + api = responder.API(secret_key="your-secret-key-here") Static Files @@ -211,34 +306,56 @@ Static files are served from the ``static/`` directory by default:: api = responder.API(static_dir="static", static_route="/static") -For single-page apps, serve ``index.html`` as the default:: +Place your CSS, JavaScript, images, and other assets in the ``static/`` +directory and they'll be served automatically. + +For single-page applications, you can serve ``index.html`` as the default +response for all unmatched routes:: api.add_route("/", static=True) +You can add additional static directories at runtime:: + + api.static_app.add_directory("extra_assets") + CORS ---- -:: +Enable Cross-Origin Resource Sharing for your API:: api = responder.API(cors=True, cors_params={ "allow_origins": ["https://example.com"], "allow_methods": ["GET", "POST"], "allow_headers": ["*"], + "allow_credentials": True, + "max_age": 600, }) +The default CORS policy is restrictive — you must explicitly enable the +origins, methods, and headers your frontend needs. + HSTS ---- -Redirect all traffic to HTTPS:: +Force all traffic to HTTPS with a single flag:: api = responder.API(enable_hsts=True) +This adds the ``Strict-Transport-Security`` header and redirects HTTP +requests to HTTPS. + Trusted Hosts ------------- -:: +Protect against HTTP Host header attacks by restricting which hostnames +your application will respond to:: api = responder.API(allowed_hosts=["example.com", "*.example.com"]) + +Requests with a ``Host`` header that doesn't match any of the patterns +will receive a 400 Bad Request response. Wildcard domains are supported. + +By default, all hostnames are allowed. diff --git a/pyproject.toml b/pyproject.toml index 3f4febd..de80b7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "graphql-core>=3.1", "marshmallow", "pueblo[sfa-full]>=0.0.11", + "pydantic>=2", "python-multipart", "starlette[full]>=0.40", "uvicorn[standard]", diff --git a/responder/api.py b/responder/api.py index a759d46..b2a3f7d 100644 --- a/responder/api.py +++ b/responder/api.py @@ -327,7 +327,7 @@ class API: self.router.add_event_handler(event_type, handler) - def route(self, route=None, **options): + def route(self, route=None, *, request_model=None, response_model=None, **options): """Decorator for creating new routes around function and class definitions. Usage:: @@ -336,9 +336,40 @@ class API: def hello(req, resp): resp.text = "hello, world!" + With Pydantic models for OpenAPI documentation:: + + from pydantic import BaseModel + + class ItemIn(BaseModel): + name: str + price: float + + class ItemOut(BaseModel): + id: int + name: str + price: float + + @api.route("/items", methods=["POST"], + request_model=ItemIn, response_model=ItemOut) + async def create_item(req, resp): + data = await req.media() + resp.media = {"id": 1, **data} + """ def decorator(f): + if request_model is not None: + f._request_model = request_model + if hasattr(self, "openapi"): + self.openapi.add_schema( + request_model.__name__, request_model, check_existing=False + ) + if response_model is not None: + f._response_model = response_model + if hasattr(self, "openapi"): + self.openapi.add_schema( + response_model.__name__, response_model, check_existing=False + ) self.add_route(route, f, **options) return f diff --git a/responder/ext/openapi/__init__.py b/responder/ext/openapi/__init__.py index a2caa31..c6746ea 100644 --- a/responder/ext/openapi/__init__.py +++ b/responder/ext/openapi/__init__.py @@ -8,6 +8,38 @@ from responder.statics import API_THEMES, DEFAULT_OPENAPI_THEME from responder.templates import Templates +def _is_pydantic_model(obj): + """Check if obj is a Pydantic model class.""" + try: + from pydantic import BaseModel + + return isinstance(obj, type) and issubclass(obj, BaseModel) + except ImportError: + return False + + +class PydanticPlugin: + """APISpec plugin that resolves Pydantic models to JSON Schema.""" + + def __init__(self): + self._schemas = {} + + def definition_helper(self, name, definition, **kwargs): + schema = kwargs.get("schema") + if schema is not None and _is_pydantic_model(schema): + return schema.model_json_schema() + return None + + def resolve_schemas(self, spec): + pass + + def init_spec(self, spec): + pass + + def operation_helper(self, **kwargs): + return {} + + class OpenAPISchema: def __init__( self, @@ -27,6 +59,7 @@ class OpenAPISchema: ): self.app = app self.schemas = {} + self.pydantic_schemas = {} self.title = title self.version = version self.description = description @@ -80,9 +113,56 @@ class OpenAPISchema: operations = yaml_utils.load_operations_from_docstring(route.description) spec.path(path=route.route, operations=operations) + # Check for Pydantic-annotated routes + endpoint = route.endpoint + req_model = getattr(endpoint, "_request_model", None) + resp_model = getattr(endpoint, "_response_model", None) + + if req_model or resp_model: + operations = {} + methods = getattr(route, "methods", None) or ["get"] + + for method in [m.lower() for m in methods]: + op = {} + if req_model and method in ("post", "put", "patch"): + model_name = req_model.__name__ + op["requestBody"] = { + "content": { + "application/json": { + "schema": {"$ref": f"#/components/schemas/{model_name}"} + } + } + } + if resp_model: + model_name = resp_model.__name__ + op["responses"] = { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": f"#/components/schemas/{model_name}" + } + } + }, + } + } + if op: + operations[method] = op + + if operations and not route.description: + spec.path(path=route.route, operations=operations) + + # Register marshmallow schemas for name, schema in self.schemas.items(): spec.components.schema(name, schema=schema) + # Register Pydantic schemas + for name, model in self.pydantic_schemas.items(): + json_schema = model.model_json_schema() + json_schema.pop("title", None) + spec.components.schema(name, component=json_schema) + return spec @property @@ -90,14 +170,18 @@ class OpenAPISchema: return self._apispec.to_yaml() def add_schema(self, name, schema, check_existing=True): - """Adds a marshmallow schema to the API specification.""" + """Adds a marshmallow or Pydantic schema to the API specification.""" if check_existing: assert name not in self.schemas + assert name not in self.pydantic_schemas - self.schemas[name] = schema + if _is_pydantic_model(schema): + self.pydantic_schemas[name] = schema + else: + self.schemas[name] = schema def schema(self, name, **options): - """Decorator for creating new routes around function and class definitions. + """Decorator for registering schemas (marshmallow or Pydantic). Usage:: @@ -107,6 +191,15 @@ class OpenAPISchema: class PetSchema(Schema): name = fields.Str() + Or with Pydantic:: + + from pydantic import BaseModel + + @api.schema("Pet") + class Pet(BaseModel): + name: str + age: int = 0 + """ def decorator(f): diff --git a/tests/test_coverage.py b/tests/test_coverage.py index b9b9eab..dbf5a5a 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -586,6 +586,61 @@ def test_openapi_static_url(): assert url == "/static/swagger-ui.css" +def test_pydantic_schema(): + """Pydantic models registered via @api.schema.""" + from pydantic import BaseModel + + api = responder.API( + title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"], + ) + + @api.schema("Pet") + class Pet(BaseModel): + name: str + age: int = 0 + + r = api.requests.get("http://;/schema.yml") + assert r.status_code == 200 + assert "Pet" in r.text + assert "name" in r.text + assert "type: string" in r.text + + +def test_pydantic_request_response_models(): + """request_model and response_model generate OpenAPI schemas.""" + from pydantic import BaseModel + + api = responder.API( + title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"], + ) + + class ItemIn(BaseModel): + name: str + price: float + + class ItemOut(BaseModel): + id: int + name: str + price: float + + @api.route("/items", methods=["POST"], + request_model=ItemIn, response_model=ItemOut) + async def create(req, resp): + data = await req.media() + resp.media = {"id": 1, **data} + + # Check schema generation + r = api.requests.get("http://;/schema.yml") + assert "ItemIn" in r.text + assert "ItemOut" in r.text + assert "$ref" in r.text + assert "requestBody" in r.text + + # Check the endpoint still works + r = api.requests.post("http://;/items", json={"name": "widget", "price": 9.99}) + assert r.json() == {"id": 1, "name": "widget", "price": 9.99} + + def test_templates_context(tmp_path): """Lines 23, 27: Templates.context getter and setter.""" template_dir = tmp_path / "templates"