Schemas API#

fastapi_restly.schemas implements the Pydantic schema layer: schema base classes, the ReadOnly and WriteOnly field markers, relationship reference types, and schema generation from SQLAlchemy models.

See also

Work with Foreign Keys and Relationships covers MustExist checked foreign keys and the IDRef / IDSchema relationship wire contract. Custom Schemas and Field Types describes schema bases, field markers, and aliases in prose.

fastapi_restly.schemas.ReadOnly#

Marks a field as excluded from generated create and update schemas. The field remains available in responses.

fastapi_restly.schemas.WriteOnly#

Marks a field as input-only by setting Pydantic’s serialization exclusion. See JSON document columns for restrictions inside JSON documents.

class fastapi_restly.schemas.BaseSchema#

Bases: BaseModel

Thin Pydantic base for ORM-facing Restly schemas.

Equivalent to:

class BaseSchema(pydantic.BaseModel):
    model_config = pydantic.ConfigDict(from_attributes=True)

from_attributes=True lets Pydantic/FastAPI validate objects by attribute when the schema is used directly. The inherited CRUD endpoint methods still serialize through to_single_response() so Restly-specific behavior such as WriteOnly filtering and relationship-id normalization is applied.

model_config: ClassVar[ConfigDict] = {'from_attributes': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class fastapi_restly.schemas.IDRef(value: ~typing.Any = <object object>, *, id: ~typing.Annotated[~typing.Any, fr.ReadOnly])#

Bases: IDSchema, Generic[SQLAlchemyModel]

Flat-id reference to a related row, for a RELATIONSHIP-named field.

Use IDRef[T] when the field is named after a relationship (author: IDRef[User], products: list[IDRef[Product]]), not after a *_id column. The wire format is the raw id (5); input also accepts {"id": N}. Restly resolves the id to the related ORM object, so data.author is an IDRef wrapper (read .id), not a plain scalar.

For a scalar foreign-key COLUMN (author_id, task_id) use fr.MustExist[int, T] instead – it keeps the field a plain checked id (data.author_id == 1), rather than making a *_id field an object wrapper. For nested {"id": N} wire on a relationship, use IDSchema[T].

author: IDRef[User] # to-one relationship, flat id products: list[IDRef[Product]] # serializes as [1, 2, 3]

Resolution applies the referenced model’s default_scope: a reference to a row the scope hides raises NotFound, so a tenant scope declared once covers every reference to the model. A model without a default_scope resolves by bare primary key; gate visibility there in authorize (data.<field>.id is the requested id, before resolution) or before_action_commit (the resolved row is on the built object). See the Foreign Keys and Relationships how-to, “Visibility and Multi-Tenancy”.

model_config: ClassVar[ConfigDict] = {'from_attributes': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class fastapi_restly.schemas.IDSchema(*, id: Annotated[Any, fr.ReadOnly])#

Bases: BaseSchema, Generic[SQLAlchemyModel]

Response-schema base that adds a read-only id; parametrized as a field type, a nested-object relationship reference.

  • As a BASE CLASS (class UserSchema(IDSchema): ...) it adds the resource’s own read-only id – the common use.

  • As a FIELD TYPE on a RELATIONSHIP-named field (author: IDSchema[User]) the wire format is {"id": N} (JSON-API / React-Admin); Restly resolves the id to the related ORM object, so data.author is a wrapper (read .id). Use IDRef[User] for flat-id wire (5) instead.

For a scalar foreign-key COLUMN (author_id), use fr.MustExist[int, T] rather than IDSchema/IDRef: it keeps the field a plain checked id instead of turning a *_id field into an object wrapper.

get_sql_model_annotation() → type[SQLAlchemyModel] | None#

Return the annotation on IDSchema when used as:

foo: IDSchema[Foo]

This property will return “Foo”.

id: ReadOnly, FieldInfo(annotation=NoneType, required=True, json_schema_extra={'readOnly': True})]#
model_config: ClassVar[ConfigDict] = {'from_attributes': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class fastapi_restly.schemas.MustExist#

Bases: object

Existence-checked scalar foreign key.

MustExist[int] is a checked int foreign key; the target model is inferred from the column’s ForeignKey. Spell the model out with a second argument when you want it explicit (MustExist[int, Post]), and use the first for a non-int primary key (MustExist[UUID, Account]).

Unlike IDRef/IDSchema this is not a wrapper: the field stays the pk scalar everywhere (wire, column, data.<field>), plus a batched existence check on write (404 on a miss). The check applies the target model’s default_scope; check against another clause, or none, by writing the marker form Annotated[<pk>, RefExists(Model, scope=...)] directly (see RefExists).

class fastapi_restly.schemas.RefExists(model: type[~typing.Any] | type[~fastapi_restly.schemas._base._Infer], scope: ~fastapi_restly.clauses._runtime.WhereClause | ~fastapi_restly.clauses._runtime.Unscoped | ~fastapi_restly.schemas._base._NotGiven = <fastapi_restly.schemas._base._NotGiven object>)#

Bases: object

Marker for an existence-checked scalar foreign key.

Built by MustExist[pk] / MustExist[pk, Model] (or written directly as Annotated[pk, RefExists(Model)]). On write, Restly batch-checks that each marked id exists in the target table and raises NotFound (404) on a miss. The field stays a plain scalar – this only adds the check; it does not wrap the value or do any relationship routing.

The check applies the target model’s default_scope, like IDRef/IDSchema resolution: a reference to a row the scope hides is a miss. scope overrides that per field: a WhereClause checks against that clause instead (RefExists(Item, scope=ItemClauses.trashed) for a restore target), and scope=fr.clauses.UNSCOPED checks unscoped, greppably; None is rejected. A model without a default_scope is checked unscoped, as before.

model is the target ORM model, or _Infer when it should be resolved from the marked column’s ForeignKey (the MustExist[pk] form).

class fastapi_restly.schemas.TimestampsSchemaMixin(*, created_at: Annotated[datetime, fr.ReadOnly], updated_at: Annotated[datetime, fr.ReadOnly])#

Bases: BaseModel

created_at: ReadOnly, FieldInfo(annotation=NoneType, required=True, json_schema_extra={'readOnly': True})]#
model_config: ClassVar[ConfigDict] = {}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

updated_at: ReadOnly, FieldInfo(annotation=NoneType, required=True, json_schema_extra={'readOnly': True})]#
fastapi_restly.schemas.derive_schema(model: type[Any], *, name: str | None = None) → type[BaseSchema]#

