CRUD Routes#

AsyncRestView and RestView give one SQLAlchemy model five CRUD routes. A subclass sets the URL prefix and the model, and it can also set the schemas. include_view() registers the routes:

@fr.include_view(app)
class UserView(fr.AsyncRestView):
    prefix = "/users"
    model = User

UserView now serves requests to list, create, get, update and delete users under /users. Build a Blog API shows the app, the database configuration and the model around such a view, in one file that runs. include_view also works as a direct call; see Register a view.

Each route runs an endpoint method, a final handler, and a business method or, for a read, a final load. To change what a route does, you override the endpoint method or the business method, or set what the load reads; Which method do I override maps each change to its method.

Choose async or sync#

Use AsyncRestView for a new application, unless the application already uses synchronous SQLAlchemy. It gets an AsyncSession through fr.AsyncSessionDep, and the methods you override are async def methods.

Use RestView with synchronous SQLAlchemy sessions, or with libraries that only work synchronously. It gets a Session through fr.SessionDep, and the methods you override are plain def methods.

Apart from this, the two classes are the same: the same routes, the same configuration and the same methods to override. You choose for each view, so one application can use both. Database & Configuration shows how to configure the sessions. To give one view another session, see Use another database on one view. The examples on this page use AsyncRestView.

The default routes#

For prefix = "/users", include_view registers these routes:

Request

Endpoint method

Input

Response

Status

GET /users

get_many_endpoint

Filter, sort and page parameters

A page of objects in a data envelope

200

POST /users

create_endpoint

schema_create

schema_response

201

GET /users/{id}

get_one_endpoint

The id in the path

schema_response

200

PATCH /users/{id}

update_endpoint

schema_update

schema_response

200

DELETE /users/{id}

delete_endpoint

The id in the path

Empty body

204

The main path of the list is /users, without a slash at the end. Restly also accepts /users/, as a route that OpenAPI does not show.

GET /users takes the filter, sort and page parameters that Filtering, Sorting & Pagination defines, and it is paginated by default. Responses shows the response bodies and how to change the data envelope.

A row that does not exist returns 404, and so does a row that the view’s scope hides. Each write commits before Restly builds the response, and a read does not commit; see What commits, and when.

Important

Restly does not authenticate requests, and by default it allows every action on every route. Add authentication with FastAPI dependencies; see Run a dependency on every route. Then decide per action who may do what in authorize(); see Authorization.

Item paths and static routes#

The paths of the built-in GET, PATCH and DELETE routes for one row follow id_type. By default, Restly takes it from the model’s primary key. The PUT route of a React Admin view uses the same path.

ID type

Registered item path

Matching values

int

/{id:int}

Digits only, including 0; no minus sign

uuid.UUID

/{id:uuid}

UUIDs that Starlette’s converter accepts

Any other type

/{id}

Any single path segment, then FastAPI validation

With an integer or UUID item path, a static route such as /users/me still works when you register it after the view.

An untyped item path, such as the path for string ids, also matches /users/me. Register a static route like that before the item route.

Details

A value that does not match the converter returns 404, unless another route matches it. So a negative integer id does not match, even when the row exists. OpenAPI still shows the path as /users/{id}, with an integer or UUID parameter.

A route that you declare yourself keeps the path you give it. Restly adds the converter only to a path parameter annotated ViewID; see Name the view’s classes in a custom route. To accept negative integer ids, replace each item endpoint you need with an untyped path such as @fr.get("/{id}"), and annotate its id parameter as int; see Change the status code or headers of one route.

Set the model and the schemas#

A CRUD view sets two class attributes:

  • prefix: the URL prefix of every route of the view.

  • model: the SQLAlchemy mapped class that the routes read and write.

Normal models built on SQLAlchemy’s DeclarativeBase work. Restly’s model defaults, fr.ModelDefaults, are optional; see Models.

If the view does not declare schema, Restly generates the view’s schema from model when you register the view. From the view’s schema, it derives the request bodies and the response class: schema_create, schema_update and schema_response. Generate the schema or write it shows when to write the schema yourself, and Hide a field from requests or from responses shows which fields each class takes.

Configure a CRUD view#

This table maps each need to its setting:

You need

Configuration

Stable response fields

Declare schema

Other fields or validation on create or update

Declare schema_create or schema_update (Explicit write schemas)

Another type for the {id} path parameter

Set id_type (Item paths)

Fewer routes, such as a read-only view

Set include_routes (the routes to serve) or exclude_routes (the routes to leave out) (Exclude CRUD routes)

Hide rows from every read

Set scope, or default_scope on the model (Scopes)

No pagination

Set pagination to None, or to a NoPagination to choose its envelope (Filtering, Sorting & Pagination)

