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-level exclude, 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 a response_model) is the only way a WriteOnly value 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 by create_model_without_read_only_fields(), which creates a subclass mixing in OmitReadOnlyMixin before UserSchema in the MRO. OmitReadOnlyMixin.__pydantic_init_subclass__ directly deletes ReadOnly entries from cls.model_fields and calls model_rebuild(force=True). The subclass still inherits validators from UserSchema for the fields that remain.

  • schema_update: produced by create_model_with_optional_fields(), which mixes in both PatchMixin and OmitReadOnlyMixin. After OmitReadOnlyMixin strips the read-only fields, PatchMixin.__pydantic_init_subclass__ sets field.default = None and wraps every remaining annotation in Optional[...]. Original field defaults from UserSchema are replaced by None, 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

UserResponse

schema_response

create body

UserCreate

schema_create

update body

UserUpdate

schema_update

list response

UserListResponse

none

list params

UserListParams

schema_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, and updated_at to 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 named id will receive IDSchema as a base.

  • ReadOnly annotation: Three field names are always marked ReadOnly: "id", "created_at", and "updated_at". So is a column_property over a SQL expression, which cannot be written. Any other server-side default or auto-populated column will not be marked ReadOnly by 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 an IDRef[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

str

str

int

int

float

float

bool

bool

datetime

datetime

date

date

time

time

UUID

UUID

Decimal

Decimal

dict / dict[str, Any]

dict / dict[str, Any]

list / list[T]

list / list[T]

enum.Enum subclass

same enum subclass

SQLAlchemy Text, String

str

SQLAlchemy Integer

int

SQLAlchemy Float

float

SQLAlchemy Boolean

bool

SQLAlchemy DateTime

datetime

SQLAlchemy Date

date

SQLAlchemy Time

time

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 in get_one / get_many; writes apply the same ones in save_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 raises MissingGreenlet rather than merely costing queries. The reload is skipped when everything the response class names is already loaded, and it runs without populate_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_commit hook, a custom business method, a @property walking a relationship nothing else loads – runs in plain async context, where a bare attribute access raises MissingGreenlet. Restly’s declarative base mixes in SQLAlchemy’s AsyncAttrs for exactly that case, so those reads can be spelled await obj.awaitable_attrs.items. The task-focused guide to all of this, including extending the loaded set and resolving MissingGreenlet, 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 an IDRef / IDSchema to 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

poolclass=StaticPool, connect_args={"check_same_thread": False}

SQLite, any

PRAGMA foreign_keys=ON, on every connection

PostgreSQL

pool_pre_ping=True, pool_recycle=1800

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 async_sessionmaker

False

False

Sync sessionmaker

SQLAlchemy default (True)

False

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#