Tutorial Part 1: Generated CRUD#
Part 1 builds a small blog API with two related models and shows the most common
FastAPI-Restly patterns: models, schemas, and a view class that generates full CRUD
endpoints. It assumes you have read Getting Started and
installed fastapi-restly[standard] with the aiosqlite driver.
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 PostRead(fr.IDSchema):
title: str
content: str
published: bool
class CommentRead(fr.IDSchema):
content: str
post_id: fr.IDRef[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.
Foreign keys with IDRef#
post_id: fr.IDRef[Post] declares a foreign-key reference. The wire format is
the raw id:
1
So a POST /comments/ request body looks like:
{
"content": "Great post!",
"post_id": 1
}
And a response looks like:
{
"id": 7,
"content": "Great post!",
"post_id": 1
}
The _id suffix on the field name is what triggers this behaviour: the view machinery
stores the id 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 existence check,
declare post_id: int in your schema instead.
See Work with Foreign Keys Using IDRef for more detail, including list relations and nested relationship objects.
App setup#
@asynccontextmanager
async def lifespan(_app: FastAPI):
await fr.db.async_create_all(fr.IDBase) # IDBase is the models' base above
yield
app = FastAPI(lifespan=lifespan)
@fr.include_view(app)
class PostView(fr.AsyncRestView):
prefix = "/posts"
model = Post
schema = PostRead
@fr.include_view(app)
class CommentView(fr.AsyncRestView):
prefix = "/comments"
model = Comment
schema = CommentRead
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.
Run it#
fastapi dev main.py
Open http://127.0.0.1:8000/docs — both resources are listed with their request and response schemas, ready to try from the browser.
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 = PostRead
exclude_routes = (fr.ViewRoute.DELETE,) # disables DELETE /posts/{id}
Read-only and write-only fields#
Let’s give Post an author token that clients send on creation but never see
back, and a view count that is server-maintained and must not be writable.
Add the columns to the model:
class Post(fr.IDBase):
title: Mapped[str]
content: Mapped[str]
published: Mapped[bool] = mapped_column(default=False)
author_token: Mapped[str] = mapped_column(default="")
view_count: Mapped[int] = mapped_column(default=0)
and mark them in the schema:
class PostRead(fr.IDSchema):
title: str
content: str
published: bool
author_token: fr.WriteOnly[str] = "" # accepted on input, stripped from responses
view_count: fr.ReadOnly[int] = 0 # returned in responses, ignored on input
ReadOnlyfields appear in responses but are ignored on create and update — the server owns them.WriteOnlyfields are accepted on create and update but stripped from every generated response.
id on IDSchema is already ReadOnly, which is why it appears in responses without
being part of the create/update body.
Querying lists#
List endpoints accept filtering, sorting, and pagination through URL query parameters. Filters use direct field names with optional operator suffixes:
GET /posts/?published=true&sort=-id&page=1&page_size=10
GET /posts/?title__icontains=hello
GET /posts/?created_at__gte=2024-01-01&created_at__lt=2025-01-01
See Filter, Sort, and Paginate Lists for the full list of 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, install the testing extra (pip install "fastapi-restly[testing]");
pytest then auto-loads Restly’s fixtures. The restly_client fixture used below wraps
each test in a database savepoint, so changes never persist between tests:
# test_posts.py
def test_create_post(restly_client):
resp = restly_client.post("/posts/", json={"title": "Hi", "content": "...", "published": False})
assert resp.json()["title"] == "Hi"
# Database changes are rolled back automatically after this test
See Testing for the full setup and savepoint details.
Nested Schemas#
Response schemas may nest related objects (Restly eager-loads and serializes
them); create/update payloads may not — inputs map to model attributes or use
*_id: IDRef[Model]. Details:
Work with Foreign Keys Using IDRef.
The complete file#
Everything this page built, as one runnable main.py (including the
read-only/write-only columns added along the way):
from contextlib import asynccontextmanager
import fastapi_restly as fr
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)
author_token: Mapped[str] = mapped_column(default="")
view_count: Mapped[int] = mapped_column(default=0)
class Comment(fr.IDBase):
content: Mapped[str]
post_id: Mapped[int] = mapped_column(ForeignKey("post.id"))
class PostRead(fr.IDSchema):
title: str
content: str
published: bool
author_token: fr.WriteOnly[str] = ""
view_count: fr.ReadOnly[int] = 0
class CommentRead(fr.IDSchema):
content: str
post_id: fr.IDRef[Post]
@asynccontextmanager
async def lifespan(_app: FastAPI):
await fr.db.async_create_all(fr.IDBase)
yield
app = FastAPI(lifespan=lifespan)
@fr.include_view(app)
class PostView(fr.AsyncRestView):
prefix = "/posts"
model = Post
schema = PostRead
@fr.include_view(app)
class CommentView(fr.AsyncRestView):
prefix = "/comments"
model = Comment
schema = CommentRead
Next steps#
Part 2: Customizing Views — override handlers, add custom routes, and share behaviour with base classes
Auto-Generated Schemas — skip writing schemas for simple models
Filter, Sort, and Paginate Lists — full filter and sort reference
Work with Foreign Keys Using IDRef — reference related rows by id
Testing — savepoint isolation and test fixtures
Examples — complete sample apps that extend these patterns