Technical Details#
This page documents the implementation behaviour beneath FastAPI-Restly’s public API: how schemas are generated and derived, how view classes register their routes, how list parameters are assembled, and which session defaults the framework sets.
Schema Generation Under the Hood#
FastAPI-Restly builds request and response schemas from the view’s schema,
or auto-generates one from the SQLAlchemy model when schema is omitted on a
view.
ReadOnly and WriteOnly#
ReadOnly and WriteOnly are field-level markers implemented with
typing.Annotated metadata. A schema declares them inline:
class UserSchema(IDSchema):
id: ReadOnly[int]
email: str
password: WriteOnly[str]
IDSchema is primarily a response-schema base: it is BaseSchema with a
read-only id field. IDRef[Model] and IDSchema[Model] are the model-aware
reference forms;
their validators coerce the id value to match the SQLAlchemy
model’s actual primary-key type.
The markers take effect as follows:
ReadOnly[...]fields are excluded from generated create/update input schemas.WriteOnly[...]fields are accepted on input and excluded from serialized responses. The marker carries Pydantic’s field-levelexclude, so the filtering happens in every serialization of the schema, including FastAPI’s response model and nested schemas. A response that never passes through the schema (a raw dict or ORM object returned without aresponse_model) is the only way aWriteOnlyvalue can leak.
The user-facing behaviour of both markers is covered in ReadOnly and WriteOnly.
Generated Input Schemas#
Restly derives two input schemas from the view’s schema in
before_include_view(). For the view’s schema UserSchema:
schema_create: produced bycreate_model_without_read_only_fields(), which creates a subclass mixing inOmitReadOnlyMixinbeforeUserSchemain the MRO.OmitReadOnlyMixin.__pydantic_init_subclass__directly deletesReadOnlyentries fromcls.model_fieldsand callsmodel_rebuild(force=True). The subclass still inherits validators fromUserSchemafor the fields that remain.schema_update: produced bycreate_model_with_optional_fields(), which mixes in bothPatchMixinandOmitReadOnlyMixin. AfterOmitReadOnlyMixinstrips the read-only fields,PatchMixin.__pydantic_init_subclass__setsfield.default = Noneand wraps every remaining annotation inOptional[...]. Original field defaults fromUserSchemaare replaced byNone, not preserved.
Both derived schemas are stored as class attributes on the view and are frozen
at registration time (see List Parameters Lifecycle).
They can be overridden by declaring schema_create or schema_update directly
on the view class before include_view() is called.
A subclass inherits the schemas its base view declares. Registration rebuilds a
schema for the subclass in two cases: Restly generated it for the parent, or
what it is built from changes. A subclass that declares a new schema gets a
new schema_response, schema_create, schema_update, and
schema_list_params. A subclass that declares a new schema_response gets new
schema_list_params. A view without a schema builds it from model, so there
a new model counts as a new schema. A class that Restly generates for the
subclass is not a change of its own, so it does not replace a class that the
base declares.
Generated Class Names#
A class that Restly generates is named <Resource><Role>. Resource is the
class name of the view’s schema without a final Schema or Response. For
the view’s schema UserSchema, the resource is User:
Role |
Class |
View attribute |
|---|---|---|
response |
|
|
create body |
|
|
update body |
|
|
list response |
|
none |
list params |
|
A client sees these names in OpenAPI. It does not see the view’s schema, unless another schema nests it or a custom route names it. The list params are not a class in OpenAPI either: OpenAPI shows them as separate query parameters.
The list response has no view attribute, because it has nothing of its own
to set: it is the envelope of the view’s
pagination, filled with
the response class. To change it, change one of those two.
A custom route names the same classes#
A route decorator runs in the class body, before Restly builds the response
classes. So a custom route gets the classes from two functions, at module
level. Each returns the same class that the view uses:
derive_schema_response(schema)
and
derive_schema_list_response(schema_response, pagination=...).
Pass the view’s pagination as it is, also when it is None:
UserResponse = fr.schemas.derive_schema_response(UserSchema)
UserListResponse = fr.schemas.derive_schema_list_response(
UserResponse, pagination=AppView.pagination
)
@fr.include_view(app)
class UserView(AppView):
prefix = "/users"
model = User
schema = UserSchema
@fr.get("/inactive", response_model=UserListResponse)
async def inactive(self, list_params):
result = await self.handle_get_many(list_params, where=User.active.is_(False))
return self.to_response(result, fr.ResponseShape.LIST)
@fr.post("/{id}/activate", response_model=UserResponse)
async def activate(self, id: int):
user = await self.get_one(id)
async with self.write_action("activate", obj=user):
user.active = True
return self.to_response(user)
OpenAPI then shows UserListResponse and UserResponse for these routes,
as for the CRUD routes. A function builds these classes, so type checkers do
not accept them in an annotation, as in data: UserResponse. mypy also does
not accept them inside a type in other places, as in
response_model=list[UserResponse]. At runtime they are normal classes, so
add # type: ignore[valid-type] on such a line. A view with its own
schema_response passes that class to derive_schema_list_response instead.
A custom route that names UserSchema or PaginatedEnvelope[UserSchema]
sends the same JSON, but shows a type of its own in OpenAPI. If a route passes
a pagination with another envelope than the view’s, its list response has
another shape, for example data without total_count. There are then two
classes named UserListResponse, and Restly warns as described below.
A view without a schema gets a generated one. For a model named User it
is UserSchema, so the names above stay the same. A schema with another name
keeps its full name as the resource: UserRead gives UserReadCreate.
By default the response class is the view’s schema without its WriteOnly
fields. Restly builds it also when the schema has no WriteOnly fields, so
OpenAPI always shows UserResponse. It is a subclass of the view’s schema, and
to_single_response()
returns an instance of it. An override may return an instance of the view’s
schema instead: the response class copies its values and does not validate
them again. An override that returns an instance of another model gets it
validated into the response class from its attributes, so only the fields of
the response class go out. A view can also set its own response class; see
Your own response class. The list response is the
pagination’s envelope, filled with the response class. A react-admin view has
no list response class: its list route returns a plain JSON list.
Two views with the same schema and the same pagination show one set of names.
Two different classes can still get the same name. For example, two views
share a schema but only one is paginated, or you wrote a UserCreate that is
not the same as the generated one. Pydantic then shows both classes under long
names built from the module path, such as app__users__views__UserCreate.
When the module path is the same too, it numbers them, and the numbers follow
the order in which the views are registered. When the app builds its OpenAPI
spec, Restly checks for this and warns with a
RestlyDuplicateSchemaNameWarning that
names the classes and the views. Restly checks an app that you pass to
fr.configure(app, ...) or
configure_tests(app=...), or
that you include a view on. Name your schemas shows how
to avoid it.
Auto-Generated Schemas#
derive_schema(model, ...) builds a Pydantic schema with one
field per column on the model’s SQLAlchemy mapper, inherited ones included.
A column’s type comes from its Mapped[T] annotation, or from
the plain annotation on a SQLModel table. Under
from __future__ import annotations, the annotation string is evaluated against
the model’s module. When no annotation resolves, the column type’s
python_type is used. A column with neither raises TypeError naming the
attribute. Synonyms and composites get no field of their own; the columns
behind them already have one. Three of its behaviours are worth noting:
Base class selection: The function checks whether the model has fields named
id,created_at, andupdated_atto decide which schema base classes to mix in (IDSchema,TimestampsSchemaMixin,BaseSchema). It does not inspect the model’s Python inheritance hierarchy; a model with a field accidentally namedidwill receiveIDSchemaas a base.ReadOnly annotation: Three field names are always marked
ReadOnly:"id","created_at", and"updated_at". So is acolumn_propertyover a SQL expression, which cannot be written. Any other server-side default or auto-populated column will not be markedReadOnlyby auto-generation.Relationship fields: Never included. A foreign key column, such as
customer_id, is an ordinary field. To show a related row in a response, write the schema yourself, for example with anIDRef[Customer]field; see Work with Foreign Keys and Relationships.
When a RestView / AsyncRestView omits schema, the view setup calls
derive_schema(model), so the view and a direct call get the same schema.
SQLAlchemy-to-Pydantic Type Mapping#
convert_sqlalchemy_type_to_pydantic maps each column’s Python type, from its
annotation or its column type, to its Pydantic equivalent. Pass-through types (those
already understood by Pydantic) are returned unchanged:
SQLAlchemy / Python annotation |
Pydantic field type |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
same enum subclass |
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
Any type not in this table raises TypeError at schema-generation time. For
custom column types, declare an explicit schema and bypass auto-generation.
JSON document columns#
A JSON column maps to a bare dict, which does not validate the document’s
fields. To validate its shape, declare an explicit schema with a nested
Pydantic model. Restly calls model_dump(mode="json") before assigning the
document to the column. The view’s schema validates the stored document
when it is read back.
Documents containing WriteOnly or
Field(exclude=True) fields raise
RestlyConfigurationError
before assignment, including exclusions inside nested models. Those fields
would otherwise be omitted from storage. Documents containing dataclasses or
iterators are also unsupported. Use a SQLAlchemy
TypeDecorator to define their storage representation.
Its bind processor receives the original Pydantic model instead of a dump.
Marking the whole JSON column as WriteOnly remains supported. It hides the
column in responses without excluding fields from the stored document.
View Classes and Registration#
The generated schemas feed the view layer, which turns a model and a schema into registered FastAPI routes.
AsyncRestView and RestView#
Both AsyncRestView (async) and RestView (sync) are public API and
share the same CRUD structure via an internal abstract base class (not exposed
as fr.*). The choice between them is determined by which class you subclass:
AsyncRestView declares session: AsyncSessionDep and RestView declares
session: SessionDep. A subclass can override that session annotation with
its own Annotated[..., Depends(...)] dependency for
per-view session wiring.
The async and sync variants have identical endpoint signatures; the only
difference is that the async variant uses await in its process methods.
AsyncSessionDep and SessionDep wrap a session source as a FastAPI
sub-dependency. The source is either Restly’s built-in generator or the custom
generator passed to configure() before
route registration. Making that source a dependency lets FastAPI share its
cached session with the application’s Depends(get_db), without rewriting
view annotations. The wrapper checks for uncommitted changes before the source
cleans up. The built-in generators roll back and close on exit.
They do not commit on response. handle_<verb> normally owns
the commit and runs
before_action_commit,
then the commit itself, then
after_action_commit
around domain logic. Inside shared_write_action_commit, the outermost block
owns the commit and handlers return after flush, before the commit and
after-hook.
Custom write routes can use the same bracket with
async with self.write_action(action, ...). Several brackets can share a commit
with shared_write_action_commit().
If a request ends with uncommitted
changes, Restly warns with RestlyUncommittedChangesWarning by default.
Custom session generators
control construction and cleanup, not commit ownership.
Using RestView owns the user-facing model, schema,
identity, route, and list configuration. At registration,
before_include_view()
turns that configuration into FastAPI signatures. It derives missing schemas,
builds the list params, sets the endpoint annotations, and
removes the route marker for each exclusion. An exclusion may use a
ViewRoute value or its endpoint-method string, such as "delete_endpoint".
include_view()#
include_view() is the registration boundary between declarative view modules
and application composition. For larger apps, define view classes without
side effects in subject packages, then include them from the module that builds
your FastAPI app or APIRouter:
fr.include_view(app, MyView)
This keeps imports predictable: importing app.users.views defines
UserView, while app.main decides which app or router receives it. See
Project structure for the layout this assumes.
For small apps and examples, include_view() also works as a decorator:
@fr.include_view(app)
class MyView(fr.AsyncRestView):
...
Both forms call before_include_view() (which generates derived schemas,
annotates endpoint signatures, and registers the schema_list_params), then
attach an APIRouter to the parent app/router.
The Three Tiers of a CRUD Verb#
Every CRUD verb is split into an endpoint method (<verb>_endpoint), a
handler (handle_<verb>), and a business method (<verb>); the model and the
override decision table live in
Customizing RestView.
The implementation detail worth knowing here is that the endpoint method calls
to_response(obj, shape), the single response method, which delegates to
to_single_response(obj) for the per-object serialization
(relationship-id normalization and response-schema validation).
Nested Response Schemas vs Write Payloads#
Nested schemas serve two different roles in Restly today:
Response serialization is supported. The CRUD views recursively build
selectinload(...)options for nested relationship fields in the response schema, so related objects can be serialized efficiently and with aliases. Reads apply those options inget_one/get_many; writes apply the same ones insave_object, because the refresh that follows a flush leaves relationships unloaded. Without that, serializing a create or update response would reach them one lazy query at a time, which on an async session raisesMissingGreenletrather than merely costing queries. The reload is skipped when everything the response class names is already loaded, and it runs withoutpopulate_existing, so a relationship the caller has already populated keeps its value.Loader options follow relationships the response class names. Code that reaches past that set – an
after_action_commithook, a custom business method, a@propertywalking a relationship nothing else loads – runs in plain async context, where a bare attribute access raisesMissingGreenlet. Restly’s declarative base mixes in SQLAlchemy’sAsyncAttrsfor exactly that case, so those reads can be spelledawait obj.awaitable_attrs.items. The task-focused guide to all of this, including extending the loaded set and resolvingMissingGreenlet, is Relationship Loading and Async; this section covers the mechanism behind it.Create/update payloads are not supported in the general case. The default
make_new_object()/update_object()flow expects payload keys to map directly to model attributes, with*_id: fr.MustExist[int, Model](see MustExist) as the FK case. For a relationship-named field, after resolving anIDRef/IDSchemato an ORM object, Restly chooses the FK scalar, relationship object, or both based on the model constructor. If the client supplies both fields, Restly checks they refer to the same row.
If you declare a nested input field like address: AddressSchema on a write
schema, the default CRUD implementation will pass that nested Pydantic object
through to the SQLAlchemy model constructor or attribute setter, which usually
does not match the ORM model shape. Use a flattened schema or override the
create() / update() business methods to transform the payload first.
Work with Foreign Keys and Relationships
describes the supported reference-field patterns.
List Parameters Lifecycle#
List endpoints accept URL query parameters of the form
name=John, age__gte=18, sort=-created_at, and
page=2&page_size=50. The full operator surface (__ne, __isnull,
__contains, __icontains, and more) is documented in
Filter, Sort, and Paginate Lists.
During before_include_view(), the framework freezes a single class-level
attribute, cls.schema_list_params: the list params, a Pydantic model generated
by derive_schema_list_params(cls.schema_response, cls.model, pagination=cls.pagination). It covers pagination, sorting, and one filter
parameter per field of the response class that maps to a filterable column on the
model, with optional operator suffixes. A client can filter and sort only on
what it can see. It is generated once per registration
and never re-derived.
A route reads the list params through a dependency, not as a FastAPI query model.
FastAPI splits a query model into separate parameters only when it is the
route’s only query parameter, so a dependency’s ?api_key= would collapse
the filters into one required object. Restly validates the model from the
query string itself and documents each key as its own OpenAPI parameter,
next to whatever else the route reads.
Custom dialects (e.g. react-admin’s
AsyncReactAdminView / ReactAdminView) live as
parallel view classes that bypass apply_list_params entirely and
implement their own request/response contract.
Restly Runtime Configuration#
Restly exposes one public process-wide runtime configuration. Most applications configure it once during startup:
fr.configure(async_database_url="sqlite+aiosqlite:///app.db")
fr.configure(...) rejects no-op calls; pass at least one setup option. The
authoritative list of accepted options is the
Database reference. This page does not
duplicate the contract.
Internally, Restly keeps a private context object so its own tests and fixtures can isolate runtime state. That context is not a public multi-engine feature. If an application needs multiple databases, wire a custom FastAPI dependency or session generator for that view. Restly does not currently bind different views to different named contexts.
Engine Defaults#
fr.configure() builds an engine only when
you give it a URL. To that engine, and no other, Restly applies the options a
web application almost always wants but SQLAlchemy leaves at its library
defaults:
Backend |
Applied |
|---|---|
SQLite, in-memory |
|
SQLite, any |
|
PostgreSQL |
|
An in-memory SQLite database lives inside its connection, so the default pool,
which opens one connection per thread, serves every thread but the first a
second and empty database. FastAPI runs def endpoints on a thread pool, so
StaticPool is what makes an in-memory URL behave as one database.
SQLite enforces foreign keys only under PRAGMA foreign_keys=ON. Without it the
ForeignKey constraints your models declare are parsed and then ignored, so
ondelete never fires and a dangling reference is stored instead of raising the
IntegrityError that becomes a 409.
On PostgreSQL, pool_pre_ping=True tests a pooled connection before handing it
out, turning a connection left stale by a database restart or a network blip
into a reconnect rather than an error. pool_recycle then discards connections
older than 30 minutes, because proxies such as pgbouncer drop idle connections
without telling the client. Pool sizing stays yours: Restly sets neither
pool_size nor max_overflow.
Each of these is connection state, and that is the line they stop at. Restly configures the connections it opens and does not modify the database they reach, so nothing here leaves a mark on your data once the process exits.
PRAGMA journal_mode=WAL is the notable omission. WAL lets readers run while a
writer holds the database, so it is the usual answer to database is locked on
a file-backed SQLite database serving concurrent requests. Restly does not set
it, because journal mode is written into the database file and outlives every
process that touches it: a default here would permanently change a database you
own, and every later reader would inherit the choice. It is also a loud failure
rather than a silent one, unlike the pragmas above. Turn it on yourself when you
want it:
from sqlalchemy import create_engine, event
engine = create_engine("sqlite:///app.db")
@event.listens_for(engine, "connect")
def _enable_wal(dbapi_connection, connection_record):
dbapi_connection.execute("PRAGMA journal_mode=WAL")
fr.configure(engine=engine)
Note that WAL adds -wal and -shm files beside the database, which are
removed on clean shutdown but remain after a hard kill, and that it does not
work over network filesystems.
All of this rides on the URL rung alone. Pass engine=, make_session= or a
session generator and Restly leaves your object exactly as it arrived, so
building the engine yourself is how you decline any of these defaults.
Engine Disposal#
Disposing an engine closes the connections its pool is holding. Restly never disposes an engine, including the ones it builds, because an engine is meant to live as long as the process that serves requests through it. When that process exits, the operating system reclaims the sockets, so most applications never need to dispose anything.
Asynchronous applications are the exception worth handling. AsyncEngine.dispose()
is a coroutine, and nothing awaits it during interpreter shutdown, so an async
engine that is never disposed drops its connections rather than closing them.
The database server is not what notices. The operating system closes the sockets
as the process exits, and a backend waiting for its next command treats that as
an ordinary end of session. The cost lands in your own shutdown output: a
ResourceWarning for every connection left in the pool, and, when a request is
still in flight, a RuntimeError: Event loop is closed traceback from the
finalizer trying to terminate a connection on a loop that is already gone.
SQLite has no server and no socket, so none of this applies to it.
An application that builds its own engine already holds it and disposes it in
its lifespan, which is what the production
template does. When you configure from a
URL, Restly owns the engine, and
fr.db.get_async_engine() hands it
back:
from contextlib import asynccontextmanager
import fastapi_restly as fr
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await fr.db.get_async_engine().dispose()
app = FastAPI(lifespan=lifespan)
fr.configure(app, async_database_url="postgresql+asyncpg://localhost/app")
Synchronous applications call
fr.db.get_engine() and dispose()
without awaiting. Note that Starlette runs on_startup and on_shutdown
handlers only when the application was built without a lifespan argument, so
disposal belongs in the lifespan itself rather than in a handler appended to
app.router.
An application that disposes its engine in the lifespan cannot be tested against an in-memory SQLite database. Restly’s test clients run the ASGI lifespan, so the disposal happens at the end of every test. An in-memory database lives inside its connection, so closing it discards the schema and the next test finds no tables. Point the suite at a file database instead.
Reconfiguring Restly in a running process abandons the previous pool rather than closing it. CPython reclaims it once the garbage collector reaches the cycle the engine sits in, which is why a test suite that reconfigures per test can hold several pools open at once. Suites that care dispose explicitly; Restly’s own fixtures do.
Session Factory Defaults#
When fr.configure() creates session factories from URLs or engines, Restly
sets a few SQLAlchemy session options intentionally:
Factory |
Autoflush |
Expire on commit |
|---|---|---|
Async |
|
|
Sync |
SQLAlchemy default ( |
|
expire_on_commit=False is used for both sync and async sessions so ORM
objects remain readable after a route commits. Restly’s write handlers commit
inside the request, and the response-schema conversion reads attributes from the
committed object afterwards (as does an after_action_commit hook that inspects it).
With expire_on_commit=True the commit expires those attributes, so each such
read becomes an implicit database read: in async code it raises
MissingGreenlet, because the serializer – and any such hook – runs in plain
async context; in sync code it quietly makes response rendering database-dependent.
The autoflush setting is intentionally different. Async sessions disable autoflush because autoflush can turn a read operation into an implicit write and database I/O must happen at explicit awaited SQLAlchemy boundaries. Restly’s async CRUD helpers flush explicitly when writes should hit the database. Sync sessions keep SQLAlchemy’s default autoflush behavior, preserving the usual unit-of-work ergonomics where ORM queries see pending in-session changes.
Projects with custom sessionmakers or generators should preserve these defaults unless they need different behavior.
See Also#
Filter, Sort, and Paginate Lists: the full filter, sort, and pagination reference.