Build a Blog API#

This tutorial builds a small blog API with two related models. It uses FastAPI-Restly models, schemas, and view classes that inherit full CRUD endpoint methods. 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#

We begin with the data layer: the database connection and two SQLAlchemy models declared against IDBase, which provides an integer id primary key:

import os

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=os.environ.get(
        "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, so you do not pass it.

IDBase also maps a plain Mapped[datetime] column to DateTime(timezone=True). Treat these values as UTC instants. PostgreSQL enforces the timezone-aware column type, while SQLite returns naive datetime values. For an intentional timezone-free wall-clock field, opt out per column with mapped_column(DateTime()).

Schemas#

With the models in place, we can define the Pydantic schemas that shape the API’s requests and responses, one per model, each extending fr.IDSchema:

class PostSchema(fr.IDSchema):
    title: str
    content: str
    published: bool


class CommentSchema(fr.IDSchema):
    content: str
    post_id: fr.MustExist[int, 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 MustExist#

post_id: fr.MustExist[int, Post] declares a checked foreign-key column. 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
}

Declaring the field as fr.MustExist[int, Post] is what triggers this behaviour: the view machinery keeps the plain id in the post_id column and validates that a Post with that id exists (returning 404 if not). Restly matches the field name to a mapped column or relationship via the model’s mapper, not to an _id suffix, so the FK column can be named anything.

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 and Relationships for more detail, including list relations and nested relationship objects.

App setup#

Models and schemas meet in the view layer. We create the FastAPI app, then register one view class per resource; each view names its URL prefix, model, and schema:

@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 = 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 async_create_all.

Run it#

The application is complete, so we can start 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.

Default CRUD routes#

Each view inherits five endpoint methods. With prefix = "/posts", include_view registers them at these paths:

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:

class PostView(fr.AsyncRestView):
    prefix = "/posts"
    model = Post
    schema = PostSchema
    exclude_routes = (fr.ViewRoute.DELETE,)  # disables DELETE /posts/{id}

Read-only and write-only fields#

So far every schema field travels in both directions. In practice some fields belong to only one. For example we will 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. First we 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 PostSchema(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
  • ReadOnly fields appear in responses but are ignored on create and update; the server owns them.

  • WriteOnly fields are accepted on create and update but stripped from every CRUD response.

id on IDSchema is already ReadOnly, which is why it appears in responses without being part of the create/update body.

Where each marker takes effect, including on schemas used outside a view, is covered in ReadOnly and WriteOnly.

Querying lists#

The default 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

# Enter it: the app's lifespan, which creates the tables above, runs on entry.
with RestlyTestClient(app) as client:
    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. Point the suite at a test database in conftest.py. Setting the environment variable before the import works because this tutorial’s single file configures Restly when it is imported. An application built by a factory receives its test database explicitly instead, as shown in Test APIs with RestlyTestClient and Fixtures:

# conftest.py
import os

os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test.db"

import fastapi_restly as fr

from main import app  # noqa: E402

fr.testing.configure_tests(
    app=app,
    base=fr.IDBase,
    create_all=True,
)

Tests then need nothing but the client, and everything they write is rolled back when they finish:

# 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#

The view’s schema may nest related objects, and Restly eager-loads and serializes them for you. Create and update payloads may not nest: inputs map to model attributes or use *_id: MustExist[int, Model]. See Work with Foreign Keys and Relationships for the details.

The complete file#

Here is everything this page built as one runnable main.py, including the read-only and write-only columns added along the way:

import os
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=os.environ.get(
        "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 PostSchema(fr.IDSchema):
    title: str
    content: str
    published: bool
    author_token: fr.WriteOnly[str] = ""
    view_count: fr.ReadOnly[int] = 0


class CommentSchema(fr.IDSchema):
    content: str
    post_id: fr.MustExist[int, 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 = PostSchema


@fr.include_view(app)
class CommentView(fr.AsyncRestView):
    prefix = "/comments"
    model = Comment
    schema = CommentSchema

Next steps#

Continue with Customize the Blog API, which overrides business methods, adds custom routes, and shares behaviour with base classes. These pages cover the topics from this tutorial in more detail: