mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Add tutorials: REST API, SQLAlchemy, Flask migration. Rewrite CLI and API ref.
Three new tutorial pages: - Building a REST API: full CRUD with Pydantic validation, from scratch - Using SQLAlchemy: async engine, lifespan setup, CRUD with ORM - Migrating from Flask: concept mapping, quick reference table, gradual migration via app mounting Also rewritten: - CLI docs: cleaner, more concise - API reference: added prose descriptions for each section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+38
-13
@@ -1,35 +1,60 @@
|
||||
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
|
||||
-----------------
|
||||
Status Code Helpers
|
||||
-------------------
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_100
|
||||
Convenience functions for checking which category a status code falls
|
||||
into. Useful in middleware and after-request hooks.
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_200
|
||||
.. autofunction:: responder.status_codes.is_100
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_300
|
||||
.. autofunction:: responder.status_codes.is_200
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_400
|
||||
.. autofunction:: responder.status_codes.is_300
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_500
|
||||
.. 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
|
||||
|
||||
@@ -100,6 +100,14 @@ Python 3.9 and above. That's it.
|
||||
api
|
||||
cli
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Tutorials
|
||||
|
||||
tutorial-rest
|
||||
tutorial-sqlalchemy
|
||||
tutorial-flask
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Project
|
||||
|
||||
@@ -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,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.
|
||||
Reference in New Issue
Block a user