Changelog#
FastAPI-Restly follows Semantic Versioning. While the
version is below 1.0.0, breaking changes may land in minor releases
(0.x → 0.y) and are always listed below under the release that ships them;
patch releases are fixes only. To opt into fixes without surprises, pin to a
minor version, for example fastapi-restly~={{ release }}.
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased#
0.9.0 - 2026-08-13#
Added#
fr.testing.configure_tests()for schema setup and per-test database isolation.db_cleanup=modes (rollback,delete, andnone) with per-run--restly-db-cleanupandRESTLY_DB_CLEANUPoverrides.Pooled asyncpg support for applications configured with only an async database; no synchronous database configuration or test-only
NullPoolengine is required.AsyncRestlyTestClientand therestly_async_clientfixture.SQLAlchemy
AsyncAttrssupport throughawaitable_attrson Restly models.get_relationship_loader_options()as an eager-loading override.PostgreSQL CI coverage for dialect-specific behavior and database fixtures.
Public
EnvelopeandPaginatedEnveloperesponse models.
Changed#
List endpoints now paginate by default.
GET /thingschanges from an unbounded bare array to{"data": [...], "total_count": ..., "page": ..., "page_size": ..., "total_pages": ...}, capped atdefault_page_size(50). Existing paginated responses must renameitemstodataandtotaltototal_count.include_pagination_metadataandto_paginated_listing_response()are removed: setpaginated = Falsefor an unpaginated{"data": [...]}response with no pagination query parameters, overrideto_listing_response()to customize that envelope, or replaceget_many_endpointwith a matchingresponse_modelif the API must keep returning a bare array.default_page_sizemust now be an integer from 1 throughmax_page_size; the oldNonevalue is replaced bypaginated = False. Single-object responses are unchanged.After
configure_tests(), rollback isolation now applies to every test that uses the configured database, including client-only tests and code that callsfr.open_session()orfr.open_async_session()without requesting a public session fixture. Sessions join one test transaction through SQLAlchemy savepoints, socommit()androllback()behave as in production. A value added through a session fixture becomes visible to a request afterflush()orcommit(); the sessions do not share an identity map.Under rollback cleanup, an application configured with only an async database can no longer combine the synchronous
restly_clientwithrestly_async_sessionor other direct async database access. That would use one transaction from two event loops, so Restly raises a configuration error before database work begins and directs the test torestly_async_client. Under"delete"and"none", the same check applies to drivers such as asyncpg that bind pooled connections to one event loop.Under rollback cleanup, requesting
restly_sessionorrestly_async_sessionwith only a custom session generator now raises instead of skipping. Configure the corresponding database URL, engine, or sessionmaker as well so Restly can build the isolated session.
Fixed#
restly_clientnow runs application lifespan startup and shutdown once per test. A view registered throughrestly_client.appafter the fixture has constructed the client also keeps working, including Restly’s integrity-error handling.PostgreSQL constraint errors now produce clean details with psycopg 3.
Write responses now eager-load schema relationships, avoiding
MissingGreenleterrors and sync N+1 queries.Rollback isolation now routes applications that configure a custom session generator alongside a matching sessionmaker through the isolated factory, instead of letting request writes escape the test transaction.
restly_project_rootnow resolves from the requesting test file, including in monorepos.restly_async_sessionno longer errors when a synchronous sessionmaker is also configured. Under rollback cleanup, it uses the same pinned connection asrestly_session, so committed writes are visible between them within a test.Engine and schema helpers now work inside session fixtures.
Removed#
activate_savepoint_only_mode()anddeactivate_savepoint_only_mode()are removed. They isolated each session separately rather than the whole test, so a write from one request was not reliably visible to the next. Callfr.testing.configure_tests()once inconftest.pyinstead; it gives the client, session fixtures, and directly opened sessions one transaction for the whole test.
0.8.0 - 2026-07-22#
Added#
fr.MustExist[int, Post]— an existence-checked scalar foreign key.post_id: fr.MustExist[int, Post]validates that the referenced row exists (a clean 404 on a miss, batched — no N+1) while the field stays a plain scalar everywhere: on the wire, in the column, in hooks (data.post_idis the id itself, not anIDRef/IDSchemawrapper), and to a type checker (data.post_idisint). The primary-key type comes first (fr.MustExist[UUID, Account]for a UUID key); when the column has a singleForeignKey, you can drop the model and let Restly infer it —fr.MustExist[int]. ReserveIDRef/IDSchemafor relationship-named fields.The opt-in
fr.configure(warn_on_misuse=True)lint now flags thepost_id: fr.IDRef[Post]mistake — anIDRef/IDSchemareference typed on a scalar foreign-key column, wheredata.post_idbecomes a wrapper instead of the plain id — steering tofr.MustExist[int, Post].IDRef/IDSchemaare now documented as relationship-named types, with*_idcolumns pointed atMustExist.
Changed#
The tier vocabulary in docstrings and error messages now matches the docs: endpoint method (was “route shell”), handler (was “request handler”), and business method (was “business verb”). The misuse warning (
RestlyMisuseWarning) and the bare-verb route-nameTypeErroruse the new terms; match on"endpoint method"/"business method"if you asserted on those messages.View.tagsis now typedClassVar[Iterable[str | Enum] | None]instead ofClassVar[Any], matching what FastAPI accepts for router tags. Runtime behaviour is unchanged; type checkers now flag a wrongly-typedtags.The
[testing]extra now installshttpx2alongsidehttpx. Newer StarletteTestClientprefershttpx2and emits aStarletteDeprecationWarningwhen onlyhttpxis present; shipping both keepsRestlyTestClienton the non-deprecated path without dropping support for the older Starlette versions in our range, which still importhttpxdirectly.RestlyTestClientnow prefershttpx2(falling back tohttpx), matching Starlette’s ownTestClient. The “install the[testing]extra” hint that Restly raises when the test client is missing now also fires under newer Starlette, which signals the absence ashttpx2(and via aRuntimeError) rather than a missinghttpx.
Removed#
The
orjsondependency and the orjson serializer on URL-created engines.JSONcolumns now use SQLAlchemy’s default encoder (the standard libraryjson) on every engine: naive datetimes are no longer silently stamped as UTC, and datetime/UUID values or keys now raiseTypeErrorat write time instead of being coerced to strings. To keep the old behavior, build your own engine withjson_serializer=/json_deserializer=and pass it tofr.configure().
Fixed#
Filtering on a dotted path deeper than one relationship hop (
?city.country.code=NL) no longer fails with a 500. Deep paths were advertised and resolved, but the filter clause collected its joins into an unordered set, so the second hop could be joined before the first — an implicit cartesian product that the database rejects as an ambiguous join. Filter joins now apply in path order, as sorting always did. Two shapes remain unsupported and are now documented as such: paths through a self-referential relationship, and two filter paths that reach the same table — those need per-path join aliasing.List views no longer advertise filter parameters that can never execute for collection-typed columns (
JSON/ARRAY, i.e. fields typeddict,list[...],Sequence, and similar). A query-string value cannot coerce into a collection, soeq/__in/__ne— and, for parametrized generics such aslist[str], the range family — answered 400 on every request; those parameters are now omitted from the generated schema and OpenAPI, and only__isnull, which works, is kept. On generated endpoints, requests using the removed parameters now fail validation as unknown parameters (422) instead of passing validation and failing at execution; callers passing rawQueryParamstoapply_list_paramsstill get the 400.pydantic.Json[...]fields keep their filters: their validation parses the query string into the collection, so they execute.Registering the same view class twice on the same app or router (
fr.include_view(app, V); fr.include_view(app, V)— a double import, or the decorator form combined with an explicit call) no longer mounts its routes twice. The duplicate call is now a no-op: each parent tracks the view classes already mounted on it, and an app and its.routerattribute count as one parent. The opt-infr.configure(warn_on_misuse=True)lint flags the duplicate call with aRestlyMisuseWarning. Registering on different parents (a public and an admin app,/v1and/v2sub-apps) still mounts on each, as before.fr.IDRef[T]/fr.IDSchema[T]foreign-key fields now work under any column name, not only fields ending in_id. A field likepost_fk: fr.IDRef[Post]backed by a non-_idFK column was silently misrouted — the resolved ORM object was assigned into the integer FK column and the request failed at flush (sqlalchemy.exc.ProgrammingError) instead of at validation. Reference routing (the create plan, the in-place update, and the both-supplied FK/relationship consistency check) now decides column vs. relationship and derives the partner attribute from the SQLAlchemy mapper rather than from the field name, so any FK column name resolves correctly.Creating with a relationship exposed as a reference field (e.g.
invoice: fr.IDRef[Invoice]) no longer fails when the model’s local FK column is a required constructor argument (noinit=Falseand no default) and a reference to an existing row is supplied. The resolved row’s id is now passed at construction instead of being assigned afterward, so the dataclass__init__no longer raisesTypeError: ... missing 1 required keyword-only argument. Declaring the FK columninit=Falseis still supported but no longer required.A null reference — an explicit
post=Nonefor apost: fr.IDRef[Post] | Nonefield, or such a field omitted and defaulting toNone— no longer raisesTypeError: __init__() missing 1 required keyword-only argument: 'post_id'when the model’s local FK column is a required constructor argument (noinit=Falseand no default). A null reference now takes a dedicated plan path that writes the field’s own slot and passes a partner kwarg (asNULL) at construction when the dataclass requires it — in either direction (FK column or relationship) and nothing more, so an unset sibling reference field (schemas may declare both names of an FK/relationship pair as reference fields) never clobbers the side the client supplied. A null reference to a nullable FK creates the row with a NULL FK; to a non-nullable FK it now fails at flush as a regularIntegrityError(the standard 409 path) instead of the 500TypeError.fr.IDRef[T]now serializes through the type itself under plain Pydanticfrom_attributes, so a reference field validated outside a Restly route — a nested model, a custom endpoint, orresponse_model=on a raw schema — no longer crashes with a crypticint_typeerror when the value is the related ORM row. Previously only the view layer’sto_response_schemahandled this, by pre-extracting the scalar id; that redundant special-casing is removed and bothIDRef[T]andIDSchema[T]self-serialize from a related row or a raw scalar id along a single path. Response output is unchanged.
0.7.0 - 2026-06-11#
Added#
The generated route shells (
get_many_endpoint,create_endpoint, …) now carry one-line override-redirect docstrings, sohelp(RestView), source readers, and coding agents see which tier to override (<verb>for domain logic,handle_<verb>for orchestration,to_responsefor shape). The docstrings are stripped from generated routes at registration so framework guidance never appears as OpenAPI operation descriptions in your API; endpoints you define or override yourself keep FastAPI’s normal docstring behavior.Scalar
fr.IDRef[T]foreign-key fields are now filterable on list endpoints by their own public name. Previouslypost_id: fr.IDRef[Post]— the FK form the tutorial teaches — generated no filter parameter at all, soGET /comments/?post_id=1returned a 422 that looked like client error; the only filterable form was a plainpost_id: int. AnIDRefid is treated as opaque, so it gets equality,__in,__ne, and__isnull(uniform across int/UUID/string primary keys) but not the range or substring operator families.Python 3.14 is now officially supported and tested. It was previously in the CI matrix as an experimental (allowed-to-fail) target while
orjsonlacked a 3.14 wheel; that wheel now ships, the full test suite passes on 3.14, and the job gates CI like every other supported version.fr.db.create_all(Base)/fr.db.async_create_all(Base)— dev/demo helpers that create every table for a declarative base (or aMetaData) on the engine configured viafr.configure(), replacing theengine = fr.db.get_async_engine(); async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all)boilerplate in quickstarts and test setup. Use Alembic migrations in production.Opt-in registration-time misuse warnings:
fr.configure(warn_on_misuse=True)makesinclude_viewlint each registered view class and emitfr.exc.RestlyMisuseWarningfor the three dominant misuse patterns — overriding a route shell (<verb>_endpoint) where a business-verb override was meant, callingsession.commit()directly in a view method, and hand-rolling a CRUD route set on a bareViewinstead of subclassingRestView/AsyncRestView. Each message names the idiomatic fix. Off by default; intended for development, project templates, and CI.
Changed#
The
RestlyUncommittedChangesWarningmessage now leads with the fix (bracket the mutation withwrite_action(...)or reuse ahandle_<verb>) and offers only the per-route suppression (session.info["_fr_suppress_uncommitted"] = True) for intentional dry runs. It no longer advertises the globalwarn_on_uncommitted=Falseopt-out, which readers took as a fix for the warning instead of committing their changes.Breaking — top-level
fr.*namespace curated. Errors, HTTP exceptions, and the uncommitted-changes warning moved tofr.exc(fr.exc.NotFound,fr.exc.RestlyError, …) — theexceptionsmodule is renamedexc, mirroringsqlalchemy.exc. Advanced helpers moved to their layer submodules: schema↔ORM helpers tofr.objects.*(make_new_object/save_object/snapshot/ … and theasync_*variants), list query helpers tofr.query.*(create_list_params_schema,apply_list_params), engine accessors tofr.db.*(get_engine,get_async_engine), andIDMixintofr.models.*. The route decorators (@fr.get/@fr.post/ … /@fr.route), views, registration, schemas, model bases,configure, the session helpers/dependencies, and the view support types (Action/ViewRoute/ResponseShape/ListingResult) stay top-level. Migration: e.g.fr.NotFound→fr.exc.NotFound,fr.make_new_object→fr.objects.make_new_object,fr.get_async_engine→fr.db.get_async_engine.
Fixed#
fr.open_session()/fr.open_async_session()now resolve the same session source asSessionDep/AsyncSessionDep: a custom session generator passed tofr.configure(session_generator=...)/sync_session_generator=...takes precedence over the built-in factory. Previously the context managers always used the built-in factory, so a generator-only configuration worked inside request handlers but raisedRestlyConfigurationErroroff-HTTP (in scripts, background jobs, or a custom dependency wrappingopen_*session()). The two session entry points are now consistent.
0.6.1 - 2026-06-02#
Changed#
Reference resolution (
IDRef/IDSchema) no longer mutates the validated request model in place. The resolver now returns a{field: resolved}mapping that the write path consumes, so the request model keeps its wire shape (its reference fields stayIDRef[T]values rather than being overwritten with ORM rows). Behavior of create/update is unchanged; the internal helpersbuild_create_plan/apply_update_to_object/validate_resolved_reference_consistencygained aresolvedargument.The
standardextra is now runtime-only and mirrorsfastapi[standard]. It no longer pulls the test toolchain (pytest,pytest-asyncio,pytest-cov,httpx) or bundles theaiosqlitedriver, so installingfastapi-restly[standard]for production no longer drags pytest into the image. Test tooling stays in thetestingextra; the database driver is now an explicit choice (Restly remains driver-agnostic). Migration: if you relied on[standard]for test dependencies, switch to[testing]; if you ran on SQLite, addaiosqliteto your dependencies directly.The
testingextra now includes only the third-party packages Restly’s shipped test helpers import —pytest,pytest-asyncio, andhttpx(forRestlyTestClientand therestly_*fixtures).pytest-covis no longer pulled in; add it yourself if you want coverage reports.Removed the
docsextra from the published extras. It only installed the toolchain for building Restly’s own documentation site (a maintainer concern); those dependencies remain in thedevdependency group. The published extras are nowstandardandtesting.
Fixed#
RestlyUncommittedChangesWarningno longer false-positives on every write run under the savepoint test fixtures: the patchedcommitclears the pending-changes flag (mimicking the realafter_commit), while a genuinely forgotten commit still warns.An
IDReflist field that references the same id more than once no longer raises a confusingId not found: set()404 when the referenced rows all exist; a genuinely missing id is now named in the error.An
IDReflist field now resolves in the client-sent order instead of silently reordering to the database’s primary-key order (duplicate ids are collapsed, first occurrence wins).A
ReadOnlyorWriteOnlymarker nested inside a field’s type instead of wrapping it (such asOptional[WriteOnly[str]],WriteOnly[str] | None, orlist[WriteOnly[str]]) is now rejected with aRestlyConfigurationErrorinstead of silently no-op’ing. Nested there the marker has no effect — aWriteOnlyfield would leak into responses and aReadOnlyfield would stay writable — so the framework now raises when the schema is defined (and again at view registration for schemas that do not derive fromBaseSchema), pointing to the safeMarker[Optional[T]]form.A list view no longer advertises filter query parameters for fields that are not filterable columns — a to-many relationship (
books: list[BookRef]) or a reference field that does not resolve to a column. These appeared in OpenAPI but always returned 400. Filter-param generation now validates each field against the model with the same column-resolution predicate the request path uses, so non-column fields no longer get filter params; to-one dotted traversal is unchanged. (create_list_params_schemanow takes the queriedmodelas a required argument.)
0.6.0 - 2026-06-01#
Reworks the class-based view API around a three-tier “handle” design. This is a breaking change; views written for 0.5.x need updating.
Changed (breaking)#
Each CRUD verb now has three tiers: route shell (
*_endpoint), request handler (handle_*), and domain verb (get_many,get_one,create,update,delete). This replaces thelisting/get/create/update/deleteendpoints and the singleperform_*tier.The framework owns commits.
handle_<verb>andwrite_actionrunbefore_commit→ commit →after_commit; request-session dependencies no longer commit on response. Custom write routes should reusehandle_<verb>or bracket mutations withself.write_action(...). Manualsession.commit()is only for shapes the bracket does not model, such as batch commits.commit_session_on_responseis removed; custom session generators construct and clean up sessions but do not own commits.Renamed:
creation_schema/update_schema→schema_create/schema_update;build_from_schema/apply_schema→make_new_object/update_object;count_listing→count. Response shaping goes through a singleto_response(obj_or_list, shape=ResponseShape.SINGLE)method.ViewRoute.LIST/GET→ViewRoute.GET_MANY/GET_ONE.
Added#
authorize(action, obj, data)override — an empty override by default; raisefr.Forbidden/fr.NotFoundto gate a verb (row visibility goes inbuild_query).before_commit/after_committransaction hooks and asnapshot()helper for old-vs-new comparison.Typed request-time exceptions
NotFound,Forbidden,Conflict, andBadQueryParam, subclassingfastapi.HTTPException(so a singleapp.add_exception_handler(fr.NotFound, ...)can reshape them).Top-level
make_new_object/update_object/save_object/delete_object/snapshothelpers (and theirasync_*variants where applicable) for use outside a view.write_action(action, *, obj, data)— a context manager for custom write actions. It shares the CRUD authorize + commit bracket. Create-shaped actions that omitobj=must assign the yielded handle’s.objbefore exit.RestlyUncommittedChangesWarning(default on;warn_on_uncommitted=Falseto disable) when a request finishes with uncommitted changes — the tell of a write route that forgot to commit.
Fixed#
A
@routemethod named like a bare verb (create/update/delete/get_one/get_many) is now rejected at registration: it shadowed the verb and collided with its*_endpointroute shell.Registering a View subclass alongside its parent on the same app no longer duplicates the child’s routes.
React Admin list/count/update paths now use the same
build_query,count, authorization, and commit lifecycle as standard REST views; filter/sort resolution is limited to public schema fields.A paginated list sorted on a non-unique column now appends the primary key as a final
ORDER BYtiebreaker, so rows are no longer skipped or repeated across pages. Applies to both the standard and React Admin sort paths.A
build_querythat joins a to-many relationship no longer fans out:get_manyde-duplicates entities and the list total counts distinct rows, so the page andtotal_countagree. A no-op for queries without such a join.A
WriteOnlyfield no longer leaks into a response, including from a nested response schema.WriteOnlyfields are now excluded from serialization at the field level (exclude=Trueon the marker), so they are stripped recursively on the wire and dropped from the OpenAPI response schema, while staying required, documented request inputs. PreferWriteOnly[Optional[T]]overOptional[WriteOnly[T]]— aWriteOnlymarker buried only inside a union is not excluded.
0.5.1 - 2026-05-11#
Fixed#
Fixed
fr.include_view(...)registration onfastapi.APIRouterparents.
0.5.0 - 2026-05-06#
First public beta release.
Added#
Class-based CRUD views for async and sync SQLAlchemy sessions with generated list, get, create, update, and delete routes.
React Admin compatible
AsyncReactAdminViewandReactAdminViewvariants for thera-data-simple-restwire contract.Generated schema support for read, create, and update payloads, including
ReadOnly,WriteOnly,IDSchema,IDRef, and timestamp schema helpers.Standard list query support for filtering, sorting, pagination, relation aliases, and pagination metadata.
Public
RestlyErrorandRestlyConfigurationErrorexception hierarchy.Testing utilities through
RestlyTestClient, savepoint-only mode helpers, and thefastapi_restly.pytest_fixturespytest plugin.
Changed#
Consolidated framework setup on
fr.configure(...), including async/sync engine configuration and response-session commit policy.Renamed built-in route methods to resource-oriented names:
list,get,create,update, anddelete.Renamed business-logic hooks to
perform_list,perform_get,perform_create,perform_update, andperform_delete.Standardized schema component names on
ModelRead,ModelCreate, andModelUpdate.Made
sortthe standard list ordering parameter.Split
__containsand__icontainsso case-sensitive and case-insensitive matching have distinct public operators.Exposed savepoint-only testing helpers through
fastapi_restly.testinginstead of the top-level package namespace.Renamed
build_listing_querytobuild_queryand broadened its role:perform_getnow also routes through this hook, so a single override filters listing, the pagination total, and single-row fetches. A row hidden from listing returns 404 fromGET /{id}too, andperform_update/perform_deleteinherit the visibility check viaperform_get.perform_getnow issuesSELECT ... WHERE pk = ?instead ofsession.get(...). Single-column primary-key behavior is unchanged. Composite-primary-key subclasses must overrideperform_get.Advanced schema-to-object helpers now live in
fastapi_restly.objects:build_from_schema,apply_schema,save_object, anddelete_object, with async equivalents. The view methods use the same names for the mapping hooks and keep persistence at thesave_object/delete_objectboundary.
Removed#
Removed the pre-stable
query=argument fromperform_listing. Overridebuild_query()for SQL-level base query changes so listing, pagination totals, and single-row fetches stay aligned.Removed pre-release route and hook names such as
index,get,post,patch,delete,handle_list, andhandle_getfrom the public view API.Removed unsupported
get_one_or_createhelpers before the first stable release.Removed internal model helpers such as
TableNameMixin,underscore, andutc_nowfrom the public API surface.Removed duplicate pytest fixture exports from
fastapi_restly.testing; the pytest plugin path isfastapi_restly.pytest_fixtures.