# Tutorial This tutorial builds a small blog API with two related models and shows the most common FastAPI-Restly patterns. It assumes you have already read [Getting Started](getting_started.md). This tutorial uses explicit schemas for clarity. For faster scaffolding, you can omit `schema = ...` on a view and let FastAPI-Restly auto-generate it from the model. See [Auto-Generated Schemas](technical_details.md#auto-generated-schemas). --- ## Models ```python import fastapi_restly as fr from contextlib import asynccontextmanager from fastapi import FastAPI from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column fr.configure(async_database_url="sqlite+aiosqlite:///blog.db") class Post(fr.IDBase): title: Mapped[str] content: Mapped[str] published: Mapped[bool] = mapped_column(default=False) class Comment(fr.IDBase): content: Mapped[str] post_id: Mapped[int] = mapped_column(ForeignKey("post.id")) ``` ### Table naming `IDBase` automatically derives table names from the class name using snake_case conversion: `Post` becomes `"post"`, `Comment` becomes `"comment"`, `BlogPost` would become `"blog_post"`. This is why `ForeignKey("post.id")` is the correct reference for `Post.id`. ### IDBase and dataclass semantics `IDBase` uses SQLAlchemy's `MappedAsDataclass`. Always pass fields as keyword arguments: ```python Post(title="Hello", content="World", published=False) # correct ``` The `id` column is excluded from `__init__` automatically — you do not pass it. --- ## Schemas ```python class PostSchema(fr.IDSchema): title: str content: str published: bool class CommentSchema(fr.IDSchema): content: str post_id: fr.IDSchema[Post] ``` ### What IDSchema provides `fr.IDSchema` is a Pydantic base class that adds a read-only `id` field to your schema. Because `id` is `ReadOnly`, it appears in responses but is ignored when creating or updating records. You do not need to declare `id` yourself. ### IDSchema as a field type `post_id: fr.IDSchema[Post]` is a special convention for foreign-key fields. Instead of sending a plain integer, the API accepts and returns a small envelope: ```json { "id": 1 } ``` So a `POST /comments/` request body looks like: ```json { "content": "Great post!", "post_id": {"id": 1} } ``` And a response looks like: ```json { "id": 7, "content": "Great post!", "post_id": {"id": 1} } ``` The `_id` suffix on the field name is what triggers this behaviour: the view machinery extracts the integer from `{"id": N}` and stores it in the `post_id` column, and it also validates that a `Post` with that `id` exists (returning 404 if not). If you prefer a plain `int` field and want to skip the envelope and the existence check, declare `post_id: int` in your schema instead. See [How-To: Work with Foreign Keys Using IDSchema](howto_relationship_idschema.md) for more detail, including list relations. --- ## App setup ```python @asynccontextmanager async def lifespan(_app: FastAPI): engine = fr.get_async_engine() async with engine.begin() as conn: await conn.run_sync(fr.DataclassBase.metadata.create_all) yield app = FastAPI(lifespan=lifespan) @fr.include_view(app) class PostView(fr.AsyncRestView): prefix = "/posts" model = Post schema = PostSchema @fr.include_view(app) class CommentView(fr.AsyncRestView): prefix = "/comments" model = Comment schema = CommentSchema ``` Tables are created inside a FastAPI `lifespan` context manager so they are initialised after the event loop starts. This is safe with both `uvicorn` and testing tools. For production projects, use Alembic migrations instead of `create_all`. --- ## Generated endpoints For each view, FastAPI-Restly generates five endpoints. With `prefix = "/posts"`: | Method | Path | Action | |----------|---------------|----------------| | `GET` | `/posts/` | List all posts | | `POST` | `/posts/` | Create a post | | `GET` | `/posts/{id}` | Get one post | | `PATCH` | `/posts/{id}` | Update a post | | `DELETE` | `/posts/{id}` | Delete a post | The `prefix` value must include the leading slash (e.g. `"/posts"`, not `"posts"`). To disable specific endpoints, set `exclude_routes`: ```python class PostView(fr.AsyncRestView): prefix = "/posts" model = Post schema = PostSchema exclude_routes = ("delete",) # disables DELETE /posts/{id} ``` --- ## Read-only and write-only fields Say you want to add an author token that is stored on creation but never returned, and a `slug` field that is computed server-side and must not be writable: ```python class PostSchema(fr.IDSchema): title: str content: str published: bool author_token: fr.WriteOnly[str] # accepted on create/update, hidden in responses slug: fr.ReadOnly[str] # returned in responses, ignored on create/update ``` - `ReadOnly` fields appear in responses but are ignored on create and update. - `WriteOnly` fields are accepted on create and update but never returned in responses. `id` on `IDSchema` is already `ReadOnly`, which is why it appears in responses without being part of the create/update body. --- ## Querying lists FastAPI-Restly supports two query parameter styles. Each view uses one style, configured globally or per-view with `query_modifier_version`. **V1 (default) — JSONAPI-style filters:** ```text GET /posts/?filter[published]=true GET /posts/?sort=-id GET /posts/?limit=10&offset=0 ``` **V2 — direct field names with operator suffixes:** ```text GET /posts/?published=true&order_by=-id&page=1&page_size=10 GET /posts/?title__contains=hello ``` V1 and V2 are separate systems; you cannot mix their parameters in the same request. To switch globally: ```python fr.set_query_modifier_version(fr.QueryModifierVersion.V2) ``` To switch per-view: ```python class PostView(fr.AsyncRestView): prefix = "/posts" model = Post schema = PostSchema query_modifier_version = fr.QueryModifierVersion.V2 ``` See [How-To: Filter, Sort, and Paginate Lists](howto_query_modifiers.md) for the full list of filters and operators. --- ## Testing FastAPI-Restly provides `RestlyTestClient`, a thin wrapper around FastAPI's `TestClient` that asserts sensible default status codes and gives clear failure messages. ```python from fastapi_restly.testing import RestlyTestClient client = RestlyTestClient(app) post = client.post("/posts/", json={"title": "Hello", "content": "World", "published": False}) # Automatically asserts status 201 item = client.get(f"/posts/{post.json()['id']}") # Automatically asserts status 200 ``` For test isolation, use the `async_session` or `session` pytest fixtures. These wrap each test in a database savepoint so changes never persist between tests: ```python # conftest.py pytest_plugins = ["fastapi_restly.pytest_fixtures"] ``` ```python # test_posts.py def test_create_post(client): resp = client.post("/posts/", json={"title": "Hi", "content": "...", "published": False}) assert resp.json()["title"] == "Hi" # Database changes are rolled back automatically after this test ``` See [How-To: Testing](howto_testing.md) and [Pytest Fixtures](pytest_fixtures.md) for the full setup and savepoint details. --- ## Nested Schemas Nested schemas are supported for **responses** and relation filtering. If a response schema includes nested related objects, Restly eagerly loads those relationships and serializes the nested payloads, including aliases. Nested schemas are **not** supported for create/update payloads. `POST` and `PATCH` inputs must still map directly to model attributes or use the `*_id: IDSchema[Model]` pattern for foreign keys. If you need a nested request shape, flatten it in the schema or override `on_create` / `on_update` and transform the payload yourself. --- ## Next steps - **[Part 2: Customizing Views](tutorial_customizing.md)** — override hooks, add custom routes, and share behaviour with base classes - [Auto-Generated Schemas](technical_details.md#auto-generated-schemas) — skip writing schemas for simple models - [How-To: Filter, Sort, and Paginate Lists](howto_query_modifiers.md) — full filter and sort reference - [How-To: Foreign Keys with IDSchema](howto_relationship_idschema.md) — list relations and nested objects - [How-To: Testing](howto_testing.md) — savepoint isolation and test fixtures - [API Reference](api_reference.md)