diff --git a/docs/source/api.rst b/docs/source/api.rst index 51648dd..fc7851f 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -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 diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 47f3e7e..d7a0447 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -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 `_:: + + $ 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 diff --git a/docs/source/index.rst b/docs/source/index.rst index aa907a1..7455866 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -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 diff --git a/docs/source/tutorial-flask.rst b/docs/source/tutorial-flask.rst new file mode 100644 index 0000000..d6bf14d --- /dev/null +++ b/docs/source/tutorial-flask.rst @@ -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 ````. Responder uses ``{curly_braces}`` +with the same type convertor idea: + +Flask:: + + @app.route("/users/") + 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/") + 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. diff --git a/docs/source/tutorial-rest.rst b/docs/source/tutorial-rest.rst new file mode 100644 index 0000000..ec95f4b --- /dev/null +++ b/docs/source/tutorial-rest.rst @@ -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 `_ 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. diff --git a/docs/source/tutorial-sqlalchemy.rst b/docs/source/tutorial-sqlalchemy.rst new file mode 100644 index 0000000..3bdcabd --- /dev/null +++ b/docs/source/tutorial-sqlalchemy.rst @@ -0,0 +1,254 @@ +Using SQLAlchemy +================ + +Most real web applications need a database. This guide shows how to +integrate `SQLAlchemy `_ 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 `_ for database + migrations — it tracks schema changes over time so you can evolve + your database without losing data.