Generate a Pydantic schema from a SQLAlchemy model.

This is the same schema that a view generates when it has no schema: one field per column, and no relationship fields. A foreign key column, such as author_id, is an ordinary field. The fields id, created_at and updated_at, and read-only columns such as a column_property, are marked ReadOnly.

Parameters:
  • model – The SQLAlchemy model class.

  • name – Name for the generated schema class. Defaults to the model name followed by Schema, as in UserSchema.

Returns:

A Pydantic schema class.

fastapi_restly.schemas.derive_schema_list_response(schema_response: type[BaseModel], *, pagination: NumberedPagination | NoPagination | None = NumberedPagination(default_page_size=50, max_page_size=1000, max_page=None, page_query_param='page', page_size_query_param='page_size', envelope=PaginatedEnvelope)) → type[BaseModel]#

Build the list response class of a view: the envelope of its pagination, filled with its response class, named <Resource>ListResponse.

GET / of a view returns this class, except on a react-admin view, which returns a plain list. A custom list route names it, so OpenAPI shows one type for both routes:

UserResponse = fr.schemas.derive_schema_response(UserSchema)
UserListResponse = fr.schemas.derive_schema_list_response(
    UserResponse, pagination=AppView.pagination
)


class UserView(AppView):
    schema = UserSchema

    @fr.get("/inactive", response_model=UserListResponse)
    async def inactive(self, list_params): ...

Each call with the same response class and envelope returns the same class. Only the envelope of the pagination matters: two paginations with the same envelope give the same class. A view has no attribute for this class. To change it, change the view’s schema_response or the envelope of its pagination.

Parameters:
  • schema_response – The view’s response class, such as the one from derive_schema_response().

  • pagination – The view’s pagination. None is a view without pagination, as on the view. The default is the default pagination.

Returns:

The list response class.

fastapi_restly.schemas.derive_schema_response(schema: type[_SchemaT]) → type[_SchemaT]#

Build the response class of a view’s schema: the schema without its WriteOnly fields, named <Resource>Response.

A view without its own schema_response gets this class. Restly builds it also when the schema has no WriteOnly fields, so OpenAPI shows UserResponse and not the view’s schema.

The class is a subclass of schema. Each call with the same schema returns the same class, so a custom route can name the class that the view uses:

UserResponse = fr.schemas.derive_schema_response(UserSchema)


class UserView(fr.AsyncRestView):
    schema = UserSchema

    @fr.get("/me", response_model=UserResponse)
    async def me(self): ...
Parameters:

schema – The view’s schema.

Returns:

The response class.