Other page limits, parameter names or list envelope

Set pagination to a NumberedPagination (Pagination settings)

Fewer filter or sort keys on a list

Set include_filters or include_sort_keys (Choose the keys of a list)

A list filter of your own

Decorate a method with fr.list_filter (Add a filter of your own)

A query key of your own next to the list params, not a filter

Declare it on the endpoint method or in a dependency (Accept extra query parameters)

OpenAPI metadata or dependencies on every route

Set tags, responses or dependencies (Share behavior with base views)

A route name, summary or operation ID

Override the endpoint method with a decorator of your own (OpenAPI)

Another session for one view

Override the session annotation (Database & Configuration)

View Reference lists the type and the default of each attribute.

Exclude CRUD routes#

Set exclude_routes to leave out some of the CRUD routes:

@fr.include_view(app)
class UserView(fr.AsyncRestView):
    prefix = "/users"
    model = User
    exclude_routes = [fr.ViewRoute.DELETE, fr.ViewRoute.UPDATE]

UserView now serves only GET /users, POST /users and GET /users/{id}.

Subclasses inherit exclude_routes. To make several views read-only, set it on a base view:

class ReadOnlyView(fr.AsyncRestView):
    exclude_routes = (fr.ViewRoute.CREATE, fr.ViewRoute.UPDATE, fr.ViewRoute.DELETE)


@fr.include_view(app)
class ProductView(ReadOnlyView):
    prefix = "/products"
    model = Product

ProductView only serves GET /products and GET /products/{id}.

A subclass that sets its own exclude_routes replaces the list of its base. It does not add to it. So repeat the entries of the base, or the subclass serves the write routes again: exclude_routes = (*ReadOnlyView.exclude_routes, fr.ViewRoute.GET_ONE).

exclude_routes leaves out only the routes that it names. So a subclass also serves the custom routes of its base, such as POST /tags/merge, and also a write route that someone adds to the base later. For a read-only copy of a view with custom routes, set include_routes instead. It names the routes to serve, and the view leaves out every other route:

@fr.include_view(app)
class TagView(fr.AsyncRestView):
    prefix = "/tags"
    model = Tag

    @fr.post("/merge")
    async def merge(self, source_id: int, target_id: int) -> None: ...


@fr.include_view(public_router)  # an APIRouter(prefix="/public")
class PublicTagView(TagView):
    include_routes = (fr.ViewRoute.GET_MANY, fr.ViewRoute.GET_ONE)

PublicTagView serves only GET /public/tags and GET /public/tags/{id}, also when TagView gets more routes later.

Details

Each entry of include_routes and exclude_routes is a fr.ViewRoute value, GET_MANY, GET_ONE, CREATE, UPDATE or DELETE, or the name of a route method, such as "delete_endpoint" or "merge". A name that is not a route of the view raises AttributeError when you register the view.

The default of include_routes, ("*",), is every route. A view serves a route when include_routes has it and exclude_routes does not. A subclass that sets its own include_routes replaces the list of its base.

The other way to make a read-only copy is a base view with the shared settings, subclassed twice. Then the copy does not inherit the custom routes of TagView:

class TagBase(fr.AsyncRestView):
    model = Tag


@fr.include_view(app)
class TagView(TagBase):
    prefix = "/tags"

    @fr.post("/merge")
    async def merge(self, source_id: int, target_id: int) -> None: ...


@fr.include_view(app)
class PublicTagView(TagBase):
    prefix = "/public/tags"
    exclude_routes = (fr.ViewRoute.CREATE, fr.ViewRoute.UPDATE, fr.ViewRoute.DELETE)

PublicTagView serves only the two read routes, also when TagView gets more routes later. But both views serve a route that TagBase gets later.

Nest a resource under its parent#

A nested resource is a child resource that the URL places under its parent, such as /projects/{project_id}/tasks. In Restly, it is a normal AsyncRestView with three additions: a path parameter in its prefix, a dependency that looks up the parent, and a scope that limits the rows to that parent. Every CRUD route keeps working, including pages, filters and sorting.

A flat view that filters on the foreign key, GET /tasks?project_id=17, needs no extra code; see Filter by a foreign key. Nest the view when the nested URL is part of your API.

Nest one level#

This example serves the tasks of a project at /projects/{project_id}/tasks:

import sqlalchemy as sa
from fastapi import FastAPI
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

import fastapi_restly as fr


class Base(fr.ModelDefaults, DeclarativeBase):
    pass


class Project(fr.IDMixin, Base):
    name: Mapped[str]
    archived: Mapped[bool] = mapped_column(default=False)


