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.
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.
Models#
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:
Post(title="Hello", content="World", published=False) # correct
The id column is excluded from __init__ automatically — you do not pass it.
Schemas#
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:
{ "id": 1 }
So a POST /comments/ request body looks like:
{
"content": "Great post!",
"post_id": {"id": 1}
}
And a response looks like:
{
"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 for more detail, including list relations.
App setup#
@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 |
|---|---|---|
|
|
List all posts |
|
|
Create a post |
|
|
Get one post |
|
|
Update a post |
|
|
Delete a post |
The prefix value must include the leading slash (e.g. "/posts", not "posts").
To disable specific endpoints, set exclude_routes:
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:
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
ReadOnlyfields appear in responses but are ignored on create and update.WriteOnlyfields 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:
GET /posts/?filter[published]=true
GET /posts/?sort=-id
GET /posts/?limit=10&offset=0
V2 — direct field names with operator suffixes:
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:
fr.set_query_modifier_version(fr.QueryModifierVersion.V2)
To switch per-view:
class PostView(fr.AsyncRestView):
prefix = "/posts"
model = Post
schema = PostSchema
query_modifier_version = fr.QueryModifierVersion.V2
See How-To: Filter, Sort, and Paginate Lists 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.
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:
# conftest.py
pytest_plugins = ["fastapi_restly.pytest_fixtures"]
# 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 and Pytest Fixtures 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 — override hooks, add custom routes, and share behaviour with base classes
Auto-Generated Schemas — skip writing schemas for simple models
How-To: Filter, Sort, and Paginate Lists — full filter and sort reference
How-To: Foreign Keys with IDSchema — list relations and nested objects
How-To: Testing — savepoint isolation and test fixtures