Technical Details#
Schema Generation Under the Hood#
FastAPI-Restly builds request and response schemas from your declared schema class,
or auto-generates one from the SQLAlchemy model when schema is omitted on a
view.
ReadOnly and WriteOnly#
Field-level markers are implemented with typing.Annotated metadata:
class UserSchema(IDSchema[User]):
id: ReadOnly[int]
email: str
password: WriteOnly[str]
IDSchema is generic: IDSchema[User] enables a field validator that coerces
the id value to match the SQLAlchemy model’s actual primary-key type. Without
the type parameter the validator is a no-op and id stays typed as Any.
ReadOnly[...]fields are excluded from generated create/update input schemas.WriteOnly[...]fields are accepted on input and excluded from serialized responses. The filtering is done explicitly inBaseRestView.to_response_schema(), which skips any field whereis_field_writeonly()returnsTrue. FastAPI’s response model serialization does not filter them; a custom serialization path that bypassesto_response_schema()would exposeWriteOnlyfields.
Generated Input Schemas#
For a view schema MySchema, Restly derives two input schemas in
before_include_view():
creation_schema: produced bycreate_model_without_read_only_fields(), which creates a subclass mixing inOmitReadOnlyMixinbeforeMySchemain the MRO.OmitReadOnlyMixin.__pydantic_init_subclass__directly deletesReadOnlyentries fromcls.model_fieldsand callsmodel_rebuild(force=True). The subclass still inherits validators fromMySchemafor the fields that remain.update_schema: 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 fromMySchemaare replaced byNone, not preserved.
Both derived schemas are stored as class attributes on the view and are frozen
at registration time (see Query Modifier Lifecycle).
They can be overridden by declaring creation_schema or update_schema directly
on the view class before include_view() is called.
Auto-Generated Schemas#
create_schema_from_model(model_cls, ...) walks all Mapped[...] annotations
on the model (including inherited ones) and builds a Pydantic schema. Key
behaviours:
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: Only three field names are automatically marked
ReadOnly:"id","created_at", and"updated_at"(controlled byinclude_readonly_fields=True). Any other server-side default or auto-populated column will not be markedReadOnlyby auto-generation.Relationship fields: Included when
include_relationships=True(the default forcreate_schema_from_model). Relationship fields are set toOptionalwithdefault=Nonein the generated schema and nested schemas are generated recursively (one level deep, without relationships, to avoid circular references).
auto_generate_schema_for_view(view_cls, model_cls) is a thin wrapper that
calls create_schema_from_model(model_cls, schema_name, include_relationships=False).
It does not apply any other filtering beyond excluding relationship attributes;
foreign-key columns appear in the output as ordinary scalar fields.
SQLAlchemy-to-Pydantic Type Mapping#
convert_sqlalchemy_type_to_pydantic maps the Python type extracted from each
Mapped[T] annotation 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.
View Classes and Registration#
AsyncRestView and RestView#
Both AsyncRestView (async) and RestView (sync) are public API and
share the same CRUD structure via their common base BaseRestView. The
choice between them is determined by which class you subclass — AsyncRestView
hardcodes session: AsyncSessionDep and RestView hardcodes session: SessionDep.
The async and sync variants have identical endpoint signatures; the only difference
is that the async variant uses await in its process methods.
BaseRestView exposes several class variables that affect endpoint
registration and runtime behaviour:
schema— the Pydantic schema class; auto-generated if absent.creation_schema,update_schema— derived fromschemaif not declared.model— the SQLAlchemy model class.id_type— Python type for the{id}path parameter (defaultint).exclude_routes— tuple of method names to suppress (e.g.exclude_routes = ("delete",)). Routes listed here have their_api_route_argsmarker removed duringbefore_include_view()so FastAPI never registers them.include_pagination_metadata— ifTrue, theindexendpoint returns a paginated envelope withitems,total,page,page_size,total_pages,limit, andoffset.query_modifier_version— override the global version for this view class.
include_view()#
include_view() works in two equivalent forms:
# Decorator form
@fr.include_view(app)
class MyView(fr.AsyncRestView):
...
# Direct call form
fr.include_view(app, MyView)
Both forms call before_include_view() (which generates derived schemas,
annotates endpoint signatures, and registers the index_param_schema), then
attach an APIRouter to app.
Endpoint / Hook separation#
Every CRUD endpoint delegates to an on_* hook (on_list,
on_get, on_create, on_update, on_delete). Override
the on_* hook to change business logic while keeping the endpoint
wrapper intact, or override the endpoint method itself (e.g. index) to replace
the full request/response flow.
Nested Response Schemas vs Write Payloads#
Nested schemas serve two different roles in Restly today:
Response serialization: supported.
BaseRestViewrecursively buildsselectinload(...)options for nested relationship fields in the response schema, so related objects can be serialized efficiently and with aliases.Create/update payloads: 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: IDSchema[Model]as the supported special case for foreign keys.
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
on_create() / on_update() to transform the payload first.
Query Modifier Lifecycle#
Query modifiers have two versions:
QueryModifierVersion.V1: JSONAPI-style —filter[name]=John,sort,limit,offsetQueryModifierVersion.V2: standard HTTP —name=John,order_by,page,page_size
The active version is stored in a ContextVar (_query_modifier_version),
defaulting to V1. set_query_modifier_version() calls .set() on this
ContextVar, not a plain module-level global. In async frameworks, ContextVar
values are scoped to the current task/context, so calling it at module level
during application startup (a single-context moment) works as expected, but the
setting does not propagate into concurrent request contexts automatically.
During before_include_view(), two class-level attributes are set (once,
idempotently):
cls.query_modifier_version— the version read from theContextVarat registration time, stored as a class attribute. Once set, later calls toset_query_modifier_version()do not affect already-registered views.cls.index_param_schema— the query-parameter Pydantic schema generated for this view’sindexendpoint. It is generated inside ause_query_modifier_version(cls.query_modifier_version)context so the correct V1 or V2 field set is used. Likequery_modifier_version, it is frozen at registration time.
To use a specific version, either:
call
set_query_modifier_version(...)before registering the view, orset
query_modifier_version = QueryModifierVersion.V1|V2directly on the view class.
Database Globals and Test Isolation#
The database session factories (async_make_session, make_session) are stored
on an FRGlobals instance. A ContextVar (_fr_globals_ctx) determines which
FRGlobals object is active in any given context. The module-level fr_globals
is a proxy that delegates attribute access to get_fr_globals(), which returns
the context-local instance if one has been set, or the default instance
otherwise.
The use_fr_globals(globals_obj) context manager swaps in an alternative
FRGlobals during the block and restores the previous one on exit. This is how
activate_savepoint_only_mode() achieves test isolation: it injects a
savepoint-backed session factory without touching global state visible to other
concurrent contexts.