class Task(fr.IDMixin, Base):
    title: Mapped[str]
    project_id: Mapped[int] = mapped_column(sa.ForeignKey("project.id"), init=False)


class ProjectSchema(fr.IDSchema):
    name: str


class TaskSchema(fr.IDSchema):
    title: str
    project_id: fr.ReadOnly[int]


class Parent(fr.ContextNamespace):
    project_id: fr.ContextParam[int]


app = FastAPI()
fr.configure(app, async_database_url="sqlite+aiosqlite:///app.db")


@fr.include_view(app)
class ProjectView(fr.AsyncRestView):
    prefix = "/projects"
    model = Project
    schema = ProjectSchema
    scope = fr.where_clause(Project.archived.is_(False))


async def project_from_path(project_id: int, session: fr.AsyncSessionDep) -> int:
    stmt = fr.apply_clauses(
        sa.select(Project.id).where(Project.id == project_id),
        fr.resolve_scope(ProjectView),
    )
    found = await session.scalar(stmt)
    if found is None:
        raise fr.exc.NotFound(f"Project with id {project_id} was not found")
    return found


@fr.include_view(app)
class ProjectTaskView(fr.AsyncRestView):
    prefix = "/projects/{project_id}/tasks"
    model = Task
    schema = TaskSchema
    dependencies = [Parent.depends(project_id=project_from_path)]
    scope = fr.all_of(
        fr.resolve_scope(Task),
        fr.where_clause(Task.project_id == Parent.project_id),
    )

    async def create(self, data):
        task = await super().create(data)
        task.project_id = Parent.project_id()
        return task

ProjectTaskView differs from a flat view in these places:

  • prefix contains {project_id}, so every route of the view is under a project. Name the path parameter after the parent, as here. The item routes already use {id}, and FastAPI raises an error for a second {id} when you register the view.

  • project_from_path reads project_id from the path. FastAPI checks that it is an integer, and OpenAPI lists it on every route. The function returns 404 when the project does not exist or when ProjectView hides it.

  • Parent.depends binds the project id for the request. Parent is a fr.ContextNamespace, like Current in Request Context. One Parent serves every nested view in the app.

  • scope limits every read to the tasks of that project: the list, its total count, get one, update and delete. A task of another project returns 404. A view’s scope replaces the model’s default scope (Give one view other rows), so fr.resolve_scope(Task) adds the default scope back.

  • create sets the project id; see Create a child.

The routes then answer like this:

GET    /projects/1/tasks?sort=-title   the tasks of project 1, by title, descending
POST   /projects/1/tasks               a new task in project 1
GET    /projects/2/tasks/7             404 when task 7 is in another project
GET    /projects/99/tasks              404 when project 99 does not exist
GET    /projects/x/tasks               422, x is not an integer

Details

A sync fr.RestView works in the same way. Write the lookups as plain functions that take fr.SessionDep, and write the create override as a plain def without await.

Create a child#

The project id comes from the path, not from the request body. TaskSchema marks project_id as fr.ReadOnly, so the generated create and update schemas leave it out. The create override sets it from Parent after super().create() builds the task. The INSERT has the id, because Restly saves after create returns.

Declare the foreign key with init=False. super().create() builds the task from the request body, which has no project_id. Without init=False, the model requires project_id in its constructor, and the create returns 500.

Keep the parent id out of every write schema of the nested view. If TaskSchema declared a writable project_id, the generated update schema would include it. An explicit write schema writes every field it declares. In both cases, a PATCH request could move the task to a project that the URL never named, and the lookup would not check it.

Details

Set the parent id in the view, not in a column default that reads Parent.project_id. Only the nested route knows the parent, so the value belongs in create; see Values every write path sets for the difference. With such a default, creating a task anywhere else fails, for example in a flat view or a script, because nothing binds Parent there.

Which parents a client can reach#

The lookup decides which parents a client can reach. project_from_path applies the scope of ProjectView through fr.resolve_scope. So a project that GET /projects/{id} hides also returns 404 here, with all of its tasks. In the example, that is an archived project.

Without this check, POST /projects/{project_id}/tasks creates a task in any project whose id a client can guess.

The lookup does not run the authorize method of ProjectView. Put rules about which rows a user may see in a scope, so that both views apply them. The nested view still runs its own authorize on each of its routes.

Nest more than one level#

For more levels, build a chain of base views, one for each level. Each base adds its segment to prefix and its lookup to dependencies. Both add up from base to subclass; see Concatenate URL prefixes. This example serves tasks at /companies/{company_id}/projects/{project_id}/tasks. It is a separate app. It uses the imports, app, Base, Task and TaskSchema of the first example, and its own Project, Parent and lookups:

from typing import Annotated

from fastapi import Depends


