mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f02016ed6 | |||
| 3c2b1acc19 | |||
| a3b49ab9fd | |||
| bf17b02653 | |||
| 9383cd0f16 | |||
| 226bd63ed3 | |||
| b3c55f68d9 | |||
| a2a2ae21ff | |||
| 84074860aa | |||
| 21baa03640 | |||
| 0c552e25cb |
@@ -11,6 +11,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
|
||||
documentation:
|
||||
@@ -40,3 +43,11 @@ jobs:
|
||||
|
||||
- name: Build static HTML documentation
|
||||
run: sphinx-build -W --keep-going docs/source docs/build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs/build
|
||||
cname: responder.kennethreitz.org
|
||||
|
||||
@@ -7,6 +7,44 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v3.2.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
- Pydantic auto-validation: `request_model` validates input, returns 422 on failure
|
||||
- Pydantic auto-serialization: `response_model` strips extra fields from responses
|
||||
- Server-Sent Events: `@resp.sse` for real-time streaming
|
||||
- `resp.stream_file()` for streaming large files without loading into memory
|
||||
- `@api.after_request()` hooks
|
||||
- `api.group("/prefix")` for route groups and API versioning
|
||||
- `api.graphql("/path", schema=schema)` one-liner GraphQL setup
|
||||
- `api = responder.API(request_id=True)` for automatic request ID generation
|
||||
- Built-in rate limiter: `RateLimiter(requests=100, period=60).install(api)`
|
||||
- MessagePack format support: `await req.media("msgpack")`
|
||||
- `req.is_json`, `req.path_params`, `req.client` properties
|
||||
- `api.exception_handler()` decorator for custom error handling
|
||||
- Lifespan context manager support
|
||||
- `uuid` and `path` route convertors
|
||||
- PEP 561 `py.typed` marker
|
||||
- Pydantic support for OpenAPI schema generation
|
||||
|
||||
### Changed
|
||||
|
||||
- Dependencies flattened: `pip install responder` gets everything
|
||||
- Core deps reduced to starlette + uvicorn
|
||||
- TestClient lazy-loaded (no httpx import in production)
|
||||
- Before-request hooks can short-circuit by setting status code
|
||||
- Removed poethepoet task runner
|
||||
|
||||
### Fixed
|
||||
|
||||
- Multipart parser losing headers when parts have multiple headers
|
||||
- `url_for()` with typed route params (`{id:int}`)
|
||||
- `resp.body` encoding crash on bytes content
|
||||
- GraphQL text query missing `await`
|
||||
- Streaming responses not sending Content-Type headers
|
||||
- Python 3.9 compatibility for union type syntax
|
||||
|
||||
## [v3.0.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -17,7 +17,7 @@ if __name__ == "__main__":
|
||||
|
||||
$ pip install responder
|
||||
|
||||
That's it. Supports Python 3.9+.
|
||||
That's it. Supports Python 3.10+.
|
||||
|
||||
## The Basics
|
||||
|
||||
|
||||
+77
-13
@@ -1,35 +1,99 @@
|
||||
API Reference
|
||||
=============
|
||||
|
||||
API Documentation
|
||||
=================
|
||||
This page documents Responder's public Python API. For usage examples
|
||||
and explanations, see the :doc:`quickstart` and :doc:`tour`.
|
||||
|
||||
|
||||
Web Service (API) Class
|
||||
-----------------------
|
||||
The API Class
|
||||
-------------
|
||||
|
||||
The central object of every Responder application. It holds your routes,
|
||||
middleware, templates, and configuration. Create one at the top of your
|
||||
module and use it to define your entire web service.
|
||||
|
||||
.. module:: responder
|
||||
|
||||
.. autoclass:: API
|
||||
:inherited-members:
|
||||
|
||||
Requests & Responses
|
||||
--------------------
|
||||
|
||||
Request
|
||||
-------
|
||||
|
||||
The request object is passed into every view as the first argument. It
|
||||
gives you access to everything the client sent — headers, query
|
||||
parameters, the request body, cookies, and more.
|
||||
|
||||
Most properties are synchronous, but reading the body requires ``await``
|
||||
because it involves I/O.
|
||||
|
||||
.. autoclass:: Request
|
||||
:inherited-members:
|
||||
|
||||
|
||||
Response
|
||||
--------
|
||||
|
||||
The response object is passed into every view as the second argument.
|
||||
Mutate it to control what gets sent back to the client — the body,
|
||||
status code, headers, and cookies.
|
||||
|
||||
.. autoclass:: Response
|
||||
:inherited-members:
|
||||
|
||||
|
||||
Utility Functions
|
||||
-----------------
|
||||
Route Groups
|
||||
------------
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_100
|
||||
Group related routes under a shared URL prefix — useful for API versioning
|
||||
and organizing large applications.
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_200
|
||||
.. autoclass:: responder.api.RouteGroup
|
||||
:members:
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_300
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_400
|
||||
Background Queue
|
||||
----------------
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_500
|
||||
Run tasks in background threads without blocking the response. Available
|
||||
as ``api.background``.
|
||||
|
||||
.. autoclass:: responder.background.BackgroundQueue
|
||||
:members:
|
||||
|
||||
|
||||
Query Dict
|
||||
----------
|
||||
|
||||
A dictionary subclass for query string parameters with multi-value support.
|
||||
|
||||
.. autoclass:: responder.models.QueryDict
|
||||
:members:
|
||||
|
||||
|
||||
Rate Limiter
|
||||
------------
|
||||
|
||||
In-memory token bucket rate limiter. Limits requests per client IP address
|
||||
and returns ``429 Too Many Requests`` when exceeded.
|
||||
|
||||
.. autoclass:: responder.ext.ratelimit.RateLimiter
|
||||
:members:
|
||||
|
||||
|
||||
Status Code Helpers
|
||||
-------------------
|
||||
|
||||
Convenience functions for checking which category a status code falls
|
||||
into. Useful in middleware and after-request hooks.
|
||||
|
||||
.. autofunction:: responder.status_codes.is_100
|
||||
|
||||
.. autofunction:: responder.status_codes.is_200
|
||||
|
||||
.. autofunction:: responder.status_codes.is_300
|
||||
|
||||
.. autofunction:: responder.status_codes.is_400
|
||||
|
||||
.. autofunction:: responder.status_codes.is_500
|
||||
|
||||
+78
-171
@@ -1,174 +1,81 @@
|
||||
Responder CLI
|
||||
=============
|
||||
Command Line Interface
|
||||
======================
|
||||
|
||||
Responder installs a command line program ``responder``. Use it to launch
|
||||
a Responder application from a file or module, either located on a local
|
||||
or remote filesystem, or object store.
|
||||
|
||||
Launch Module Entrypoint
|
||||
------------------------
|
||||
|
||||
For loading a Responder application from a Python module, you will refer to
|
||||
its ``API()`` instance using a `Python entry point object reference`_ that
|
||||
points to a Python object. It is either in the form ``importable.module``,
|
||||
or ``importable.module:object.attr``.
|
||||
|
||||
A basic invocation command to launch a Responder application:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run acme.app
|
||||
|
||||
The command above assumes a Python package ``acme`` including an ``app``
|
||||
module ``acme/app.py`` that includes an attribute ``api`` that refers
|
||||
to a ``responder.API`` instance, reflecting the typical layout of
|
||||
a standard Responder application.
|
||||
|
||||
Loading a Responder application using an entrypoint specification will
|
||||
inherit the capacities of `Python's import system`_, as implemented by
|
||||
`importlib`_.
|
||||
|
||||
Launch Local File
|
||||
-----------------
|
||||
|
||||
Acquire a minimal example single-file application, ``helloworld.py`` [1]_,
|
||||
to your local filesystem, giving you the chance to edit it, and launch the
|
||||
Responder HTTP service.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
wget https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
|
||||
responder run helloworld.py
|
||||
|
||||
.. note::
|
||||
|
||||
To validate the example application, invoke a HTTP request, for example using
|
||||
`curl`_, `HTTPie`_, or your favourite browser at hand.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
http http://127.0.0.1:5042/Hello
|
||||
|
||||
The response is no surprise.
|
||||
|
||||
::
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
content-length: 13
|
||||
content-type: text/plain
|
||||
date: Sat, 26 Oct 2024 13:16:55 GMT
|
||||
encoding: utf-8
|
||||
server: uvicorn
|
||||
|
||||
Hello, world!
|
||||
|
||||
.. [1] The Responder application `helloworld.py`_ implements a basic echo handler.
|
||||
|
||||
Launch Remote File
|
||||
------------------
|
||||
|
||||
You can also launch a single-file application where its Python file is stored
|
||||
on a remote location.
|
||||
|
||||
Responder supports all filesystem adapters compatible with `fsspec`_, and
|
||||
installs the adapters for Azure Blob Storage (az), Google Cloud Storage (gs),
|
||||
GitHub, HTTP, and AWS S3 by default.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# Works 1:1.
|
||||
responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
|
||||
responder run github://kennethreitz:responder@/examples/helloworld.py
|
||||
|
||||
If you need access other kinds of remote targets, see the `list of
|
||||
fsspec-supported filesystems and protocols`_. The next section enumerates
|
||||
a few synthetic examples. The corresponding storage buckets do not even
|
||||
exist, so don't expect those commands to work.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# Azure Blob Storage, Google Cloud Storage, and AWS S3.
|
||||
responder run az://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run gs://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run s3://kennethreitz-assets/responder/examples/helloworld.py
|
||||
|
||||
# Hadoop Distributed File System (hdfs), SSH File Transfer Protocol (sftp),
|
||||
# Common Internet File System (smb), Web-based Distributed Authoring and
|
||||
# Versioning (webdav).
|
||||
responder run hdfs://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run sftp://user@host/kennethreitz/responder/examples/helloworld.py
|
||||
responder run smb://workgroup;user:password@server:port/responder/examples/helloworld.py
|
||||
responder run webdav+https://user:password@server:port/responder/examples/helloworld.py
|
||||
|
||||
.. tip::
|
||||
|
||||
In order to install support for all filesystem types supported by fsspec, run:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
uv pip install 'fsspec[full]'
|
||||
|
||||
When using ``uv``, this concludes within an acceptable time of approx.
|
||||
25 seconds. If you need to be more selectively instead of using ``full``,
|
||||
choose from one or multiple of the available `fsspec extras`_, which are:
|
||||
|
||||
abfs, arrow, dask, dropbox, fuse, gcs, git, github, hdfs, http, oci, s3,
|
||||
sftp, smb, ssh.
|
||||
|
||||
Launch with Non-Standard Instance Name
|
||||
--------------------------------------
|
||||
|
||||
By default, Responder will acquire an ``responder.API`` instance using the
|
||||
symbol name ``api`` from the specified Python module.
|
||||
|
||||
If your main application file uses a different name than ``api``, please
|
||||
append the designated symbol name to the launch target address.
|
||||
|
||||
It works like this for module entrypoints and local files:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run acme.app:service
|
||||
responder run /path/to/acme/app.py:service
|
||||
|
||||
It works like this for URLs:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run http://app.server.local/path/to/acme/app.py#service
|
||||
|
||||
Within your ``app.py``, the instance would have been defined to use
|
||||
the ``service`` symbol name instead of ``api``, like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
service = responder.API()
|
||||
|
||||
Build JavaScript Application
|
||||
----------------------------
|
||||
|
||||
The ``build`` subcommand invokes ``npm run build``, optionally accepting
|
||||
a target directory. By default, it uses the current working directory,
|
||||
where it expects a regular NPM ``package.json`` file.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder build
|
||||
|
||||
When specifying a target directory, Responder will change to that
|
||||
directory beforehand.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder build /path/to/project
|
||||
Responder installs a ``responder`` command that lets you launch
|
||||
applications from the terminal. You can point it at a Python module,
|
||||
a local file, or even a URL — and it will find your ``API`` instance
|
||||
and start serving.
|
||||
|
||||
|
||||
.. _curl: https://curl.se/
|
||||
.. _fsspec: https://filesystem-spec.readthedocs.io/en/latest/
|
||||
.. _fsspec extras: https://github.com/fsspec/filesystem_spec/blob/2024.12.0/pyproject.toml#L27-L69
|
||||
.. _helloworld.py: https://github.com/kennethreitz/responder/blob/main/examples/helloworld.py
|
||||
.. _HTTPie: https://httpie.io/docs/cli
|
||||
.. _importlib: https://docs.python.org/3/library/importlib.html
|
||||
.. _list of fsspec-supported filesystems and protocols: https://github.com/fsspec/universal_pathlib#currently-supported-filesystems-and-protocols
|
||||
.. _Python entry point object reference: https://packaging.python.org/en/latest/specifications/entry-points/
|
||||
.. _Python's import system: https://docs.python.org/3/reference/import.html
|
||||
Launching from a Module
|
||||
-----------------------
|
||||
|
||||
The most common way to run a Responder application in production. Use
|
||||
Python's standard dotted module path::
|
||||
|
||||
$ responder run acme.app
|
||||
|
||||
This imports ``acme.app`` and looks for an attribute called ``api``
|
||||
(a ``responder.API`` instance). It's the same import system Python
|
||||
uses everywhere — your ``PYTHONPATH`` and virtual environment are
|
||||
respected.
|
||||
|
||||
|
||||
Launching from a File
|
||||
---------------------
|
||||
|
||||
During development, you often have a single file you want to run::
|
||||
|
||||
$ responder run helloworld.py
|
||||
|
||||
This loads the file directly and starts the server. Quick and easy for
|
||||
prototyping and single-file applications.
|
||||
|
||||
You can test it with a simple HTTP request::
|
||||
|
||||
$ curl http://127.0.0.1:5042/hello
|
||||
hello, world!
|
||||
|
||||
|
||||
Launching from a URL
|
||||
--------------------
|
||||
|
||||
Responder can fetch and run a Python file from any URL — great for
|
||||
demos, sharing examples, and running code from GitHub::
|
||||
|
||||
$ responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
|
||||
|
||||
This also works with ``github://`` URLs and any filesystem protocol
|
||||
supported by `fsspec <https://filesystem-spec.readthedocs.io/>`_::
|
||||
|
||||
$ responder run github://kennethreitz:responder@/examples/helloworld.py
|
||||
|
||||
Cloud storage is supported too — Azure Blob Storage, Google Cloud
|
||||
Storage, S3, HDFS, SFTP, and more. Install ``fsspec[full]`` for all
|
||||
protocols::
|
||||
|
||||
$ uv pip install 'fsspec[full]'
|
||||
|
||||
|
||||
Custom Instance Names
|
||||
---------------------
|
||||
|
||||
By default, Responder looks for an attribute called ``api``. If your
|
||||
application uses a different name, specify it with a colon::
|
||||
|
||||
$ responder run acme.app:service
|
||||
$ responder run myapp.py:application
|
||||
|
||||
For URLs, use a fragment::
|
||||
|
||||
$ responder run https://example.com/app.py#service
|
||||
|
||||
|
||||
Building Frontend Assets
|
||||
-------------------------
|
||||
|
||||
If your project includes a JavaScript frontend with a ``package.json``,
|
||||
the ``build`` subcommand runs ``npm run build``::
|
||||
|
||||
$ responder build
|
||||
$ responder build /path/to/frontend
|
||||
|
||||
+50
-33
@@ -1,34 +1,32 @@
|
||||
Deployment
|
||||
==========
|
||||
|
||||
Responder applications are standard ASGI apps. You can deploy them anywhere
|
||||
you'd deploy a Python web service.
|
||||
Responder applications are standard `ASGI <https://asgi.readthedocs.io/>`_
|
||||
apps. ASGI (Asynchronous Server Gateway Interface) is the modern successor
|
||||
to WSGI — it supports async, WebSockets, and HTTP/2. This means you can
|
||||
deploy a Responder app anywhere that runs Python, using any ASGI server.
|
||||
|
||||
|
||||
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!"
|
||||
During development, ``api.run()`` is all you need::
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
This starts a production uvicorn server on ``127.0.0.1:5042``.
|
||||
This starts a `uvicorn <https://www.uvicorn.org/>`_ server on
|
||||
``127.0.0.1:5042``. Uvicorn is a lightning-fast ASGI server built on
|
||||
`uvloop <https://uvloop.readthedocs.io/>`_ — it handles thousands of
|
||||
concurrent connections efficiently and protects against slowloris attacks,
|
||||
making a reverse proxy like nginx optional for many deployments.
|
||||
|
||||
|
||||
Docker
|
||||
------
|
||||
|
||||
A minimal Dockerfile for deploying a Responder application::
|
||||
Docker is the most common way to package and deploy web applications.
|
||||
Here's a minimal Dockerfile::
|
||||
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
@@ -43,44 +41,63 @@ Build and run::
|
||||
$ docker build -t myapi .
|
||||
$ docker run -p 8000:80 myapi
|
||||
|
||||
The ``python:3.13-slim`` image is about 150MB — small enough for fast
|
||||
deploys but includes everything you need. For even smaller images, you
|
||||
can use ``python:3.13-alpine``, though some packages may need extra
|
||||
build dependencies.
|
||||
|
||||
|
||||
Cloud Platforms
|
||||
---------------
|
||||
|
||||
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.
|
||||
Responder automatically honors the ``PORT`` environment variable. When
|
||||
``PORT`` is set, the server binds to ``0.0.0.0`` on that port — this is
|
||||
the convention that virtually every cloud platform uses.
|
||||
|
||||
This works out of the box with:
|
||||
This means zero configuration on:
|
||||
|
||||
- **Fly.io**
|
||||
- **Railway**
|
||||
- **Render**
|
||||
- **Google Cloud Run**
|
||||
- **Azure Container Apps**
|
||||
- **AWS App Runner**
|
||||
- **Fly.io** — ``fly launch`` and you're done
|
||||
- **Railway** — push your code, Railway sets ``PORT``
|
||||
- **Render** — set start command to ``python api.py``
|
||||
- **Google Cloud Run** — containerize and deploy
|
||||
- **Azure Container Apps** — same pattern
|
||||
- **AWS App Runner** — and here too
|
||||
|
||||
Just deploy your code and set the start command to ``python api.py``.
|
||||
The pattern is always the same: deploy your code, set the start command
|
||||
to ``python api.py``, and the platform handles the rest.
|
||||
|
||||
|
||||
Uvicorn Directly
|
||||
----------------
|
||||
|
||||
For more control over the production server, you can bypass ``api.run()``
|
||||
and use uvicorn directly::
|
||||
For production deployments where you want more control, bypass
|
||||
``api.run()`` and use uvicorn directly::
|
||||
|
||||
$ 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 <https://www.uvicorn.org/>`_ for details.
|
||||
The ``--workers`` flag spawns multiple processes, each handling requests
|
||||
independently. A good starting point is 2-4 workers per CPU core.
|
||||
|
||||
Uvicorn supports many options — SSL certificates, access logging, graceful
|
||||
shutdown timeouts, and more. See the
|
||||
`uvicorn documentation <https://www.uvicorn.org/deployment/>`_ 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.
|
||||
For high-traffic production deployments, you may want a reverse proxy like
|
||||
`nginx <https://nginx.org/>`_ or `Caddy <https://caddyserver.com/>`_ in
|
||||
front of your application for:
|
||||
|
||||
- **SSL/TLS termination** — let the proxy handle HTTPS certificates
|
||||
- **Load balancing** — distribute traffic across multiple app instances
|
||||
- **Static asset serving** — offload static files to the proxy
|
||||
- **Rate limiting** — at the infrastructure level
|
||||
|
||||
Responder's ``TrustedHostMiddleware`` and ``HTTPSRedirectMiddleware`` work
|
||||
correctly behind proxies that set standard forwarding headers.
|
||||
correctly behind proxies that set standard forwarding headers
|
||||
(``X-Forwarded-For``, ``X-Forwarded-Proto``).
|
||||
|
||||
That said, uvicorn is production-ready on its own. Many applications run
|
||||
uvicorn directly without a reverse proxy and do just fine.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
Configuration
|
||||
=============
|
||||
|
||||
Every application needs different settings for different environments —
|
||||
debug mode in development, real secrets in production, different database
|
||||
URLs for testing. This guide covers how to manage configuration cleanly.
|
||||
|
||||
|
||||
Environment Variables
|
||||
---------------------
|
||||
|
||||
The simplest and most universal approach. Environment variables work
|
||||
everywhere — locally, in Docker, on cloud platforms — and keep secrets
|
||||
out of your source code::
|
||||
|
||||
import os
|
||||
import responder
|
||||
|
||||
api = responder.API(
|
||||
debug=os.getenv("DEBUG", "false").lower() == "true",
|
||||
secret_key=os.environ["SECRET_KEY"],
|
||||
cors=os.getenv("CORS_ENABLED", "false").lower() == "true",
|
||||
)
|
||||
|
||||
Some variables Responder handles automatically:
|
||||
|
||||
- ``PORT`` — when set, the server binds to ``0.0.0.0`` on this port
|
||||
|
||||
Set variables in your shell::
|
||||
|
||||
$ export SECRET_KEY="your-secret-here"
|
||||
$ export DEBUG=true
|
||||
$ python app.py
|
||||
|
||||
Or in a ``.env`` file (don't commit this to git)::
|
||||
|
||||
SECRET_KEY=your-secret-here
|
||||
DEBUG=true
|
||||
|
||||
|
||||
Using .env Files
|
||||
----------------
|
||||
|
||||
For local development, a ``.env`` file is convenient. Install
|
||||
``python-dotenv`` and load it at the top of your app::
|
||||
|
||||
$ uv pip install python-dotenv
|
||||
|
||||
::
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
import os
|
||||
import responder
|
||||
|
||||
api = responder.API(
|
||||
secret_key=os.environ["SECRET_KEY"],
|
||||
)
|
||||
|
||||
Add ``.env`` to your ``.gitignore`` — never commit secrets.
|
||||
|
||||
|
||||
Configuration Class Pattern
|
||||
----------------------------
|
||||
|
||||
For larger applications, a configuration class keeps things organized::
|
||||
|
||||
import os
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret")
|
||||
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///dev.db")
|
||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",")
|
||||
|
||||
config = Config()
|
||||
|
||||
api = responder.API(
|
||||
debug=config.DEBUG,
|
||||
secret_key=config.SECRET_KEY,
|
||||
cors=bool(config.CORS_ORIGINS[0]),
|
||||
cors_params={"allow_origins": config.CORS_ORIGINS},
|
||||
)
|
||||
|
||||
This makes it easy to see all your settings in one place.
|
||||
|
||||
|
||||
Secret Key
|
||||
----------
|
||||
|
||||
The ``secret_key`` is used to sign session cookies. If someone knows your
|
||||
secret key, they can forge session data and impersonate any user.
|
||||
|
||||
Rules:
|
||||
|
||||
- **Never use the default** in production
|
||||
- **Generate a random key**: ``python -c "import secrets; print(secrets.token_hex(32))"``
|
||||
- **Store it in an environment variable**, not in code
|
||||
- **Rotate it** if it's ever compromised (this invalidates all sessions)
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(secret_key=os.environ["SECRET_KEY"])
|
||||
|
||||
|
||||
Debug Mode
|
||||
----------
|
||||
|
||||
Debug mode controls error page behavior:
|
||||
|
||||
- **On** (``debug=True``): detailed error pages with tracebacks. Never
|
||||
use this in production — it exposes your source code.
|
||||
- **Off** (``debug=False``): generic error pages. This is the default.
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(debug=True) # development only
|
||||
|
||||
A common pattern is to read it from the environment::
|
||||
|
||||
api = responder.API(debug=os.getenv("DEBUG") == "true")
|
||||
|
||||
|
||||
Allowed Hosts
|
||||
-------------
|
||||
|
||||
In production, always set ``allowed_hosts`` to prevent Host header
|
||||
attacks. This should match the domain names your application serves::
|
||||
|
||||
api = responder.API(
|
||||
allowed_hosts=["example.com", "www.example.com"],
|
||||
)
|
||||
|
||||
In development, you can use ``["*"]`` (the default) or specific local
|
||||
addresses::
|
||||
|
||||
api = responder.API(allowed_hosts=["localhost", "127.0.0.1"])
|
||||
|
||||
|
||||
Putting It All Together
|
||||
-----------------------
|
||||
|
||||
A production-ready configuration setup::
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API(
|
||||
debug=os.getenv("DEBUG", "false") == "true",
|
||||
secret_key=os.environ["SECRET_KEY"],
|
||||
allowed_hosts=os.getenv("ALLOWED_HOSTS", "*").split(","),
|
||||
cors=bool(os.getenv("CORS_ORIGINS")),
|
||||
cors_params={
|
||||
"allow_origins": os.getenv("CORS_ORIGINS", "").split(","),
|
||||
"allow_methods": ["GET", "POST", "PUT", "DELETE"],
|
||||
},
|
||||
)
|
||||
|
||||
With a ``.env`` file for local development::
|
||||
|
||||
SECRET_KEY=dev-secret-do-not-use-in-prod
|
||||
DEBUG=true
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
And environment variables set properly in production (via your cloud
|
||||
platform's dashboard, Docker secrets, or a secrets manager).
|
||||
+39
-20
@@ -16,36 +16,42 @@ A familiar HTTP Service Framework for Python.
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
|
||||
Powered by `Starlette`_ and `uvicorn`_. The ``async`` is optional.
|
||||
Powered by `Starlette`_, `uvicorn`_, and good intentions. The ``async`` is optional.
|
||||
|
||||
|
||||
The Idea
|
||||
--------
|
||||
|
||||
Responder takes the best ideas from `Flask`_ and `Falcon`_ and brings them
|
||||
together into one clean framework.
|
||||
If you've ever used `Flask`_, the routing will look familiar. If you've
|
||||
used `Falcon`_, the request/response pattern will click immediately. And
|
||||
if you've used `Requests`_ — well, you'll feel right at home.
|
||||
|
||||
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.
|
||||
Responder takes these ideas and brings them together. Every view receives
|
||||
a request and a response. You read from one and write to the other. No
|
||||
return values, no special response classes, no boilerplate.
|
||||
|
||||
- ``resp.text`` sends back text. ``resp.html`` sends back HTML.
|
||||
- ``resp.media`` sends back JSON — or YAML, if the client asks for it.
|
||||
- ``resp.text`` sends text. ``resp.html`` sends HTML. ``resp.media`` sends JSON.
|
||||
- ``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.
|
||||
- ``resp.status_code``, ``req.method``, ``req.url`` — the familiar ones.
|
||||
|
||||
Content negotiation happens automatically. Set ``resp.media`` to a dict
|
||||
and Responder figures out the rest.
|
||||
Set ``resp.media`` to a dict and the right thing happens. If the client
|
||||
asks for YAML, it gets YAML. Content negotiation is automatic.
|
||||
|
||||
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.
|
||||
Responder and `FastAPI`_ are siblings — both built on Starlette, both
|
||||
born around the same time, both part of the push that made ASGI the
|
||||
future of Python web services. FastAPI went deep on type annotations
|
||||
and automatic validation. Responder went for simplicity and a mutable
|
||||
request/response pattern. Both projects are better for the other
|
||||
existing. Use whichever feels right.
|
||||
|
||||
This is a passion project. It exists because building a web framework
|
||||
from scratch is one of the best ways to understand how the web works.
|
||||
It's a great fit for personal projects, prototyping, teaching, research,
|
||||
and anyone who values a clean API over a sprawling ecosystem. If you
|
||||
need battle-tested infrastructure at scale, FastAPI and Django will
|
||||
serve you well. If you want something small, expressive, and fun to
|
||||
work with — welcome.
|
||||
|
||||
|
||||
What You Get
|
||||
@@ -80,7 +86,7 @@ Installation
|
||||
|
||||
$ uv pip install responder
|
||||
|
||||
Python 3.9 and above. That's it.
|
||||
Python 3.10 and above. That's it.
|
||||
|
||||
|
||||
.. toctree::
|
||||
@@ -94,6 +100,18 @@ Python 3.9 and above. That's it.
|
||||
api
|
||||
cli
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Tutorials
|
||||
|
||||
tutorial-rest
|
||||
tutorial-sqlalchemy
|
||||
tutorial-auth
|
||||
tutorial-websockets
|
||||
tutorial-middleware
|
||||
tutorial-flask
|
||||
guide-config
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Project
|
||||
@@ -109,3 +127,4 @@ Python 3.9 and above. That's it.
|
||||
.. _Falcon: https://falconframework.org/
|
||||
.. _FastAPI: https://fastapi.tiangolo.com/
|
||||
.. _GraphQL: https://graphql.org/
|
||||
.. _Requests: https://requests.readthedocs.io/
|
||||
|
||||
+201
-57
@@ -2,164 +2,229 @@ Quick Start
|
||||
===========
|
||||
|
||||
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.
|
||||
Responder. By the end, you'll understand how HTTP requests and responses
|
||||
work, how to define routes, read data from clients, send data back, render
|
||||
HTML templates, and process work in the background.
|
||||
|
||||
|
||||
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::
|
||||
Every web application starts with a single object — the application
|
||||
instance. In Responder, this is the ``API`` class. It holds your routes,
|
||||
middleware, templates, and configuration. Think of it as the central
|
||||
nervous system of your web service::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
That's it. One import, one line. You now have a fully functional ASGI
|
||||
application with gzip compression, static file serving, session support,
|
||||
and a production-ready server — all wired up and ready to go.
|
||||
|
||||
|
||||
Hello World
|
||||
-----------
|
||||
|
||||
Next, add a route. Here, we'll make the root URL say "hello, world!"::
|
||||
A web service isn't very useful until it can respond to requests. In HTTP,
|
||||
a *route* maps a URL path to a function that handles it. When a client
|
||||
(like a browser or ``curl``) sends a request to that path, your function
|
||||
runs and produces a response.
|
||||
|
||||
Here's the simplest possible route::
|
||||
|
||||
@api.route("/")
|
||||
def hello_world(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
Every view receives a ``req`` (request) and ``resp`` (response) object. You
|
||||
don't need to return anything — just mutate the response directly.
|
||||
Two things to notice:
|
||||
|
||||
1. Every view function receives two arguments: ``req`` (the incoming
|
||||
request) and ``resp`` (the outgoing response).
|
||||
2. You don't return anything. Instead, you *mutate* the response object
|
||||
directly. This is a deliberate design choice — it keeps the API
|
||||
consistent whether you're setting text, JSON, headers, cookies, or
|
||||
status codes.
|
||||
|
||||
|
||||
Run the Server
|
||||
--------------
|
||||
|
||||
Start your web service with ``api.run()``::
|
||||
Start your web service with a single call::
|
||||
|
||||
api.run()
|
||||
|
||||
This spins up a production-grade uvicorn server on port ``5042``, ready for
|
||||
incoming HTTP requests.
|
||||
This spins up a production-grade `uvicorn <https://www.uvicorn.org/>`_
|
||||
server on port ``5042``, ready for incoming HTTP requests. Open
|
||||
``http://localhost:5042`` in your browser and you'll see your hello world
|
||||
response.
|
||||
|
||||
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.
|
||||
binds to ``0.0.0.0`` on that port, which is what cloud platforms expect.
|
||||
|
||||
.. note::
|
||||
|
||||
Both sync and async views are supported. The ``async`` keyword is always
|
||||
optional — use it when you need to ``await`` something.
|
||||
optional — use it when you need to ``await`` something, like reading a
|
||||
request body or querying a database.
|
||||
|
||||
|
||||
Route Parameters
|
||||
----------------
|
||||
|
||||
If you want dynamic URLs, use Python's familiar f-string syntax to declare
|
||||
variables in your routes::
|
||||
Static URLs like ``/about`` are useful, but most applications need dynamic
|
||||
routes — URLs that contain variable data, like a user ID or a product slug.
|
||||
|
||||
In Responder, you declare route parameters using Python's f-string syntax::
|
||||
|
||||
@api.route("/hello/{who}")
|
||||
def hello_to(req, resp, *, who):
|
||||
resp.text = f"hello, {who}!"
|
||||
|
||||
A ``GET`` request to ``/hello/world`` will respond with ``hello, world!``.
|
||||
A request to ``/hello/guido`` will respond with ``hello, guido!``.
|
||||
|
||||
Route parameters are passed as keyword-only arguments (after the ``*``).
|
||||
Route parameters are passed as *keyword-only* arguments (after the ``*``
|
||||
in the function signature). This is a Python feature that makes the
|
||||
interface explicit — you always know which arguments come from the URL.
|
||||
|
||||
|
||||
Type Convertors
|
||||
^^^^^^^^^^^^^^^
|
||||
|
||||
You can constrain route parameters to specific types. The parameter will be
|
||||
automatically converted before it reaches your view::
|
||||
By default, route parameters are strings. But often you want them as
|
||||
integers, UUIDs, or other types. Responder can convert them automatically
|
||||
using type annotations in the route pattern::
|
||||
|
||||
@api.route("/add/{a:int}/{b:int}")
|
||||
async def add(req, resp, *, a, b):
|
||||
resp.text = f"{a} + {b} = {a + b}"
|
||||
|
||||
Here, ``a`` and ``b`` will arrive as Python ``int`` objects, not strings.
|
||||
If someone requests ``/add/3/hello``, they'll get a 404 — the route won't
|
||||
match because ``hello`` isn't a valid integer.
|
||||
|
||||
Supported types:
|
||||
|
||||
- ``str`` — matches any string without slashes (default)
|
||||
- ``int`` — matches digits, converts to ``int``
|
||||
- ``float`` — matches decimal numbers, converts to ``float``
|
||||
- ``str`` — matches any string without slashes (this is the default)
|
||||
- ``int`` — matches digits and converts to ``int``
|
||||
- ``float`` — matches decimal numbers and converts to ``float``
|
||||
- ``uuid`` — matches UUID strings like ``550e8400-e29b-41d4-a716-446655440000``
|
||||
- ``path`` — matches any string *including* slashes, useful for file paths
|
||||
like ``/files/{filepath:path}``
|
||||
|
||||
|
||||
Sending Responses
|
||||
-----------------
|
||||
|
||||
Responder gives you several ways to send data back to the client. Just set
|
||||
the appropriate property on the response object.
|
||||
When an HTTP server receives a request, it must send back a response. Every
|
||||
HTTP response has three parts: a status code (like ``200 OK`` or ``404 Not
|
||||
Found``), headers (metadata like ``Content-Type``), and a body (the actual
|
||||
data).
|
||||
|
||||
**Text and HTML**::
|
||||
Responder lets you set all three by mutating the response object.
|
||||
|
||||
**Text and HTML** — the simplest response types. ``resp.text`` sets the
|
||||
``Content-Type`` to ``text/plain``, while ``resp.html`` sets it to
|
||||
``text/html``::
|
||||
|
||||
resp.text = "plain text response"
|
||||
resp.html = "<h1>HTML response</h1>"
|
||||
|
||||
**JSON** — the most common pattern for APIs. Set ``resp.media`` to any
|
||||
JSON-serializable Python object::
|
||||
**JSON** — the lingua franca of web APIs. Set ``resp.media`` to any
|
||||
JSON-serializable Python object — a dict, a list, whatever — and Responder
|
||||
will serialize it to JSON and set the right headers::
|
||||
|
||||
@api.route("/hello/{who}/json")
|
||||
def hello_json(req, resp, *, who):
|
||||
resp.media = {"hello": who}
|
||||
|
||||
If the client sends an ``Accept: application/x-yaml`` header, the same data
|
||||
will be returned as YAML instead. Content negotiation is automatic.
|
||||
will be returned as YAML instead. This is called *content negotiation* —
|
||||
the server and client agree on a format. It happens automatically.
|
||||
|
||||
**Files** — serve a file from disk with automatic content-type detection::
|
||||
**Files** — serve a file from disk. Responder uses Python's ``mimetypes``
|
||||
module to figure out the ``Content-Type`` from the file extension::
|
||||
|
||||
resp.file("reports/annual.pdf")
|
||||
|
||||
**Raw bytes**::
|
||||
**Raw bytes** — for binary data like images or protocol buffers::
|
||||
|
||||
resp.content = b"\x89PNG\r\n..."
|
||||
|
||||
**Status codes and headers**::
|
||||
**Status codes** — HTTP status codes tell the client what happened. ``200``
|
||||
means success, ``201`` means something was created, ``404`` means not found,
|
||||
``500`` means the server broke. Set it directly::
|
||||
|
||||
resp.status_code = 201
|
||||
|
||||
**Headers** — HTTP headers carry metadata. Common ones include
|
||||
``Content-Type``, ``Cache-Control``, ``Authorization``, and custom
|
||||
application headers::
|
||||
|
||||
resp.headers["X-Custom"] = "value"
|
||||
|
||||
**Redirects**::
|
||||
**Redirects** — tell the client to go somewhere else::
|
||||
|
||||
api.redirect(resp, location="/new-url")
|
||||
|
||||
This sends a ``301 Moved Permanently`` response by default. The client's
|
||||
browser will automatically follow the redirect.
|
||||
|
||||
|
||||
Reading Requests
|
||||
----------------
|
||||
|
||||
The request object gives you access to everything the client sent.
|
||||
The other half of HTTP is the request — the data the client sends to your
|
||||
server. This includes the HTTP method (GET, POST, PUT, DELETE), the URL,
|
||||
headers, query parameters, cookies, and optionally a body.
|
||||
|
||||
**Method and URL**::
|
||||
Responder wraps all of this in the ``req`` object.
|
||||
|
||||
**Method and URL** — every HTTP request has a method (what the client wants
|
||||
to do) and a URL (what resource it's about)::
|
||||
|
||||
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** — HTTP headers carry metadata from the client, like what
|
||||
content types it accepts, authentication tokens, and more. Responder's
|
||||
headers dict is case-insensitive, because the HTTP spec says header names
|
||||
are case-insensitive::
|
||||
|
||||
req.headers["Content-Type"]
|
||||
req.headers["content-type"] # same thing
|
||||
|
||||
**Query parameters**::
|
||||
**Query parameters** — the part of the URL after the ``?``. These are
|
||||
commonly used for search, filtering, and pagination::
|
||||
|
||||
# GET /search?q=python&page=2
|
||||
req.params["q"] # "python"
|
||||
req.params["page"] # "2"
|
||||
|
||||
**Path parameters** — also available on the request object::
|
||||
Note that query parameters are always strings. If you need an integer,
|
||||
you'll need to convert it yourself: ``int(req.params["page"])``.
|
||||
|
||||
**Path parameters** — the dynamic parts of the URL that matched your route
|
||||
pattern. These are also available on the request object, which is useful
|
||||
in before-request hooks where they aren't passed as function arguments::
|
||||
|
||||
req.path_params["user_id"] # same as the keyword argument
|
||||
|
||||
**Request body** — for POST/PUT/PATCH requests, you need to ``await`` the
|
||||
body content::
|
||||
**Request body** — for POST, PUT, and PATCH requests, the client sends
|
||||
data in the body. Since reading the body is an I/O operation, you need to
|
||||
``await`` it::
|
||||
|
||||
# JSON body
|
||||
# JSON body (the most common format for APIs)
|
||||
data = await req.media()
|
||||
|
||||
# Form data
|
||||
# Form data (from HTML forms)
|
||||
data = await req.media("form")
|
||||
|
||||
# File uploads
|
||||
# File uploads (multipart)
|
||||
files = await req.media("files")
|
||||
|
||||
# Raw bytes
|
||||
@@ -170,41 +235,59 @@ body content::
|
||||
|
||||
**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
|
||||
req.is_json # True if the content type is JSON
|
||||
req.cookies # dict of cookies sent by the client
|
||||
req.session # session data (a signed, server-side dict)
|
||||
req.client # (host, port) tuple — the client's IP address
|
||||
req.is_secure # True if the request came over HTTPS
|
||||
|
||||
|
||||
Rendering Templates
|
||||
-------------------
|
||||
|
||||
Responder includes built-in `Jinja2 <https://jinja.palletsprojects.com/>`_
|
||||
support. Templates are loaded from the ``templates/`` directory by default.
|
||||
While APIs typically return JSON, many web applications need to render
|
||||
HTML pages. Responder includes built-in support for
|
||||
`Jinja2 <https://jinja.palletsprojects.com/>`_, one of the most popular
|
||||
templating engines in the Python ecosystem.
|
||||
|
||||
The simplest way is to use ``api.template()``::
|
||||
Templates let you write HTML with placeholders that get filled in with
|
||||
dynamic data. This keeps your presentation logic (HTML) separate from
|
||||
your application logic (Python) — a pattern called
|
||||
*separation of concerns*.
|
||||
|
||||
The simplest way to render a template is ``api.template()``. Templates
|
||||
are loaded from the ``templates/`` directory by default::
|
||||
|
||||
@api.route("/hello/{name}/html")
|
||||
def hello_html(req, resp, *, name):
|
||||
resp.html = api.template("hello.html", name=name)
|
||||
|
||||
You can also use the ``Templates`` class directly for more control::
|
||||
The template file ``templates/hello.html`` might look like::
|
||||
|
||||
<h1>Hello, {{ name }}!</h1>
|
||||
|
||||
The ``{{ name }}`` part is a Jinja2 expression — it gets replaced with
|
||||
the value you passed in.
|
||||
|
||||
You can also use the ``Templates`` class directly for more control over
|
||||
the template directory and configuration::
|
||||
|
||||
from responder.templates import Templates
|
||||
|
||||
templates = Templates(directory="templates")
|
||||
templates = Templates(directory="my_templates")
|
||||
|
||||
@api.route("/page")
|
||||
def page(req, resp):
|
||||
resp.html = templates.render("page.html", title="Hello")
|
||||
|
||||
Async rendering is supported too::
|
||||
For applications that need non-blocking template rendering (rare, but
|
||||
useful under extreme load), async rendering is supported::
|
||||
|
||||
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::
|
||||
And for quick one-off templates, you can render a string directly without
|
||||
a file::
|
||||
|
||||
resp.html = api.template_string("Hello, {{ name }}!", name="world")
|
||||
|
||||
@@ -213,7 +296,13 @@ Background Tasks
|
||||
----------------
|
||||
|
||||
Sometimes you want to accept a request, respond immediately, and do the
|
||||
actual processing later. Responder makes this easy with background tasks::
|
||||
actual processing later. This is a common pattern for operations that take
|
||||
a long time — sending emails, processing images, updating caches, or
|
||||
calling slow external APIs.
|
||||
|
||||
Responder makes this easy with background tasks. Decorate any function
|
||||
with ``@api.background.task`` and it will run in a thread pool, separate
|
||||
from the request/response cycle::
|
||||
|
||||
@api.route("/incoming")
|
||||
async def receive_incoming(req, resp):
|
||||
@@ -227,8 +316,63 @@ actual processing later. Responder makes this easy with background tasks::
|
||||
|
||||
process_data(data)
|
||||
|
||||
# Respond immediately — processing continues in the background
|
||||
# This response is sent immediately, while process_data
|
||||
# continues running 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.
|
||||
The client gets an instant response — the heavy lifting happens after.
|
||||
This is the same pattern used by task queues like Celery, but much simpler
|
||||
for lightweight use cases where you don't need a full message broker.
|
||||
|
||||
.. note::
|
||||
|
||||
Background tasks run in threads, not processes. They share memory with
|
||||
your application, which makes them fast to start but means CPU-intensive
|
||||
work will block the event loop. For heavy computation, consider a proper
|
||||
task queue.
|
||||
|
||||
|
||||
Putting It All Together
|
||||
-----------------------
|
||||
|
||||
Here's a complete, working Responder application that combines everything
|
||||
from this guide::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/")
|
||||
def index(req, resp):
|
||||
resp.text = "Welcome to the API"
|
||||
|
||||
@api.route("/hello/{name}")
|
||||
def greet(req, resp, *, name):
|
||||
resp.media = {"message": f"hello, {name}!"}
|
||||
|
||||
@api.route("/add/{a:int}/{b:int}")
|
||||
def add(req, resp, *, a, b):
|
||||
resp.media = {"result": a + b}
|
||||
|
||||
@api.route("/echo", methods=["POST"])
|
||||
async def echo(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"received": data}
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Save this as ``app.py``, run it with ``python app.py``, and try::
|
||||
|
||||
$ curl http://localhost:5042/
|
||||
$ curl http://localhost:5042/hello/world
|
||||
$ curl http://localhost:5042/add/3/4
|
||||
$ curl -X POST http://localhost:5042/echo \
|
||||
-H "Content-Type: application/json" -d '{"key": "value"}'
|
||||
|
||||
From here, explore the :doc:`tour` for the full range of features, or
|
||||
jump into the tutorials:
|
||||
|
||||
- :doc:`tutorial-rest` — build a full CRUD API with validation
|
||||
- :doc:`tutorial-sqlalchemy` — connect to a database
|
||||
- :doc:`tutorial-auth` — add authentication
|
||||
|
||||
+143
-14
@@ -3,7 +3,9 @@ Testing
|
||||
|
||||
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.
|
||||
making them fast and reliable. There's no separate test server to manage,
|
||||
no ports to allocate, and no race conditions to worry about. Just import
|
||||
your app and start making requests.
|
||||
|
||||
|
||||
Getting Started
|
||||
@@ -22,7 +24,9 @@ Given a simple application in ``api.py``::
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
You can test it with pytest::
|
||||
You can test it with pytest. Every Responder ``API`` instance has a
|
||||
``requests`` property that gives you a test client — use it exactly like
|
||||
you'd use ``requests`` or ``httpx``::
|
||||
|
||||
# test_api.py
|
||||
import api as service
|
||||
@@ -35,12 +39,15 @@ Run your tests::
|
||||
|
||||
$ pytest
|
||||
|
||||
That's really all there is to it. No configuration, no test server setup.
|
||||
|
||||
|
||||
Using Fixtures
|
||||
--------------
|
||||
|
||||
For larger test suites, use pytest fixtures to share the API instance
|
||||
across tests::
|
||||
For larger test suites, pytest fixtures keep things organized. Create a
|
||||
fixture that returns your API instance, and every test gets a fresh
|
||||
reference to it::
|
||||
|
||||
import pytest
|
||||
import api as service
|
||||
@@ -62,13 +69,16 @@ across tests::
|
||||
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.
|
||||
so you don't have to hard-code paths in your tests. If you rename a route
|
||||
later, your tests won't break.
|
||||
|
||||
|
||||
Testing JSON APIs
|
||||
-----------------
|
||||
|
||||
Send JSON data and check the response::
|
||||
Most APIs send and receive JSON. The test client makes this natural — pass
|
||||
``json=`` to send a JSON body, and call ``.json()`` on the response to
|
||||
parse it::
|
||||
|
||||
def test_create_item(api):
|
||||
@api.route("/items")
|
||||
@@ -81,11 +91,45 @@ Send JSON data and check the response::
|
||||
assert r.status_code == 201
|
||||
assert r.json() == {"created": {"name": "widget"}}
|
||||
|
||||
You can also test content negotiation by setting the ``Accept`` header::
|
||||
|
||||
r = api.requests.get("/data", headers={"Accept": "application/x-yaml"})
|
||||
assert "key: value" in r.text
|
||||
|
||||
|
||||
Testing Request Validation
|
||||
--------------------------
|
||||
|
||||
If you're using Pydantic models for request validation, you can test
|
||||
that invalid inputs are properly rejected::
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
def test_validation(api):
|
||||
@api.route("/items", methods=["POST"], request_model=Item)
|
||||
async def create(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = data
|
||||
|
||||
# Valid request
|
||||
r = api.requests.post("/items", json={"name": "thing", "price": 9.99})
|
||||
assert r.status_code == 200
|
||||
|
||||
# Missing required field
|
||||
r = api.requests.post("/items", json={"name": "thing"})
|
||||
assert r.status_code == 422
|
||||
assert "errors" in r.json()
|
||||
|
||||
|
||||
Testing File Uploads
|
||||
--------------------
|
||||
|
||||
Send files using the ``files`` parameter::
|
||||
File uploads use the ``files`` parameter, just like the ``requests``
|
||||
library. Each file is a tuple of ``(filename, content, content_type)``::
|
||||
|
||||
def test_upload(api):
|
||||
@api.route("/upload")
|
||||
@@ -98,10 +142,29 @@ Send files using the ``files`` parameter::
|
||||
assert r.json() == {"received": ["doc"]}
|
||||
|
||||
|
||||
Testing Headers and Cookies
|
||||
----------------------------
|
||||
|
||||
Check response headers and cookies just like you would with any HTTP
|
||||
client::
|
||||
|
||||
def test_headers(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.headers["X-Custom"] = "hello"
|
||||
resp.cookies["session"] = "abc123"
|
||||
|
||||
r = api.requests.get("/")
|
||||
assert r.headers["X-Custom"] == "hello"
|
||||
assert "session" in r.cookies
|
||||
|
||||
|
||||
Testing WebSockets
|
||||
------------------
|
||||
|
||||
Use Starlette's ``TestClient`` directly for WebSocket connections::
|
||||
WebSocket tests use Starlette's ``TestClient`` directly, since WebSocket
|
||||
connections require a different protocol. The ``websocket_connect`` context
|
||||
manager gives you a connection you can send and receive on::
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
@@ -109,19 +172,23 @@ Use Starlette's ``TestClient`` directly for WebSocket connections::
|
||||
@api.route("/ws", websocket=True)
|
||||
async def ws(ws):
|
||||
await ws.accept()
|
||||
await ws.send_text("hello")
|
||||
name = await ws.receive_text()
|
||||
await ws.send_text(f"hello, {name}!")
|
||||
await ws.close()
|
||||
|
||||
client = TestClient(api)
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
assert ws.receive_text() == "hello"
|
||||
ws.send_text("world")
|
||||
assert ws.receive_text() == "hello, world!"
|
||||
|
||||
|
||||
Testing Error Handling
|
||||
----------------------
|
||||
|
||||
To test error responses without pytest raising the exception, disable
|
||||
server exception propagation::
|
||||
By default, the test client raises exceptions from your route handlers,
|
||||
which is usually what you want — it makes bugs obvious. But when you're
|
||||
testing error handling specifically, you want to see the error response
|
||||
instead. Disable exception propagation with ``raise_server_exceptions``::
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
@@ -134,12 +201,30 @@ server exception propagation::
|
||||
r = client.get(api.url_for(fail))
|
||||
assert r.status_code == 500
|
||||
|
||||
If you've registered a custom exception handler, you can test that too::
|
||||
|
||||
def test_custom_error(api):
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
@api.route("/fail")
|
||||
def fail(req, resp):
|
||||
raise ValueError("bad input")
|
||||
|
||||
client = TestClient(api, raise_server_exceptions=False)
|
||||
r = client.get(api.url_for(fail))
|
||||
assert r.status_code == 400
|
||||
assert r.json() == {"error": "bad input"}
|
||||
|
||||
|
||||
Testing Lifespan Events
|
||||
-----------------------
|
||||
|
||||
The test client supports lifespan events. Use ``with`` to ensure startup
|
||||
and shutdown hooks run::
|
||||
If your app uses startup and shutdown events (for database connections,
|
||||
caches, etc.), you need the test client to trigger them. Wrap the client
|
||||
in a ``with`` block — startup runs on enter, shutdown runs on exit::
|
||||
|
||||
def test_with_lifespan(api):
|
||||
started = {"value": False}
|
||||
@@ -155,3 +240,47 @@ and shutdown hooks run::
|
||||
with api.requests as session:
|
||||
r = session.get("http://;/")
|
||||
assert r.json() == {"started": True}
|
||||
|
||||
Without the ``with`` block, lifespan events won't fire, which can lead to
|
||||
confusing test failures if your routes depend on startup initialization.
|
||||
|
||||
|
||||
Testing Before and After Hooks
|
||||
------------------------------
|
||||
|
||||
Before-request and after-request hooks run automatically during tests,
|
||||
just like in production. You can verify their effects on the response::
|
||||
|
||||
def test_hooks(api):
|
||||
@api.route(before_request=True)
|
||||
def add_version(req, resp):
|
||||
resp.headers["X-Version"] = "3.2"
|
||||
|
||||
@api.after_request()
|
||||
def add_timing(req, resp):
|
||||
resp.headers["X-Served-By"] = "responder"
|
||||
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.text = "ok"
|
||||
|
||||
r = api.requests.get("/")
|
||||
assert r.headers["X-Version"] == "3.2"
|
||||
assert r.headers["X-Served-By"] == "responder"
|
||||
|
||||
|
||||
Tips
|
||||
----
|
||||
|
||||
- **Keep tests fast.** The in-process test client is already fast — no
|
||||
network overhead. Avoid ``time.sleep()`` in tests.
|
||||
|
||||
- **One API per test** when testing configuration. If you need a specific
|
||||
``API()`` configuration (like ``cors=True``), create a new instance
|
||||
in the test rather than sharing the fixture.
|
||||
|
||||
- Use ``api.url_for()`` instead of hard-coded paths. It's a small
|
||||
thing, but it makes refactoring painless.
|
||||
|
||||
- **Test the contract, not the implementation.** Assert on status codes,
|
||||
response bodies, and headers — not on internal state.
|
||||
|
||||
+265
-176
@@ -1,15 +1,27 @@
|
||||
Feature Tour
|
||||
============
|
||||
|
||||
This section walks through Responder's features in detail. Each section
|
||||
includes working code examples you can copy into your application.
|
||||
This section walks through Responder's features in depth. Each section
|
||||
explains the concept, shows working code, and explains the design choices
|
||||
behind it. If you're new to web development, this is a good place to learn
|
||||
how modern web frameworks work under the hood.
|
||||
|
||||
|
||||
Method Filtering
|
||||
----------------
|
||||
|
||||
By default, a route matches all HTTP methods. If you want to restrict a
|
||||
route to specific methods, pass the ``methods`` parameter::
|
||||
HTTP defines several *methods* (also called verbs) that describe what a
|
||||
client wants to do with a resource. The most common are:
|
||||
|
||||
- ``GET`` — retrieve data
|
||||
- ``POST`` — create something new
|
||||
- ``PUT`` — replace something entirely
|
||||
- ``PATCH`` — update part of something
|
||||
- ``DELETE`` — remove something
|
||||
|
||||
By default, a Responder route matches all methods. This is fine for simple
|
||||
endpoints, but REST APIs typically map different methods to different
|
||||
operations. Use the ``methods`` parameter to restrict a route::
|
||||
|
||||
@api.route("/items", methods=["GET"])
|
||||
def list_items(req, resp):
|
||||
@@ -20,15 +32,22 @@ route to specific methods, pass the ``methods`` parameter::
|
||||
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.
|
||||
Note the ``check_existing=False`` — Responder normally prevents you from
|
||||
registering two routes with the same path (to catch typos). When you
|
||||
intentionally want multiple handlers for the same path with different
|
||||
methods, you need to opt in.
|
||||
|
||||
|
||||
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::
|
||||
Function-based views are great for simple endpoints, but sometimes you want
|
||||
to group related HTTP methods together into a single resource. This is
|
||||
where class-based views come in — a pattern popularized by
|
||||
`Falcon <https://falconframework.org/>`_.
|
||||
|
||||
Responder dispatches to the appropriate method handler based on the HTTP
|
||||
method::
|
||||
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
@@ -47,14 +66,19 @@ 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.
|
||||
This is simpler than Django's ``View`` classes and more Pythonic than
|
||||
framework-specific base classes.
|
||||
|
||||
|
||||
Lifespan Events
|
||||
---------------
|
||||
|
||||
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::
|
||||
Real applications need to set up resources when they start (database
|
||||
connection pools, ML models, caches) and tear them down when they stop.
|
||||
This is called the application *lifespan*.
|
||||
|
||||
The modern approach is the *context manager* pattern, where startup and
|
||||
shutdown are two halves of the same block::
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -68,7 +92,11 @@ supports the lifespan context manager pattern::
|
||||
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
You can also use the traditional event decorator style::
|
||||
Everything before ``yield`` runs at startup. Everything after runs at
|
||||
shutdown. If startup fails, the server won't start. If shutdown raises,
|
||||
it's logged but the server still exits.
|
||||
|
||||
The traditional event decorator style also works::
|
||||
|
||||
@api.on_event("startup")
|
||||
async def startup():
|
||||
@@ -78,57 +106,71 @@ You can also use the traditional event decorator style::
|
||||
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.
|
||||
The context manager is preferred for new code — it keeps related startup
|
||||
and shutdown logic together and makes resource cleanup more explicit.
|
||||
|
||||
|
||||
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::
|
||||
Web applications often need to serve files — downloads, reports, images.
|
||||
Responder makes this simple with ``resp.file()``, which reads a file from
|
||||
disk and sets the ``Content-Type`` header automatically using Python's
|
||||
``mimetypes`` module::
|
||||
|
||||
@api.route("/download")
|
||||
def download(req, resp):
|
||||
resp.file("reports/annual.pdf")
|
||||
|
||||
You can override the content type if needed::
|
||||
You can override the content type if the automatic detection isn't right::
|
||||
|
||||
@api.route("/image")
|
||||
def image(req, resp):
|
||||
resp.file("photos/cat.jpg", content_type="image/jpeg")
|
||||
|
||||
For large files, use ``resp.stream_file()`` to avoid loading the entire
|
||||
file into memory. This streams the file in chunks::
|
||||
|
||||
@api.route("/export")
|
||||
def export(req, resp):
|
||||
resp.stream_file("data/export.csv")
|
||||
|
||||
|
||||
Custom Error Handling
|
||||
---------------------
|
||||
|
||||
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::
|
||||
In production, you don't want your users to see raw Python tracebacks.
|
||||
Responder lets you register custom handlers for specific exception types,
|
||||
so you can return clean, 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.
|
||||
Now, any route that raises a ``ValueError`` will return a clean JSON
|
||||
response with a 400 status code instead of a generic 500 error page.
|
||||
|
||||
This is a common pattern in API development — you define your own exception
|
||||
classes for different error conditions, register handlers for each, and
|
||||
your API always returns consistent, machine-readable error responses.
|
||||
|
||||
|
||||
Before-Request Hooks
|
||||
--------------------
|
||||
|
||||
Run code before every request. This is useful for logging, adding common
|
||||
headers, or setting up per-request state::
|
||||
Sometimes you need to run the same code before every request —
|
||||
authentication checks, request logging, adding common headers, or setting
|
||||
up per-request state. Before-request hooks let you do this without
|
||||
duplicating code in every route::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def add_headers(req, resp):
|
||||
resp.headers["X-API-Version"] = "3.1"
|
||||
resp.headers["X-API-Version"] = "3.2"
|
||||
|
||||
**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::
|
||||
**Short-circuiting** is the really powerful part. If your hook sets
|
||||
``resp.status_code``, the route handler is skipped entirely and the
|
||||
response is sent immediately. This is the pattern for authentication::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def auth_check(req, resp):
|
||||
@@ -137,19 +179,36 @@ This is the pattern for authentication guards::
|
||||
resp.media = {"error": "unauthorized"}
|
||||
|
||||
If the ``Authorization`` header is missing, the client gets a 401 response
|
||||
and the actual route handler never runs.
|
||||
and the actual route handler never runs. This is cleaner than adding
|
||||
auth checks to every individual route.
|
||||
|
||||
WebSocket hooks work the same way::
|
||||
|
||||
@api.before_request(websocket=True)
|
||||
async def ws_auth(ws):
|
||||
await ws.accept()
|
||||
After-Request Hooks
|
||||
-------------------
|
||||
|
||||
The complement to before-request hooks. After-request hooks run after the
|
||||
route handler completes but before the response is sent. They're useful
|
||||
for logging, adding response headers, or any post-processing::
|
||||
|
||||
@api.after_request()
|
||||
def log_response(req, resp):
|
||||
print(f"{req.method} {req.full_url} -> {resp.status_code}")
|
||||
|
||||
@api.after_request()
|
||||
async def add_timing(req, resp):
|
||||
resp.headers["X-Served-By"] = "responder"
|
||||
|
||||
|
||||
WebSocket Support
|
||||
-----------------
|
||||
|
||||
Responder supports WebSockets for real-time, bidirectional communication::
|
||||
HTTP is a request-response protocol — the client asks, the server answers.
|
||||
But some applications need real-time, bidirectional communication: chat
|
||||
apps, live dashboards, multiplayer games, collaborative editors.
|
||||
|
||||
`WebSockets <https://en.wikipedia.org/wiki/WebSocket>`_ solve this by
|
||||
upgrading an HTTP connection into a persistent, full-duplex channel where
|
||||
both sides can send messages at any time::
|
||||
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
@@ -161,14 +220,54 @@ Responder supports WebSockets for real-time, bidirectional communication::
|
||||
|
||||
You can send and receive in multiple formats:
|
||||
|
||||
- ``send_text`` / ``receive_text`` — plain text
|
||||
- ``send_json`` / ``receive_json`` — JSON objects
|
||||
- ``send_text`` / ``receive_text`` — plain text strings
|
||||
- ``send_json`` / ``receive_json`` — JSON objects (auto-serialized)
|
||||
- ``send_bytes`` / ``receive_bytes`` — raw binary data
|
||||
|
||||
WebSocket routes are marked with ``websocket=True`` in the route decorator.
|
||||
They receive a ``ws`` object instead of ``req`` and ``resp``.
|
||||
|
||||
|
||||
Server-Sent Events (SSE)
|
||||
-------------------------
|
||||
|
||||
SSE is a simpler alternative to WebSockets for *one-way* real-time
|
||||
communication — the server pushes events to the client, but the client
|
||||
can't send messages back. This is perfect for live feeds, progress bars,
|
||||
notification streams, and AI response streaming.
|
||||
|
||||
Unlike WebSockets, SSE works over plain HTTP, is automatically reconnected
|
||||
by the browser, and doesn't require any special client-side libraries::
|
||||
|
||||
@api.route("/events")
|
||||
async def events(req, resp):
|
||||
@resp.sse
|
||||
async def stream():
|
||||
for i in range(10):
|
||||
yield {"data": f"message {i}"}
|
||||
|
||||
On the client side, you consume SSE events with JavaScript's built-in
|
||||
``EventSource`` API::
|
||||
|
||||
const source = new EventSource("/events");
|
||||
source.onmessage = (event) => {
|
||||
console.log(event.data);
|
||||
};
|
||||
|
||||
Each yielded value can be a string (treated as data) or a dict with the
|
||||
standard SSE fields::
|
||||
|
||||
yield {"event": "update", "data": "hello", "id": "1", "retry": "5000"}
|
||||
yield "simple string message"
|
||||
|
||||
|
||||
GraphQL
|
||||
-------
|
||||
|
||||
`GraphQL <https://graphql.org/>`_ is a query language for APIs that lets
|
||||
clients request exactly the data they need — no more, no less. Instead of
|
||||
multiple REST endpoints, you define a schema and let clients query it.
|
||||
|
||||
Responder includes built-in GraphQL support via
|
||||
`Graphene <https://graphene-python.org/>`_. Set up a full GraphQL endpoint
|
||||
with a single method call::
|
||||
@@ -183,9 +282,10 @@ with a single method call::
|
||||
|
||||
api.graphql("/graphql", schema=graphene.Schema(query=Query))
|
||||
|
||||
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.
|
||||
Visiting ``/graphql`` in a browser renders the
|
||||
`GraphiQL <https://github.com/graphql/graphiql>`_ interactive IDE, where
|
||||
you can explore your schema, write queries, and see results in real-time.
|
||||
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"]``.
|
||||
@@ -194,8 +294,12 @@ through ``info.context["request"]`` and ``info.context["response"]``.
|
||||
OpenAPI Documentation
|
||||
---------------------
|
||||
|
||||
Responder can generate an OpenAPI schema and serve interactive API
|
||||
documentation automatically::
|
||||
`OpenAPI <https://www.openapis.org/>`_ (formerly Swagger) is the industry
|
||||
standard for describing REST APIs. An OpenAPI specification lets you
|
||||
auto-generate interactive documentation, client libraries, and validation
|
||||
logic.
|
||||
|
||||
Responder generates OpenAPI specs from your code::
|
||||
|
||||
api = responder.API(
|
||||
title="Pet Store",
|
||||
@@ -211,9 +315,11 @@ This gives you:
|
||||
|
||||
There are three ways to document your endpoints.
|
||||
|
||||
**Pydantic models** — the recommended approach for new APIs. Use
|
||||
``request_model`` and ``response_model`` to annotate your routes, and
|
||||
Responder will generate the schema automatically::
|
||||
**Pydantic models** — the recommended approach. Use ``request_model`` and
|
||||
``response_model`` to annotate your routes, and Responder generates the
|
||||
schema automatically. When ``request_model`` is set, request bodies are
|
||||
also validated automatically — invalid inputs get a ``422`` response with
|
||||
detailed error messages::
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -232,19 +338,11 @@ Responder will generate the schema automatically::
|
||||
data = await req.media()
|
||||
resp.media = {"id": 1, **data}
|
||||
|
||||
This generates a full OpenAPI path with ``requestBody`` and ``responses``
|
||||
schemas, all linked by ``$ref`` to your Pydantic models in
|
||||
``components/schemas``.
|
||||
When ``response_model`` is set, the response is serialized through the
|
||||
model — extra fields are stripped and types are enforced.
|
||||
|
||||
You can also register standalone schemas with the ``@api.schema`` decorator::
|
||||
|
||||
@api.schema("Pet")
|
||||
class Pet(BaseModel):
|
||||
name: str
|
||||
age: int = 0
|
||||
|
||||
**YAML docstrings** — inline your OpenAPI spec directly in the docstring.
|
||||
This gives you full control over every detail::
|
||||
**YAML docstrings** — for full control, embed OpenAPI YAML in the
|
||||
docstring::
|
||||
|
||||
@api.route("/pets")
|
||||
def list_pets(req, resp):
|
||||
@@ -258,8 +356,7 @@ This gives you full control over every detail::
|
||||
"""
|
||||
resp.media = [{"name": "Fido"}]
|
||||
|
||||
**Marshmallow schemas** — if you're already using marshmallow for
|
||||
validation, Responder integrates with it via the apispec plugin::
|
||||
**Marshmallow schemas** — if you're already using marshmallow::
|
||||
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
@@ -267,19 +364,43 @@ validation, Responder integrates with it via the apispec plugin::
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
All three approaches can be mixed in the same API. Pydantic models,
|
||||
marshmallow schemas, and YAML docstrings all contribute to the same
|
||||
generated OpenAPI specification.
|
||||
All three approaches can be mixed in the same API. You can choose from
|
||||
multiple documentation themes: ``swagger_ui`` (default), ``redoc``,
|
||||
``rapidoc``, or ``elements``.
|
||||
|
||||
You can choose from multiple documentation themes:
|
||||
``swagger_ui`` (default), ``redoc``, ``rapidoc``, or ``elements``.
|
||||
|
||||
Route Groups
|
||||
------------
|
||||
|
||||
As your application grows, you'll want to organize routes logically.
|
||||
Route groups let you share a URL prefix across related endpoints — a
|
||||
common pattern for API versioning::
|
||||
|
||||
v1 = api.group("/v1")
|
||||
|
||||
@v1.route("/users")
|
||||
def list_users(req, resp):
|
||||
resp.media = []
|
||||
|
||||
@v1.route("/users/{user_id:int}")
|
||||
def get_user(req, resp, *, user_id):
|
||||
resp.media = {"id": user_id}
|
||||
|
||||
v2 = api.group("/v2")
|
||||
|
||||
@v2.route("/users")
|
||||
def list_users_v2(req, resp):
|
||||
resp.media = {"users": [], "total": 0}
|
||||
|
||||
This keeps your code organized without affecting the routing logic.
|
||||
|
||||
|
||||
Mounting Other Apps
|
||||
-------------------
|
||||
|
||||
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::
|
||||
Responder can mount any WSGI or ASGI application at a subroute. This is
|
||||
incredibly useful for gradual migrations — you can run Flask and Responder
|
||||
side by side, moving routes over one at a time::
|
||||
|
||||
from flask import Flask
|
||||
|
||||
@@ -293,12 +414,17 @@ you can gradually migrate from Flask, or run multiple frameworks side by side::
|
||||
|
||||
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.
|
||||
wraps WSGI apps in an ASGI adapter automatically.
|
||||
|
||||
|
||||
Cookies
|
||||
-------
|
||||
|
||||
`Cookies <https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies>`_ are
|
||||
small pieces of data that the server asks the browser to store and send
|
||||
back with every subsequent request. They're the foundation of sessions,
|
||||
authentication tokens, and user preferences on the web.
|
||||
|
||||
Reading and writing cookies is straightforward::
|
||||
|
||||
# Read cookies from the request
|
||||
@@ -307,26 +433,28 @@ Reading and writing cookies is straightforward::
|
||||
# Set a cookie on the response
|
||||
resp.cookies["hello"] = "world"
|
||||
|
||||
For more control over cookie directives, use ``set_cookie``::
|
||||
For production use, you'll want to set security directives. The
|
||||
``httponly`` flag prevents JavaScript from reading the cookie (defending
|
||||
against XSS attacks), and ``secure`` ensures it's only sent over HTTPS::
|
||||
|
||||
resp.set_cookie(
|
||||
"token",
|
||||
value="abc123",
|
||||
max_age=3600,
|
||||
secure=True,
|
||||
httponly=True,
|
||||
max_age=3600, # expires in 1 hour
|
||||
secure=True, # HTTPS only
|
||||
httponly=True, # no JavaScript access
|
||||
path="/",
|
||||
)
|
||||
|
||||
Supported directives: ``key``, ``value``, ``expires``, ``max_age``,
|
||||
``domain``, ``path``, ``secure``, ``httponly``.
|
||||
|
||||
|
||||
Cookie-Based Sessions
|
||||
---------------------
|
||||
|
||||
Responder has built-in support for signed, cookie-based sessions. Just
|
||||
read from and write to the ``session`` dictionary::
|
||||
Sessions let you store per-user data across multiple requests. Responder's
|
||||
built-in sessions are cookie-based — the session data is serialized, signed
|
||||
with your secret key, and stored in a cookie. The signature prevents
|
||||
tampering: if someone modifies the cookie, the signature won't match and
|
||||
the data will be rejected::
|
||||
|
||||
@api.route("/login")
|
||||
def login(req, resp):
|
||||
@@ -336,13 +464,9 @@ read from and write to the ``session`` dictionary::
|
||||
def profile(req, resp):
|
||||
resp.media = {"user": req.session.get("username")}
|
||||
|
||||
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.
|
||||
|
||||
.. warning::
|
||||
|
||||
For production use, always set a secret key::
|
||||
Always set a secret key in production. The default key is not secret::
|
||||
|
||||
api = responder.API(secret_key="your-secret-key-here")
|
||||
|
||||
@@ -350,141 +474,98 @@ from your server.
|
||||
Static Files
|
||||
------------
|
||||
|
||||
Static files are served from the ``static/`` directory by default::
|
||||
Most web applications serve static assets — CSS stylesheets, JavaScript
|
||||
files, images, fonts. Responder serves these from the ``static/`` directory
|
||||
by default::
|
||||
|
||||
api = responder.API(static_dir="static", static_route="/static")
|
||||
|
||||
Place your CSS, JavaScript, images, and other assets in the ``static/``
|
||||
directory and they'll be served automatically.
|
||||
Place your assets in the ``static/`` directory and they'll be served
|
||||
automatically at ``/static/style.css``, ``/static/app.js``, etc.
|
||||
|
||||
For single-page applications, you can serve ``index.html`` as the default
|
||||
response for all unmatched routes::
|
||||
For single-page applications (React, Vue, Angular), 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::
|
||||
`CORS <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>`_ (Cross-
|
||||
Origin Resource Sharing) is a security mechanism that controls which
|
||||
websites can make requests to your API. Browsers enforce this — if your
|
||||
API is at ``api.example.com`` and your frontend is at ``app.example.com``,
|
||||
the browser will block requests unless your API explicitly allows it.
|
||||
|
||||
Enable CORS and configure which origins are allowed::
|
||||
|
||||
api = responder.API(cors=True, cors_params={
|
||||
"allow_origins": ["https://example.com"],
|
||||
"allow_origins": ["https://app.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.
|
||||
The default policy is restrictive — you must explicitly allow each origin.
|
||||
Using ``["*"]`` for allow_origins permits any website to call your API,
|
||||
which is fine for public APIs but not for private ones.
|
||||
|
||||
|
||||
HSTS
|
||||
----
|
||||
|
||||
Force all traffic to HTTPS with a single flag::
|
||||
`HSTS <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security>`_
|
||||
(HTTP Strict Transport Security) tells browsers to always use HTTPS when
|
||||
communicating with your server. Once a browser sees the HSTS header, it
|
||||
will refuse to connect over plain HTTP, even if the user types ``http://``
|
||||
in the address bar::
|
||||
|
||||
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::
|
||||
The ``Host`` header in an HTTP request tells the server which domain name
|
||||
the client used. Attackers can forge this header to trick your application
|
||||
into generating URLs to malicious domains (a class of attack called *Host
|
||||
header injection*).
|
||||
|
||||
Restrict which hostnames your application accepts::
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Server-Sent Events (SSE)
|
||||
------------------------
|
||||
|
||||
Stream real-time updates to the client using Server-Sent Events. This is
|
||||
great for live feeds, progress updates, and AI streaming responses::
|
||||
|
||||
@api.route("/events")
|
||||
async def events(req, resp):
|
||||
@resp.sse
|
||||
async def stream():
|
||||
for i in range(10):
|
||||
yield {"data": f"message {i}"}
|
||||
|
||||
Each yielded value can be a string (treated as data) or a dict with
|
||||
``data``, ``event``, ``id``, and ``retry`` fields::
|
||||
|
||||
yield {"event": "update", "data": "hello", "id": "1"}
|
||||
yield "simple string message"
|
||||
|
||||
|
||||
Streaming Files
|
||||
---------------
|
||||
|
||||
For large files, use ``resp.stream_file()`` to stream the content without
|
||||
loading the entire file into memory::
|
||||
|
||||
@api.route("/download")
|
||||
def download(req, resp):
|
||||
resp.stream_file("large-dataset.csv")
|
||||
|
||||
For small files where memory isn't a concern, ``resp.file()`` loads the
|
||||
entire file at once — simpler but less efficient for large files.
|
||||
|
||||
|
||||
After-Request Hooks
|
||||
-------------------
|
||||
|
||||
Run code after every request, useful for logging, adding headers, or
|
||||
cleanup::
|
||||
|
||||
@api.after_request()
|
||||
def log_response(req, resp):
|
||||
print(f"{req.method} {req.full_url} -> {resp.status_code}")
|
||||
|
||||
|
||||
Route Groups
|
||||
------------
|
||||
|
||||
Organize related routes with a shared URL prefix. Useful for API versioning
|
||||
and logical grouping::
|
||||
|
||||
v1 = api.group("/v1")
|
||||
|
||||
@v1.route("/users")
|
||||
def list_users(req, resp):
|
||||
resp.media = []
|
||||
|
||||
@v1.route("/users/{user_id:int}")
|
||||
def get_user(req, resp, *, user_id):
|
||||
resp.media = {"id": user_id}
|
||||
Requests with unrecognized hosts get a ``400 Bad Request``. Wildcard
|
||||
patterns are supported. By default, all hostnames are allowed.
|
||||
|
||||
|
||||
Request ID
|
||||
----------
|
||||
|
||||
Auto-generate unique request IDs for tracing and debugging. If the client
|
||||
sends an ``X-Request-ID`` header, it's forwarded; otherwise a new UUID is
|
||||
generated::
|
||||
In distributed systems, tracing a single request across multiple services
|
||||
is essential for debugging. Request IDs are unique identifiers attached to
|
||||
each request — if something goes wrong, you can search your logs for that
|
||||
ID and find every related event.
|
||||
|
||||
Responder can auto-generate request IDs. If the client sends an
|
||||
``X-Request-ID`` header (common in microservice architectures), it's
|
||||
forwarded. Otherwise, a new UUID is generated::
|
||||
|
||||
api = responder.API(request_id=True)
|
||||
|
||||
The ID appears in the ``X-Request-ID`` response header.
|
||||
|
||||
|
||||
Rate Limiting
|
||||
-------------
|
||||
|
||||
Built-in token bucket rate limiter::
|
||||
Rate limiting prevents individual clients from overwhelming your API with
|
||||
too many requests. It's essential for public APIs, and good practice even
|
||||
for internal ones.
|
||||
|
||||
Responder includes a built-in token bucket rate limiter::
|
||||
|
||||
from responder.ext.ratelimit import RateLimiter
|
||||
|
||||
@@ -492,17 +573,25 @@ Built-in token bucket rate limiter::
|
||||
limiter.install(api)
|
||||
|
||||
When the limit is exceeded, clients receive a ``429 Too Many Requests``
|
||||
response with ``Retry-After`` and ``X-RateLimit-Remaining`` headers.
|
||||
response with a ``Retry-After`` header. Every response includes
|
||||
``X-RateLimit-Limit`` and ``X-RateLimit-Remaining`` headers so clients
|
||||
can pace themselves.
|
||||
|
||||
The rate limiter is per-client, keyed by IP address.
|
||||
|
||||
|
||||
MessagePack
|
||||
-----------
|
||||
|
||||
In addition to JSON and YAML, Responder supports MessagePack for efficient
|
||||
binary serialization::
|
||||
`MessagePack <https://msgpack.org/>`_ is a binary serialization format
|
||||
that's more compact and faster to parse than JSON. It's useful for
|
||||
high-throughput APIs, IoT devices, and anywhere bandwidth matters.
|
||||
|
||||
# Decode MessagePack request body
|
||||
Responder supports MessagePack alongside JSON and YAML::
|
||||
|
||||
# Decode a MessagePack request body
|
||||
data = await req.media("msgpack")
|
||||
|
||||
# Content negotiation also works — clients can send
|
||||
# Accept: application/x-msgpack to receive MessagePack responses.
|
||||
Content negotiation works too — clients can send
|
||||
``Accept: application/x-msgpack`` to receive MessagePack responses
|
||||
instead of JSON.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
Authentication
|
||||
==============
|
||||
|
||||
Every API that handles user data needs authentication — a way to verify
|
||||
who is making a request. This guide covers the most common patterns:
|
||||
API keys, JWT tokens, and how to build reusable auth guards with
|
||||
Responder's before-request hooks.
|
||||
|
||||
|
||||
API Key Authentication
|
||||
----------------------
|
||||
|
||||
The simplest approach. The client sends a secret key in a header, and
|
||||
your server checks it against a known value. This is common for
|
||||
server-to-server communication and simple APIs::
|
||||
|
||||
API_KEYS = {"sk-abc123", "sk-def456"}
|
||||
|
||||
@api.route(before_request=True)
|
||||
def check_api_key(req, resp):
|
||||
key = req.headers.get("X-API-Key")
|
||||
if key not in API_KEYS:
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "Invalid or missing API key"}
|
||||
|
||||
Because the before-request hook sets ``resp.status_code``, the route
|
||||
handler is skipped entirely for unauthorized requests. The client never
|
||||
reaches your endpoint — the guard catches them first.
|
||||
|
||||
The client sends the key like this::
|
||||
|
||||
$ curl -H "X-API-Key: sk-abc123" http://localhost:5042/protected
|
||||
|
||||
|
||||
Bearer Token Authentication
|
||||
----------------------------
|
||||
|
||||
Bearer tokens are the standard for modern APIs. The client sends a token
|
||||
in the ``Authorization`` header, and the server validates it. The most
|
||||
common format is `JWT <https://jwt.io/>`_ (JSON Web Tokens).
|
||||
|
||||
Install PyJWT::
|
||||
|
||||
$ uv pip install pyjwt
|
||||
|
||||
Create a helper to encode and decode tokens::
|
||||
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
SECRET = "your-secret-key"
|
||||
|
||||
def create_token(user_id: int) -> str:
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"exp": datetime.utcnow() + timedelta(hours=24),
|
||||
}
|
||||
return jwt.encode(payload, SECRET, algorithm="HS256")
|
||||
|
||||
def verify_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, SECRET, algorithms=["HS256"])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
Add a login endpoint that issues tokens, and a before-request hook that
|
||||
verifies them::
|
||||
|
||||
@api.route("/login", methods=["POST"])
|
||||
async def login(req, resp):
|
||||
data = await req.media()
|
||||
# In a real app, check credentials against a database
|
||||
if data.get("username") == "admin" and data.get("password") == "secret":
|
||||
token = create_token(user_id=1)
|
||||
resp.media = {"token": token}
|
||||
else:
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "Invalid credentials"}
|
||||
|
||||
@api.route(before_request=True)
|
||||
def auth_guard(req, resp):
|
||||
# Skip auth for the login endpoint itself
|
||||
if req.url.path == "/login":
|
||||
return
|
||||
|
||||
auth = req.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "Missing bearer token"}
|
||||
return
|
||||
|
||||
token = auth[7:] # Strip "Bearer "
|
||||
payload = verify_token(token)
|
||||
if payload is None:
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "Invalid or expired token"}
|
||||
return
|
||||
|
||||
# Store the authenticated user on the request state
|
||||
req.state.user_id = payload["sub"]
|
||||
|
||||
Now any route can access the authenticated user::
|
||||
|
||||
@api.route("/me")
|
||||
def get_me(req, resp):
|
||||
resp.media = {"user_id": req.state.user_id}
|
||||
|
||||
The client flow:
|
||||
|
||||
1. ``POST /login`` with credentials → receive a token
|
||||
2. Include ``Authorization: Bearer <token>`` on every subsequent request
|
||||
3. The token expires after 24 hours — the client must log in again
|
||||
|
||||
|
||||
Skipping Auth for Public Routes
|
||||
--------------------------------
|
||||
|
||||
The example above skips auth for ``/login`` by checking the path. For
|
||||
more control, you can use a set of public paths::
|
||||
|
||||
PUBLIC_PATHS = {"/login", "/signup", "/health", "/docs", "/schema.yml"}
|
||||
|
||||
@api.route(before_request=True)
|
||||
def auth_guard(req, resp):
|
||||
if req.url.path in PUBLIC_PATHS:
|
||||
return
|
||||
# ... check token
|
||||
|
||||
|
||||
Custom Exception for Auth Errors
|
||||
---------------------------------
|
||||
|
||||
For cleaner code, define a custom exception and register a handler::
|
||||
|
||||
class AuthError(Exception):
|
||||
def __init__(self, message="Unauthorized", status_code=401):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
|
||||
@api.exception_handler(AuthError)
|
||||
async def handle_auth_error(req, resp, exc):
|
||||
resp.status_code = exc.status_code
|
||||
resp.media = {"error": exc.message}
|
||||
|
||||
Now your auth guard can simply raise::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def auth_guard(req, resp):
|
||||
if req.url.path in PUBLIC_PATHS:
|
||||
return
|
||||
if "Authorization" not in req.headers:
|
||||
raise AuthError("Missing authorization header")
|
||||
|
||||
|
||||
Using Sessions for Web Apps
|
||||
----------------------------
|
||||
|
||||
For traditional web applications (with HTML pages and forms), cookie-based
|
||||
sessions are simpler than tokens. The browser handles cookies automatically
|
||||
— no client-side token management needed::
|
||||
|
||||
@api.route("/login", methods=["POST"])
|
||||
async def login(req, resp):
|
||||
data = await req.media("form")
|
||||
if data["username"] == "admin" and data["password"] == "secret":
|
||||
resp.session["user"] = data["username"]
|
||||
api.redirect(resp, location="/dashboard")
|
||||
else:
|
||||
resp.status_code = 401
|
||||
resp.html = "<p>Invalid credentials</p>"
|
||||
|
||||
@api.route("/dashboard")
|
||||
def dashboard(req, resp):
|
||||
user = req.session.get("user")
|
||||
if not user:
|
||||
api.redirect(resp, location="/login")
|
||||
return
|
||||
resp.html = f"<h1>Welcome, {user}!</h1>"
|
||||
|
||||
@api.route("/logout")
|
||||
def logout(req, resp):
|
||||
resp.session.clear()
|
||||
api.redirect(resp, location="/login")
|
||||
|
||||
Remember to set a proper secret key::
|
||||
|
||||
api = responder.API(secret_key="your-production-secret-key")
|
||||
|
||||
The session data is signed (not encrypted) — users can read it but
|
||||
can't tamper with it. Don't store sensitive data like passwords in
|
||||
sessions.
|
||||
@@ -0,0 +1,192 @@
|
||||
Migrating from Flask
|
||||
====================
|
||||
|
||||
If you're coming from Flask, you'll find Responder familiar but different
|
||||
in a few key ways. This guide maps Flask concepts to their Responder
|
||||
equivalents and shows you how to translate common patterns.
|
||||
|
||||
|
||||
The Big Differences
|
||||
-------------------
|
||||
|
||||
**No return values.** In Flask, you return a response. In Responder, you
|
||||
mutate it. This is the single biggest difference:
|
||||
|
||||
Flask::
|
||||
|
||||
@app.route("/")
|
||||
def hello():
|
||||
return "hello, world!"
|
||||
|
||||
Responder::
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
**Explicit request and response.** Flask uses a global ``request`` object
|
||||
(via thread-local magic). Responder passes ``req`` and ``resp`` explicitly.
|
||||
No magic, no import needed — they're right there in the function signature.
|
||||
|
||||
**ASGI, not WSGI.** Flask runs on WSGI, which is synchronous. Responder
|
||||
runs on ASGI, which supports async natively. You can still write sync
|
||||
views — Responder runs them in a thread pool automatically.
|
||||
|
||||
|
||||
Quick Reference
|
||||
---------------
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
:widths: 40 60
|
||||
|
||||
* - Flask
|
||||
- Responder
|
||||
* - ``Flask(__name__)``
|
||||
- ``responder.API()``
|
||||
* - ``return "text"``
|
||||
- ``resp.text = "text"``
|
||||
* - ``return jsonify(data)``
|
||||
- ``resp.media = data``
|
||||
* - ``return render_template("t.html", x=1)``
|
||||
- ``resp.html = api.template("t.html", x=1)``
|
||||
* - ``request.args["q"]``
|
||||
- ``req.params["q"]``
|
||||
* - ``request.json``
|
||||
- ``await req.media()``
|
||||
* - ``request.form``
|
||||
- ``await req.media("form")``
|
||||
* - ``request.headers["X"]``
|
||||
- ``req.headers["X"]``
|
||||
* - ``request.method``
|
||||
- ``req.method``
|
||||
* - ``request.cookies["x"]``
|
||||
- ``req.cookies["x"]``
|
||||
* - ``session["x"] = 1``
|
||||
- ``resp.session["x"] = 1``
|
||||
* - ``abort(404)``
|
||||
- ``resp.status_code = 404``
|
||||
* - ``redirect("/new")``
|
||||
- ``api.redirect(resp, location="/new")``
|
||||
* - ``@app.before_request``
|
||||
- ``@api.route(before_request=True)``
|
||||
* - ``@app.errorhandler(404)``
|
||||
- ``@api.exception_handler(ValueError)``
|
||||
* - ``app.run(debug=True)``
|
||||
- ``api.run(debug=True)``
|
||||
|
||||
|
||||
Route Parameters
|
||||
----------------
|
||||
|
||||
Flask uses ``<angle_brackets>``. Responder uses ``{curly_braces}``
|
||||
with the same type convertor idea:
|
||||
|
||||
Flask::
|
||||
|
||||
@app.route("/users/<int:user_id>")
|
||||
def get_user(user_id):
|
||||
return jsonify({"id": user_id})
|
||||
|
||||
Responder::
|
||||
|
||||
@api.route("/users/{user_id:int}")
|
||||
def get_user(req, resp, *, user_id):
|
||||
resp.media = {"id": user_id}
|
||||
|
||||
Note the ``*`` — route parameters are keyword-only arguments in
|
||||
Responder. This makes the interface explicit about which arguments
|
||||
come from the URL.
|
||||
|
||||
|
||||
JSON APIs
|
||||
---------
|
||||
|
||||
Flask::
|
||||
|
||||
@app.route("/api/items", methods=["POST"])
|
||||
def create_item():
|
||||
data = request.json
|
||||
# ... create item
|
||||
return jsonify(item), 201
|
||||
|
||||
Responder::
|
||||
|
||||
@api.route("/api/items", methods=["POST"])
|
||||
async def create_item(req, resp):
|
||||
data = await req.media()
|
||||
# ... create item
|
||||
resp.media = item
|
||||
resp.status_code = 201
|
||||
|
||||
The ``await`` is needed because reading the request body is an async
|
||||
I/O operation. This is more explicit than Flask's approach, and it
|
||||
means the event loop isn't blocked while waiting for the body to arrive.
|
||||
|
||||
|
||||
Templates
|
||||
---------
|
||||
|
||||
Both use Jinja2. The syntax is nearly identical:
|
||||
|
||||
Flask::
|
||||
|
||||
@app.route("/hello/<name>")
|
||||
def hello(name):
|
||||
return render_template("hello.html", name=name)
|
||||
|
||||
Responder::
|
||||
|
||||
@api.route("/hello/{name}")
|
||||
def hello(req, resp, *, name):
|
||||
resp.html = api.template("hello.html", name=name)
|
||||
|
||||
|
||||
Blueprints → Route Groups
|
||||
--------------------------
|
||||
|
||||
Flask uses Blueprints to organize routes. Responder has route groups:
|
||||
|
||||
Flask::
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
@bp.route("/users")
|
||||
def list_users():
|
||||
return jsonify([])
|
||||
|
||||
app.register_blueprint(bp)
|
||||
|
||||
Responder::
|
||||
|
||||
api_v1 = api.group("/api")
|
||||
|
||||
@api_v1.route("/users")
|
||||
def list_users(req, resp):
|
||||
resp.media = []
|
||||
|
||||
|
||||
Gradual Migration
|
||||
-----------------
|
||||
|
||||
You don't have to migrate all at once. Responder can mount your existing
|
||||
Flask app at a subroute, so you can move endpoints over one at a time::
|
||||
|
||||
from flask import Flask
|
||||
|
||||
flask_app = Flask(__name__)
|
||||
|
||||
# Your existing Flask routes stay here
|
||||
@flask_app.route("/legacy")
|
||||
def legacy():
|
||||
return "old endpoint"
|
||||
|
||||
# Mount Flask under /old, new routes go on Responder
|
||||
api.mount("/old", flask_app)
|
||||
|
||||
@api.route("/new")
|
||||
def new_endpoint(req, resp):
|
||||
resp.media = {"modern": True}
|
||||
|
||||
Requests to ``/old/legacy`` go to Flask. Everything else goes to
|
||||
Responder. When you've moved everything over, remove the mount.
|
||||
@@ -0,0 +1,129 @@
|
||||
Writing Middleware
|
||||
=================
|
||||
|
||||
Middleware sits between the server and your route handlers, processing
|
||||
every request and response that flows through your application. It's the
|
||||
right tool for cross-cutting concerns — things that apply to *all*
|
||||
requests, not just specific routes.
|
||||
|
||||
Common middleware use cases:
|
||||
|
||||
- Request logging and timing
|
||||
- Authentication and authorization
|
||||
- Adding security headers
|
||||
- Request ID generation
|
||||
- Rate limiting
|
||||
- Response compression (built-in)
|
||||
|
||||
|
||||
Hooks vs. Middleware
|
||||
--------------------
|
||||
|
||||
Responder gives you two levels of request processing:
|
||||
|
||||
**Hooks** (``before_request`` / ``after_request``) run inside Responder's
|
||||
routing layer. They receive Responder's ``req`` and ``resp`` objects and
|
||||
are the simplest way to add behavior::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def add_header(req, resp):
|
||||
resp.headers["X-Powered-By"] = "Responder"
|
||||
|
||||
@api.after_request()
|
||||
def log_request(req, resp):
|
||||
print(f"{req.method} {req.url.path} -> {resp.status_code}")
|
||||
|
||||
**Middleware** runs at the ASGI level, wrapping the entire application.
|
||||
It's more powerful but more complex — you work with raw ASGI scopes
|
||||
instead of Responder objects. Use middleware when you need to process
|
||||
requests *before* they reach Responder's routing, or when you need to
|
||||
integrate with Starlette middleware.
|
||||
|
||||
|
||||
Using Starlette Middleware
|
||||
--------------------------
|
||||
|
||||
Responder is built on Starlette, so any Starlette middleware works
|
||||
out of the box::
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
class TimingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
import time
|
||||
start = time.time()
|
||||
response = await call_next(request)
|
||||
duration = time.time() - start
|
||||
response.headers["X-Response-Time"] = f"{duration:.3f}s"
|
||||
return response
|
||||
|
||||
api.add_middleware(TimingMiddleware)
|
||||
|
||||
The ``dispatch`` method receives a Starlette ``Request`` and a
|
||||
``call_next`` function. Call ``call_next(request)`` to pass the request
|
||||
to the next middleware (or to your route handler). The return value is
|
||||
a Starlette ``Response`` that you can modify before it's sent.
|
||||
|
||||
|
||||
Built-in Middleware
|
||||
-------------------
|
||||
|
||||
Responder configures several middleware components automatically:
|
||||
|
||||
- **GZipMiddleware** — compresses responses larger than 500 bytes
|
||||
- **TrustedHostMiddleware** — validates the ``Host`` header
|
||||
- **ServerErrorMiddleware** — catches unhandled exceptions
|
||||
- **ExceptionMiddleware** — routes exceptions to your handlers
|
||||
- **SessionMiddleware** — manages signed cookie sessions
|
||||
|
||||
Optional middleware you can enable:
|
||||
|
||||
- **CORSMiddleware** — ``api = responder.API(cors=True)``
|
||||
- **HTTPSRedirectMiddleware** — ``api = responder.API(enable_hsts=True)``
|
||||
|
||||
|
||||
Adding Third-Party Middleware
|
||||
-----------------------------
|
||||
|
||||
Any ASGI middleware can be added with ``api.add_middleware()``::
|
||||
|
||||
from some_package import SomeMiddleware
|
||||
|
||||
api.add_middleware(SomeMiddleware, option1="value", option2=True)
|
||||
|
||||
Keyword arguments are passed to the middleware's constructor.
|
||||
|
||||
|
||||
Middleware Order
|
||||
----------------
|
||||
|
||||
Middleware wraps your application like layers of an onion. The *last*
|
||||
middleware added is the *outermost* layer — it sees the request first
|
||||
and the response last.
|
||||
|
||||
Responder's built-in middleware stack (from outermost to innermost):
|
||||
|
||||
1. SessionMiddleware
|
||||
2. ServerErrorMiddleware
|
||||
3. CORSMiddleware (if enabled)
|
||||
4. TrustedHostMiddleware
|
||||
5. HTTPSRedirectMiddleware (if enabled)
|
||||
6. GZipMiddleware
|
||||
7. ExceptionMiddleware
|
||||
8. Your routes
|
||||
|
||||
When you call ``api.add_middleware()``, your middleware is added *outside*
|
||||
the existing stack. Keep this in mind for ordering dependencies — if
|
||||
middleware A depends on middleware B having run first, add B before A.
|
||||
|
||||
|
||||
When to Use What
|
||||
-----------------
|
||||
|
||||
- **Simple header additions, logging, auth checks** → use hooks
|
||||
- **Response transformation, timing, third-party integrations** → use middleware
|
||||
- **Rate limiting** → use the built-in ``RateLimiter`` (it uses hooks internally)
|
||||
- **Request ID** → use ``api = responder.API(request_id=True)``
|
||||
|
||||
Start with hooks. They're simpler and cover most cases. Graduate to
|
||||
middleware when hooks aren't enough.
|
||||
@@ -0,0 +1,219 @@
|
||||
Building a REST API
|
||||
===================
|
||||
|
||||
This tutorial walks you through building a complete REST API from scratch.
|
||||
By the end, you'll have a working API with CRUD operations, request
|
||||
validation, error handling, and interactive documentation.
|
||||
|
||||
We'll build a simple book catalog — a service that lets you create, read,
|
||||
update, and delete books.
|
||||
|
||||
|
||||
Project Setup
|
||||
-------------
|
||||
|
||||
Create a new file called ``app.py``::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API(
|
||||
title="Book Catalog",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
docs_route="/docs",
|
||||
)
|
||||
|
||||
We're enabling OpenAPI documentation from the start. Visit ``/docs`` at
|
||||
any point to see interactive Swagger UI for your API.
|
||||
|
||||
|
||||
Define Your Models
|
||||
------------------
|
||||
|
||||
We'll use `Pydantic <https://docs.pydantic.dev/>`_ to define our data
|
||||
models. Pydantic models serve double duty — they validate incoming data
|
||||
*and* generate OpenAPI schemas automatically::
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class BookIn(BaseModel):
|
||||
"""What the client sends when creating a book."""
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
class Book(BaseModel):
|
||||
"""What the API returns."""
|
||||
id: int
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
``BookIn`` is the *input* model — it doesn't have an ``id`` because the
|
||||
server assigns that. ``Book`` is the *output* model — it includes
|
||||
everything. This input/output separation is a common REST API pattern.
|
||||
|
||||
|
||||
In-Memory Storage
|
||||
-----------------
|
||||
|
||||
For this tutorial, we'll store books in a simple dict. In a real
|
||||
application, you'd use a database (see :doc:`tutorial-sqlalchemy`)::
|
||||
|
||||
books_db: dict[int, dict] = {}
|
||||
next_id = 1
|
||||
|
||||
|
||||
List All Books
|
||||
--------------
|
||||
|
||||
The first endpoint — list all books. This is a ``GET`` request to
|
||||
``/books``::
|
||||
|
||||
@api.route("/books", methods=["GET"], response_model=list)
|
||||
def list_books(req, resp):
|
||||
resp.media = list(books_db.values())
|
||||
|
||||
In REST API design, ``GET`` requests should never modify data. They're
|
||||
*safe* and *idempotent* — calling them multiple times has the same effect
|
||||
as calling them once.
|
||||
|
||||
|
||||
Create a Book
|
||||
-------------
|
||||
|
||||
To create a book, the client sends a ``POST`` request with a JSON body.
|
||||
We use ``request_model=BookIn`` to validate the input automatically — if
|
||||
the client sends bad data, they get a ``422`` response with error details::
|
||||
|
||||
@api.route("/books", methods=["POST"], check_existing=False,
|
||||
request_model=BookIn, response_model=Book)
|
||||
async def create_book(req, resp):
|
||||
global next_id
|
||||
data = await req.media()
|
||||
|
||||
book = {"id": next_id, **data}
|
||||
books_db[next_id] = book
|
||||
next_id += 1
|
||||
|
||||
resp.media = book
|
||||
resp.status_code = 201
|
||||
|
||||
Note ``resp.status_code = 201`` — the HTTP ``201 Created`` status code
|
||||
tells the client that a new resource was successfully created. This is
|
||||
more informative than a generic ``200 OK``.
|
||||
|
||||
|
||||
Get a Single Book
|
||||
-----------------
|
||||
|
||||
Retrieve a specific book by its ID. The ``{book_id:int}`` route parameter
|
||||
ensures only integer IDs match — requests like ``/books/abc`` will 404::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["GET"], response_model=Book)
|
||||
def get_book(req, resp, *, book_id):
|
||||
if book_id not in books_db:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": f"Book {book_id} not found"}
|
||||
return
|
||||
|
||||
resp.media = books_db[book_id]
|
||||
|
||||
|
||||
Update a Book
|
||||
-------------
|
||||
|
||||
``PUT`` replaces a resource entirely. The client must send all fields::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["PUT"], check_existing=False,
|
||||
request_model=BookIn, response_model=Book)
|
||||
async def update_book(req, resp, *, book_id):
|
||||
if book_id not in books_db:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": f"Book {book_id} not found"}
|
||||
return
|
||||
|
||||
data = await req.media()
|
||||
book = {"id": book_id, **data}
|
||||
books_db[book_id] = book
|
||||
resp.media = book
|
||||
|
||||
|
||||
Delete a Book
|
||||
-------------
|
||||
|
||||
``DELETE`` removes a resource. The convention is to return ``204 No Content``
|
||||
with an empty body on success::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["DELETE"], check_existing=False)
|
||||
def delete_book(req, resp, *, book_id):
|
||||
if book_id not in books_db:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": f"Book {book_id} not found"}
|
||||
return
|
||||
|
||||
del books_db[book_id]
|
||||
resp.status_code = 204
|
||||
|
||||
|
||||
Error Handling
|
||||
--------------
|
||||
|
||||
Let's add a custom error handler so any ``ValueError`` in our code returns
|
||||
a clean JSON response instead of a 500 error::
|
||||
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle_value_error(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
|
||||
Run It
|
||||
------
|
||||
|
||||
Add the standard entry point at the bottom of your file::
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Start the server::
|
||||
|
||||
$ python app.py
|
||||
|
||||
Visit ``http://localhost:5042/docs`` to see your interactive API
|
||||
documentation. You can test every endpoint directly from the browser.
|
||||
|
||||
|
||||
Try It Out
|
||||
----------
|
||||
|
||||
Using ``curl``::
|
||||
|
||||
# Create a book
|
||||
$ curl -X POST http://localhost:5042/books \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "Dune", "author": "Frank Herbert", "year": 1965}'
|
||||
|
||||
# List all books
|
||||
$ curl http://localhost:5042/books
|
||||
|
||||
# Get a specific book
|
||||
$ curl http://localhost:5042/books/1
|
||||
|
||||
# Update a book
|
||||
$ curl -X PUT http://localhost:5042/books/1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "Dune", "author": "Frank Herbert", "year": 1965, "isbn": "978-0441172719"}'
|
||||
|
||||
# Delete a book
|
||||
$ curl -X DELETE http://localhost:5042/books/1
|
||||
|
||||
|
||||
What's Next
|
||||
-----------
|
||||
|
||||
This tutorial used in-memory storage. For a real application, you'll want
|
||||
a database. See :doc:`tutorial-sqlalchemy` for how to integrate SQLAlchemy
|
||||
with Responder using the lifespan pattern.
|
||||
@@ -0,0 +1,254 @@
|
||||
Using SQLAlchemy
|
||||
================
|
||||
|
||||
Most real web applications need a database. This guide shows how to
|
||||
integrate `SQLAlchemy <https://www.sqlalchemy.org/>`_ with Responder,
|
||||
using async support and the lifespan pattern for connection management.
|
||||
|
||||
SQLAlchemy is the most popular Python database toolkit. It gives you an
|
||||
ORM (Object-Relational Mapper) for working with databases using Python
|
||||
classes instead of raw SQL, plus a powerful query builder for when you
|
||||
need fine-grained control.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
Install SQLAlchemy with async support and an async database driver.
|
||||
We'll use SQLite for simplicity, but the pattern works with PostgreSQL,
|
||||
MySQL, and any other database SQLAlchemy supports::
|
||||
|
||||
$ uv pip install 'sqlalchemy[asyncio]' aiosqlite
|
||||
|
||||
|
||||
Define Your Models
|
||||
------------------
|
||||
|
||||
SQLAlchemy models map Python classes to database tables. Each attribute
|
||||
becomes a column::
|
||||
|
||||
# models.py
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
class Book(Base):
|
||||
__tablename__ = "books"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
title = Column(String, nullable=False)
|
||||
author = Column(String, nullable=False)
|
||||
year = Column(Integer, nullable=False)
|
||||
isbn = Column(String, nullable=True)
|
||||
|
||||
``DeclarativeBase`` is SQLAlchemy's modern base class (SQLAlchemy 2.0+).
|
||||
Each model class corresponds to a table, and each ``Column`` corresponds
|
||||
to a column in that table.
|
||||
|
||||
|
||||
Database Setup
|
||||
--------------
|
||||
|
||||
Create the async engine and session factory. The *engine* manages
|
||||
the connection pool. The *session* is your unit of work — you use it to
|
||||
query and modify data within a transaction::
|
||||
|
||||
# database.py
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
DATABASE_URL = "sqlite+aiosqlite:///./books.db"
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
The ``echo=True`` flag prints all SQL queries to the console — very
|
||||
helpful during development, but you'll want to disable it in production.
|
||||
|
||||
The ``expire_on_commit=False`` flag keeps model attributes accessible
|
||||
after a commit, which is convenient for returning created objects in
|
||||
API responses.
|
||||
|
||||
|
||||
Lifespan for Startup and Shutdown
|
||||
----------------------------------
|
||||
|
||||
Use Responder's lifespan context manager to create the database tables
|
||||
on startup and dispose of connections on shutdown::
|
||||
|
||||
# app.py
|
||||
from contextlib import asynccontextmanager
|
||||
import responder
|
||||
from database import engine
|
||||
from models import Base
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup: create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
# Shutdown: close all connections
|
||||
await engine.dispose()
|
||||
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
This is the proper way to manage database connections in an async
|
||||
application. The lifespan context manager ensures that:
|
||||
|
||||
1. Tables are created before the first request
|
||||
2. The connection pool is properly closed when the server shuts down
|
||||
3. If table creation fails, the server won't start
|
||||
|
||||
|
||||
CRUD Endpoints
|
||||
--------------
|
||||
|
||||
Now let's build the API endpoints. Each one opens a database session,
|
||||
does its work, and commits or rolls back::
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from database import async_session
|
||||
from models import Book
|
||||
|
||||
# Pydantic models for request/response validation
|
||||
class BookIn(BaseModel):
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
class BookOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
The ``from_attributes = True`` config tells Pydantic to read data from
|
||||
SQLAlchemy model attributes (not just dicts). This lets you pass a
|
||||
SQLAlchemy ``Book`` object directly to ``BookOut``.
|
||||
|
||||
**List all books**::
|
||||
|
||||
@api.route("/books", methods=["GET"])
|
||||
async def list_books(req, resp):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(Book))
|
||||
books = result.scalars().all()
|
||||
resp.media = [BookOut.model_validate(b).model_dump() for b in books]
|
||||
|
||||
**Create a book**::
|
||||
|
||||
@api.route("/books", methods=["POST"], check_existing=False,
|
||||
request_model=BookIn, response_model=BookOut)
|
||||
async def create_book(req, resp):
|
||||
data = await req.media()
|
||||
|
||||
async with async_session() as session:
|
||||
book = Book(**data)
|
||||
session.add(book)
|
||||
await session.commit()
|
||||
await session.refresh(book)
|
||||
resp.media = BookOut.model_validate(book).model_dump()
|
||||
resp.status_code = 201
|
||||
|
||||
**Get a single book**::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["GET"])
|
||||
async def get_book(req, resp, *, book_id):
|
||||
async with async_session() as session:
|
||||
book = await session.get(Book, book_id)
|
||||
if book is None:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": "Book not found"}
|
||||
return
|
||||
resp.media = BookOut.model_validate(book).model_dump()
|
||||
|
||||
**Update a book**::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["PUT"], check_existing=False,
|
||||
request_model=BookIn)
|
||||
async def update_book(req, resp, *, book_id):
|
||||
data = await req.media()
|
||||
|
||||
async with async_session() as session:
|
||||
book = await session.get(Book, book_id)
|
||||
if book is None:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": "Book not found"}
|
||||
return
|
||||
|
||||
for key, value in data.items():
|
||||
setattr(book, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(book)
|
||||
resp.media = BookOut.model_validate(book).model_dump()
|
||||
|
||||
**Delete a book**::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["DELETE"], check_existing=False)
|
||||
async def delete_book(req, resp, *, book_id):
|
||||
async with async_session() as session:
|
||||
book = await session.get(Book, book_id)
|
||||
if book is None:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": "Book not found"}
|
||||
return
|
||||
|
||||
await session.delete(book)
|
||||
await session.commit()
|
||||
resp.status_code = 204
|
||||
|
||||
|
||||
Run It
|
||||
------
|
||||
|
||||
::
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Start the server and you'll see SQLAlchemy's SQL echo in the console.
|
||||
The SQLite database file ``books.db`` is created automatically on first
|
||||
startup.
|
||||
|
||||
|
||||
Using PostgreSQL
|
||||
----------------
|
||||
|
||||
To switch to PostgreSQL, just change the connection URL and driver::
|
||||
|
||||
$ uv pip install asyncpg
|
||||
|
||||
::
|
||||
|
||||
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/mydb"
|
||||
|
||||
Everything else stays the same. SQLAlchemy abstracts the database
|
||||
differences so your application code doesn't need to change.
|
||||
|
||||
|
||||
Tips
|
||||
----
|
||||
|
||||
- Use ``async with async_session() as session`` for every request.
|
||||
Don't share sessions across requests — each request should get its
|
||||
own session and transaction.
|
||||
|
||||
- For complex queries, use SQLAlchemy's ``select()`` with ``.where()``,
|
||||
``.order_by()``, ``.limit()``, and ``.offset()`` — it composes
|
||||
naturally.
|
||||
|
||||
- In production, use connection pooling (SQLAlchemy does this by
|
||||
default) and set pool size limits appropriate for your database.
|
||||
|
||||
- Consider `Alembic <https://alembic.sqlalchemy.org/>`_ for database
|
||||
migrations — it tracks schema changes over time so you can evolve
|
||||
your database without losing data.
|
||||
@@ -0,0 +1,171 @@
|
||||
WebSocket Tutorial
|
||||
==================
|
||||
|
||||
HTTP is request-response — the client asks, the server answers, and the
|
||||
connection closes. WebSockets upgrade that into a persistent, bidirectional
|
||||
channel where both sides can send messages at any time. This is what powers
|
||||
chat apps, live dashboards, multiplayer games, and collaborative editors.
|
||||
|
||||
This tutorial builds a simple chat room to show how WebSockets work in
|
||||
Responder.
|
||||
|
||||
|
||||
How WebSockets Work
|
||||
-------------------
|
||||
|
||||
1. The client sends a normal HTTP request with an ``Upgrade: websocket``
|
||||
header.
|
||||
2. The server accepts the upgrade and the connection switches protocols.
|
||||
3. Both sides can now send messages freely — no more request/response.
|
||||
4. Either side can close the connection at any time.
|
||||
|
||||
In Responder, WebSocket routes receive a ``ws`` object instead of
|
||||
``req`` and ``resp``. The ``ws`` object has methods for accepting the
|
||||
connection, sending and receiving data, and closing.
|
||||
|
||||
|
||||
Echo Server
|
||||
-----------
|
||||
|
||||
The simplest WebSocket — echoes everything back::
|
||||
|
||||
@api.route("/ws", websocket=True)
|
||||
async def echo(ws):
|
||||
await ws.accept()
|
||||
while True:
|
||||
data = await ws.receive_text()
|
||||
await ws.send_text(f"Echo: {data}")
|
||||
|
||||
The ``await ws.accept()`` call completes the WebSocket handshake. After
|
||||
that, you're in a loop — receive a message, send a response.
|
||||
|
||||
Test it with a WebSocket client::
|
||||
|
||||
$ pip install websocket-client
|
||||
$ python -c "
|
||||
import websocket
|
||||
ws = websocket.create_connection('ws://localhost:5042/ws')
|
||||
ws.send('hello')
|
||||
print(ws.recv()) # Echo: hello
|
||||
ws.close()
|
||||
"
|
||||
|
||||
|
||||
Chat Room
|
||||
---------
|
||||
|
||||
A chat room needs to broadcast messages to all connected clients. We keep
|
||||
a set of active connections and iterate through them when someone sends
|
||||
a message::
|
||||
|
||||
connected = set()
|
||||
|
||||
@api.route("/chat", websocket=True)
|
||||
async def chat(ws):
|
||||
await ws.accept()
|
||||
connected.add(ws)
|
||||
try:
|
||||
while True:
|
||||
message = await ws.receive_text()
|
||||
# Broadcast to all connected clients
|
||||
for client in connected:
|
||||
await client.send_text(message)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
connected.discard(ws)
|
||||
|
||||
The ``try/finally`` block ensures we remove disconnected clients from
|
||||
the set, even if the connection drops unexpectedly.
|
||||
|
||||
|
||||
Data Formats
|
||||
------------
|
||||
|
||||
WebSockets support three data formats:
|
||||
|
||||
**Text** — plain strings::
|
||||
|
||||
await ws.send_text("hello")
|
||||
message = await ws.receive_text()
|
||||
|
||||
**JSON** — auto-serialized Python objects::
|
||||
|
||||
await ws.send_json({"type": "update", "data": [1, 2, 3]})
|
||||
message = await ws.receive_json()
|
||||
|
||||
**Binary** — raw bytes, useful for images, audio, or custom protocols::
|
||||
|
||||
await ws.send_bytes(b"\x00\x01\x02")
|
||||
data = await ws.receive_bytes()
|
||||
|
||||
|
||||
HTML Client
|
||||
-----------
|
||||
|
||||
Here's a minimal HTML page that connects to the chat room. The browser's
|
||||
built-in ``WebSocket`` API handles everything — no libraries needed:
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="messages"></div>
|
||||
<input id="input" placeholder="Type a message..." />
|
||||
<script>
|
||||
const ws = new WebSocket("ws://localhost:5042/chat");
|
||||
const messages = document.getElementById("messages");
|
||||
const input = document.getElementById("input");
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = event.data;
|
||||
messages.appendChild(p);
|
||||
};
|
||||
|
||||
input.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
ws.send(input.value);
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Save this as ``static/index.html`` and serve it with Responder's
|
||||
built-in static file support.
|
||||
|
||||
|
||||
Before-Request Hooks for WebSockets
|
||||
------------------------------------
|
||||
|
||||
You can run code before a WebSocket connection is established, just like
|
||||
HTTP before-request hooks. This is useful for authentication::
|
||||
|
||||
@api.before_request(websocket=True)
|
||||
async def ws_auth(ws):
|
||||
# Check for a token in the query string
|
||||
# (WebSocket headers are limited in browsers)
|
||||
await ws.accept()
|
||||
|
||||
WebSocket before-request hooks receive the ``ws`` object and must call
|
||||
``await ws.accept()`` if they want the connection to proceed.
|
||||
|
||||
|
||||
Testing WebSockets
|
||||
------------------
|
||||
|
||||
Use Starlette's ``TestClient`` for WebSocket tests::
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
def test_echo():
|
||||
client = TestClient(api)
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
ws.send_text("hello")
|
||||
assert ws.receive_text() == "Echo: hello"
|
||||
|
||||
The ``websocket_connect`` context manager handles the connection
|
||||
lifecycle — it connects on enter and disconnects on exit.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Complete REST API example with Pydantic validation.
|
||||
# https://responder.kennethreitz.org/tutorial-rest.html
|
||||
import responder
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BookIn(BaseModel):
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
|
||||
class BookOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
|
||||
api = responder.API(
|
||||
title="Book Catalog",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
docs_route="/docs",
|
||||
)
|
||||
|
||||
books_db: dict[int, dict] = {}
|
||||
next_id = 1
|
||||
|
||||
|
||||
@api.route("/books", methods=["GET"])
|
||||
def list_books(req, resp):
|
||||
resp.media = list(books_db.values())
|
||||
|
||||
|
||||
@api.route("/books", methods=["POST"], check_existing=False,
|
||||
request_model=BookIn, response_model=BookOut)
|
||||
async def create_book(req, resp):
|
||||
global next_id
|
||||
data = await req.media()
|
||||
book = {"id": next_id, **data}
|
||||
books_db[next_id] = book
|
||||
next_id += 1
|
||||
resp.media = book
|
||||
resp.status_code = 201
|
||||
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["GET"])
|
||||
def get_book(req, resp, *, book_id):
|
||||
if book_id not in books_db:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": f"Book {book_id} not found"}
|
||||
return
|
||||
resp.media = books_db[book_id]
|
||||
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["DELETE"], check_existing=False)
|
||||
def delete_book(req, resp, *, book_id):
|
||||
if book_id not in books_db:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": f"Book {book_id} not found"}
|
||||
return
|
||||
del books_db[book_id]
|
||||
resp.status_code = 204
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
@@ -0,0 +1,42 @@
|
||||
# Server-Sent Events streaming example.
|
||||
# https://responder.kennethreitz.org/tour.html#server-sent-events-sse
|
||||
import asyncio
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
def index(req, resp):
|
||||
resp.html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>SSE Stream</h1>
|
||||
<div id="events"></div>
|
||||
<script>
|
||||
const source = new EventSource("/stream");
|
||||
const events = document.getElementById("events");
|
||||
source.onmessage = (e) => {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = e.data;
|
||||
events.appendChild(p);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@api.route("/stream")
|
||||
async def stream(req, resp):
|
||||
@resp.sse
|
||||
async def events():
|
||||
for i in range(20):
|
||||
yield {"data": f"Event #{i}"}
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
@@ -0,0 +1,57 @@
|
||||
# WebSocket chat room example.
|
||||
# https://responder.kennethreitz.org/tutorial-websockets.html
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
connected = set()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
def index(req, resp):
|
||||
resp.html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Chat Room</h1>
|
||||
<div id="messages" style="height:300px;overflow-y:scroll;border:1px solid #ccc;padding:10px;"></div>
|
||||
<input id="input" placeholder="Type a message..." style="width:300px;" />
|
||||
<script>
|
||||
const ws = new WebSocket(`ws://${location.host}/chat`);
|
||||
const messages = document.getElementById("messages");
|
||||
const input = document.getElementById("input");
|
||||
ws.onmessage = (e) => {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = e.data;
|
||||
messages.appendChild(p);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
};
|
||||
input.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter" && input.value) {
|
||||
ws.send(input.value);
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@api.route("/chat", websocket=True)
|
||||
async def chat(ws):
|
||||
await ws.accept()
|
||||
connected.add(ws)
|
||||
try:
|
||||
while True:
|
||||
message = await ws.receive_text()
|
||||
for client in connected:
|
||||
await client.send_text(message)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
connected.discard(ws)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
+2
-3
@@ -12,7 +12,7 @@ license = {text = "Apache 2.0"}
|
||||
authors = [
|
||||
{ name = "Kenneth Reitz", email = "me@kennethreitz.org" },
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Web Environment",
|
||||
@@ -21,7 +21,6 @@ classifiers = [
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
@@ -42,7 +41,7 @@ dependencies = [
|
||||
"pueblo[sfa-full]>=0.0.11",
|
||||
"pydantic>=2",
|
||||
"python-multipart",
|
||||
"starlette[full]>=0.40",
|
||||
"starlette[full]>=1.0",
|
||||
"uvicorn[standard]",
|
||||
]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.2.0"
|
||||
__version__ = "3.4.0"
|
||||
|
||||
@@ -61,6 +61,31 @@ class API:
|
||||
lifespan=None,
|
||||
request_id=False,
|
||||
):
|
||||
"""Create a new Responder API instance.
|
||||
|
||||
:param debug: If ``True``, enable debug mode with verbose error pages.
|
||||
:param title: The title of the API, used in OpenAPI documentation.
|
||||
:param version: The version string for the API (e.g. ``"1.0"``).
|
||||
:param description: A longer description of the API for OpenAPI docs.
|
||||
:param terms_of_service: URL to the API's terms of service.
|
||||
:param contact: Contact information dict for the API (``name``, ``url``, ``email``).
|
||||
:param license: License information dict (``name``, ``url``).
|
||||
:param openapi: The OpenAPI version string (e.g. ``"3.0.2"``). Enables OpenAPI schema generation.
|
||||
:param openapi_route: The URL path for the OpenAPI schema (default ``"/schema.yml"``).
|
||||
:param static_dir: Directory for static files. Set to ``None`` to disable. Created automatically if missing.
|
||||
:param static_route: URL prefix for serving static files (default ``"/static"``).
|
||||
:param templates_dir: Directory for Jinja2 templates (default ``"templates"``).
|
||||
:param auto_escape: If ``True``, auto-escape HTML/XML in templates.
|
||||
:param secret_key: Secret key for signing cookie-based sessions. **Always set this in production.**
|
||||
:param enable_hsts: If ``True``, redirect all HTTP requests to HTTPS.
|
||||
:param docs_route: URL path for interactive API docs (e.g. ``"/docs"``). Enables OpenAPI if not already set.
|
||||
:param cors: If ``True``, enable CORS middleware.
|
||||
:param cors_params: Dict of CORS configuration (``allow_origins``, ``allow_methods``, etc.).
|
||||
:param allowed_hosts: List of allowed hostnames (e.g. ``["example.com"]``). Defaults to ``["*"]``.
|
||||
:param openapi_theme: Documentation UI theme: ``"swagger_ui"``, ``"redoc"``, ``"rapidoc"``, or ``"elements"``.
|
||||
:param lifespan: An async context manager for startup/shutdown logic.
|
||||
:param request_id: If ``True``, add ``X-Request-ID`` headers to all responses.
|
||||
""" # noqa: E501
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
self.secret_key = secret_key
|
||||
@@ -150,12 +175,30 @@ class API:
|
||||
|
||||
@property
|
||||
def static_app(self):
|
||||
"""The Starlette ``StaticFiles`` application for serving static assets."""
|
||||
if not hasattr(self, "_static_app"):
|
||||
assert self.static_dir is not None
|
||||
self._static_app = StaticFiles(directory=self.static_dir)
|
||||
return self._static_app
|
||||
|
||||
def before_request(self, websocket=False):
|
||||
"""Register a function to run before every request.
|
||||
|
||||
If the hook sets ``resp.status_code``, the route handler is skipped
|
||||
and the response is sent immediately (short-circuiting).
|
||||
|
||||
:param websocket: If ``True``, register as a WebSocket before-request hook instead of HTTP.
|
||||
|
||||
Usage::
|
||||
|
||||
@api.before_request()
|
||||
def check_auth(req, resp):
|
||||
if "Authorization" not in req.headers:
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "unauthorized"}
|
||||
|
||||
""" # noqa: E501
|
||||
|
||||
def decorator(f):
|
||||
self.router.before_request(f, websocket=websocket)
|
||||
return f
|
||||
@@ -180,6 +223,21 @@ class API:
|
||||
return decorator
|
||||
|
||||
def add_middleware(self, middleware_cls, **middleware_config):
|
||||
"""Add ASGI middleware to the application.
|
||||
|
||||
Middleware wraps the entire application and can inspect or modify
|
||||
every request and response. Middleware is applied in reverse order —
|
||||
the last middleware added runs first.
|
||||
|
||||
:param middleware_cls: A Starlette-compatible middleware class.
|
||||
:param middleware_config: Keyword arguments passed to the middleware constructor.
|
||||
|
||||
Usage::
|
||||
|
||||
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||||
api.add_middleware(HTTPSRedirectMiddleware)
|
||||
|
||||
"""
|
||||
self.app = middleware_cls(self.app, **middleware_config)
|
||||
|
||||
def exception_handler(self, exception_cls):
|
||||
@@ -501,6 +559,10 @@ class API:
|
||||
uvicorn.run(self, host=address, port=port, **options)
|
||||
|
||||
def run(self, **kwargs):
|
||||
"""Run the application. Shorthand for :meth:`serve` that inherits the ``debug`` setting.
|
||||
|
||||
:param kwargs: Keyword arguments passed through to :meth:`serve`.
|
||||
"""
|
||||
if "debug" not in kwargs:
|
||||
kwargs.update({"debug": self.debug})
|
||||
self.serve(**kwargs)
|
||||
|
||||
@@ -9,7 +9,33 @@ __all__ = ["BackgroundQueue"]
|
||||
|
||||
|
||||
class BackgroundQueue:
|
||||
"""A queue for running tasks in background threads.
|
||||
|
||||
Uses a ``ThreadPoolExecutor`` sized to the number of CPUs. Access it
|
||||
via ``api.background``.
|
||||
|
||||
Usage::
|
||||
|
||||
# As a decorator — fire and forget
|
||||
@api.background.task
|
||||
def send_email(to, subject):
|
||||
...
|
||||
|
||||
send_email("user@example.com", "Hello")
|
||||
|
||||
# Direct submission
|
||||
future = api.background.run(send_email, "user@example.com", "Hello")
|
||||
|
||||
# As a callable (supports async functions)
|
||||
await api.background(send_email, "user@example.com", "Hello")
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, n=None):
|
||||
"""Create a new background queue.
|
||||
|
||||
:param n: Number of worker threads. Defaults to CPU count.
|
||||
"""
|
||||
if n is None:
|
||||
n = multiprocessing.cpu_count()
|
||||
|
||||
@@ -18,11 +44,24 @@ class BackgroundQueue:
|
||||
self.results = []
|
||||
|
||||
def run(self, f, *args, **kwargs):
|
||||
"""Submit a function to run in a background thread.
|
||||
|
||||
:param f: The function to run.
|
||||
:returns: A ``concurrent.futures.Future`` for the result.
|
||||
"""
|
||||
f = self.pool.submit(f, *args, **kwargs)
|
||||
self.results.append(f)
|
||||
return f
|
||||
|
||||
def task(self, f):
|
||||
"""Decorator that wraps a function to run in the background thread pool.
|
||||
|
||||
The decorated function returns a ``Future`` instead of blocking.
|
||||
Exceptions are printed to stderr via traceback.
|
||||
|
||||
:param f: The function to wrap.
|
||||
"""
|
||||
|
||||
def on_future_done(fs):
|
||||
try:
|
||||
fs.result()
|
||||
|
||||
+79
-10
@@ -49,6 +49,12 @@ class CaseInsensitiveDict(dict):
|
||||
|
||||
|
||||
class QueryDict(dict):
|
||||
"""A dictionary for query string parameters that handles multi-value keys.
|
||||
|
||||
Single-value access returns the last value for a key. Use :meth:`get_list`
|
||||
to retrieve all values for a multi-value parameter.
|
||||
"""
|
||||
|
||||
def __init__(self, query_string):
|
||||
self.update(parse_qs(query_string))
|
||||
|
||||
@@ -117,6 +123,13 @@ class QueryDict(dict):
|
||||
|
||||
|
||||
class Request:
|
||||
"""An HTTP request, passed to each view as the first argument.
|
||||
|
||||
Provides access to headers, cookies, query parameters, the request body,
|
||||
session data, and more. Most properties are synchronous; reading the body
|
||||
(via :attr:`content`, :attr:`text`, or :meth:`media`) requires ``await``.
|
||||
"""
|
||||
|
||||
__slots__ = [
|
||||
"_starlette",
|
||||
"formats",
|
||||
@@ -153,6 +166,7 @@ class Request:
|
||||
|
||||
@property
|
||||
def mimetype(self):
|
||||
"""The MIME type of the request body, from the ``Content-Type`` header."""
|
||||
return self.headers.get("Content-Type", "")
|
||||
|
||||
@property
|
||||
@@ -270,6 +284,7 @@ class Request:
|
||||
|
||||
@property
|
||||
def is_secure(self):
|
||||
"""``True`` if the request was made over HTTPS."""
|
||||
return self.url.scheme == "https"
|
||||
|
||||
def accepts(self, content_type):
|
||||
@@ -315,6 +330,22 @@ def content_setter(mimetype):
|
||||
|
||||
|
||||
class Response:
|
||||
"""An HTTP response, passed to each view as the second argument.
|
||||
|
||||
Mutate this object to control what gets sent back to the client. Set
|
||||
:attr:`text`, :attr:`html`, :attr:`media`, or :attr:`content` to define
|
||||
the body. Use :attr:`headers` and :meth:`set_cookie` to control metadata.
|
||||
|
||||
:var text: Set the response body as plain text (sets ``Content-Type: text/plain``).
|
||||
:var html: Set the response body as HTML (sets ``Content-Type: text/html``).
|
||||
:var media: Set a Python object (dict, list) to be serialized as JSON (or negotiated format).
|
||||
:var content: Set the raw response body as bytes.
|
||||
:var status_code: The HTTP status code (e.g. ``200``, ``404``). Defaults to ``200`` if not set.
|
||||
:var headers: A dict of response headers.
|
||||
:var cookies: A ``SimpleCookie`` holding cookies to set on the response.
|
||||
:var session: A dict of session data. Changes are persisted in a signed cookie.
|
||||
""" # noqa: E501
|
||||
|
||||
__slots__ = [
|
||||
"req",
|
||||
"status_code",
|
||||
@@ -334,23 +365,34 @@ class Response:
|
||||
|
||||
def __init__(self, req, *, formats):
|
||||
self.req = req
|
||||
#: The HTTP Status Code to use for the Response.
|
||||
self.status_code: int | None = None
|
||||
self.content = None #: A bytes representation of the response body.
|
||||
self.content = None
|
||||
self.mimetype = None
|
||||
self.encoding = DEFAULT_ENCODING
|
||||
self.media = None #: A Python object that will be content-negotiated and
|
||||
#: sent back to the client. Typically, in JSON formatting.
|
||||
self.media = None
|
||||
self._stream = None
|
||||
self.headers = {} #: A Python dictionary of ``{key: value}``,
|
||||
#: representing the headers of the response.
|
||||
self.headers = {}
|
||||
self.formats = formats
|
||||
self.cookies: SimpleCookie = SimpleCookie() #: The cookies set in the Response
|
||||
self.session = (
|
||||
req.session
|
||||
) #: The cookie-based session data, in dict form, to add to the Response.
|
||||
self.cookies: SimpleCookie = SimpleCookie()
|
||||
self.session = req.session
|
||||
|
||||
def stream(self, func, *args, **kwargs):
|
||||
"""Set up a streaming response from an async generator function.
|
||||
|
||||
The generator yields chunks of bytes that are sent to the client
|
||||
as they are produced, without buffering the full response in memory.
|
||||
|
||||
Usage::
|
||||
|
||||
@api.route("/stream")
|
||||
async def stream_data(req, resp):
|
||||
@resp.stream
|
||||
async def body():
|
||||
for i in range(10):
|
||||
yield f"chunk {i}\\n".encode()
|
||||
|
||||
:param func: An async generator function that yields response chunks.
|
||||
"""
|
||||
assert inspect.isasyncgenfunction(func)
|
||||
|
||||
self._stream = functools.partial(func, *args, **kwargs)
|
||||
@@ -451,6 +493,12 @@ class Response:
|
||||
self.mimetype = guessed or "application/octet-stream"
|
||||
|
||||
def redirect(self, location, *, set_text=True, status_code=HTTP_301):
|
||||
"""Redirect the client to a different URL.
|
||||
|
||||
:param location: The URL to redirect to.
|
||||
:param set_text: If ``True``, set a default redirect message as the body.
|
||||
:param status_code: The HTTP status code (default ``301``).
|
||||
"""
|
||||
self.status_code = status_code
|
||||
if set_text:
|
||||
self.text = f"Redirecting to: {location}"
|
||||
@@ -496,6 +544,25 @@ class Response:
|
||||
secure=False,
|
||||
httponly=True,
|
||||
):
|
||||
"""Set a cookie on the response with full control over directives.
|
||||
|
||||
:param key: The cookie name.
|
||||
:param value: The cookie value.
|
||||
:param expires: Expiration date string (e.g. ``"Thu, 01 Jan 2026 00:00:00 GMT"``).
|
||||
:param path: URL path the cookie applies to (default ``"/"``).
|
||||
:param domain: Domain the cookie is valid for.
|
||||
:param max_age: Maximum age in seconds before the cookie expires.
|
||||
:param secure: If ``True``, cookie is only sent over HTTPS.
|
||||
:param httponly: If ``True`` (default), cookie is inaccessible to JavaScript.
|
||||
|
||||
Usage::
|
||||
|
||||
resp.set_cookie(
|
||||
"token", value="abc123",
|
||||
max_age=3600, secure=True, httponly=True,
|
||||
)
|
||||
|
||||
"""
|
||||
self.cookies[key] = value
|
||||
morsel = self.cookies[key]
|
||||
if expires is not None:
|
||||
@@ -534,10 +601,12 @@ class Response:
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
"""``True`` if the status code is in the 2xx range (success)."""
|
||||
return 200 <= self.status_code_safe < 300
|
||||
|
||||
@property
|
||||
def status_code_safe(self) -> int:
|
||||
"""Return the status code, raising ``RuntimeError`` if it hasn't been set."""
|
||||
if self.status_code is None:
|
||||
raise RuntimeError("HTTP status code has not been defined")
|
||||
return self.status_code
|
||||
|
||||
@@ -62,6 +62,12 @@ class BaseRoute:
|
||||
|
||||
|
||||
class Route(BaseRoute):
|
||||
"""An HTTP route that maps a URL pattern to an endpoint.
|
||||
|
||||
Supports path parameters with type convertors (``{id:int}``, ``{slug:str}``,
|
||||
``{pk:uuid}``, ``{value:float}``, ``{rest:path}``).
|
||||
"""
|
||||
|
||||
def __init__(self, route, endpoint, *, before_request=False, methods=None):
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
@@ -197,6 +203,8 @@ class Route(BaseRoute):
|
||||
|
||||
|
||||
class WebSocketRoute(BaseRoute):
|
||||
"""A WebSocket route that maps a URL pattern to a WebSocket handler."""
|
||||
|
||||
def __init__(self, route, endpoint, *, before_request=False):
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
@@ -253,6 +261,12 @@ class WebSocketRoute(BaseRoute):
|
||||
|
||||
|
||||
class Router:
|
||||
"""The core router that dispatches incoming requests to matching routes.
|
||||
|
||||
Handles route matching, before/after request hooks, lifespan events,
|
||||
and mounted sub-applications.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, routes=None, default_response=None, before_requests=None, lifespan=None
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user