class Company(fr.IDMixin, Base):
    name: Mapped[str]


class Project(fr.IDMixin, Base):
    name: Mapped[str]
    company_id: Mapped[int] = mapped_column(sa.ForeignKey("company.id"), init=False)


class Parent(fr.ContextNamespace):
    company_id: fr.ContextParam[int]
    project_id: fr.ContextParam[int]


async def company_from_path(company_id: int, session: fr.AsyncSessionDep) -> int:
    stmt = fr.apply_clauses(
        sa.select(Company.id).where(Company.id == company_id),
        fr.resolve_scope(Company),
    )
    found = await session.scalar(stmt)
    if found is None:
        raise fr.exc.NotFound(f"Company with id {company_id} was not found")
    return found


async def project_from_path(
    project_id: int,
    company_id: Annotated[int, Depends(company_from_path)],
    session: fr.AsyncSessionDep,
) -> int:
    stmt = fr.apply_clauses(
        sa.select(Project.id).where(
            Project.id == project_id, Project.company_id == company_id
        ),
        fr.resolve_scope(Project),
    )
    found = await session.scalar(stmt)
    if found is None:
        raise fr.exc.NotFound(f"Project with id {project_id} was not found")
    return found


class CompanyScoped(fr.AsyncRestView):
    prefix = "/companies/{company_id}"
    dependencies = [Parent.depends(company_id=company_from_path)]


class ProjectScoped(CompanyScoped):
    prefix = "/projects/{project_id}"
    dependencies = [Parent.depends(project_id=project_from_path)]


@fr.include_view(app)
class ProjectTaskView(ProjectScoped):
    prefix = "/tasks"
    model = Task
    schema = TaskSchema
    scope = fr.all_of(
        fr.resolve_scope(Task),
        fr.where_clause(Task.project_id == Parent.project_id),
    )

    async def create(self, data):
        task = await super().create(data)
        task.project_id = Parent.project_id()
        return task

project_from_path gets the company id from company_from_path as a FastAPI sub-dependency. FastAPI runs the company lookup first, once per request, and returns 404 for an unknown company. The project query also checks that the project belongs to that company. So /companies/2/projects/1/tasks returns 404 when project 1 is in company 1.

Each lookup applies the scope of its parent, as in Which parents a client can reach. This example has no views for companies or projects, so it passes the models. When a view serves the parent, such as a ProjectView, pass the view instead. The nested routes then hide what that view hides.

Details

Do not read Parent.company_id() in project_from_path instead. When the company id in the path is not an integer, FastAPI still runs the project lookup. Parent.company_id is then not bound, and the request returns 500 instead of 422.

Let each base inherit the one above it, as ProjectScoped inherits CompanyScoped. Two mixins side by side, class ProjectTaskView(CompanyMixin, ProjectMixin, fr.AsyncRestView), add their segments in reverse order: /projects/{project_id}/companies/{company_id}/tasks.

Each level adds one query to every request.

A flat view next to the nested one#

A model can have a flat view and nested views at the same time, such as a TaskView at /tasks next to ProjectTaskView. Watch these points:

  • Each view applies its own scope. Rows that the scope of TaskView hides, such as the tasks of other users, still show under the nested URL. To hide them there too, use fr.resolve_scope(TaskView) instead of fr.resolve_scope(Task) in the scope of ProjectTaskView.

  • TaskView cannot create tasks with TaskSchema, because project_id is read-only there. Give TaskView its own schema_create, a fr.BaseSchema with project_id: fr.MustExist[int, Project]. Keep project_id read-only in TaskSchema; see Create a child.

  • Both views appear under the “Tasks” tag in OpenAPI, unless you set tags.

  • OpenAPI marks a field that refers to a task, such as fr.MustExist[int, Task], with x-resource-ref. Its value comes from the prefix of the last view of Task that you register. Register the flat view after the nested one, so the value is tasks. With only a nested view, the value is projects/{project_id}/tasks, which a client cannot use as a resource name.

What the default routes do not do#

The default CRUD routes have these limits:

  • A row is identified by one primary key column. The {id} path parameter takes the Python type of the key, so a UUID or string key needs no configuration. Reference fields such as fr.MustExist[UUID, Model] accept these keys too. For a composite key, set id_type = str and override where_id to read the parts of the key from the path; see Look a row up by another key. Without where_id, a view of such a model fails when it registers, unless it leaves out the item routes.

  • Responses can contain nested objects, and lists can filter on relationships. Request bodies with nested objects are not supported in general. Use MustExist, IDRef or IDSchema to refer to other rows, or change the payload in a business method; see Relationships.

  • The update route uses PATCH, not PUT. Add a PUT route only when the client needs one.

See also#