Extend the generated API¶
Keep the generated routes and change only the behaviour your application needs.
Use a hook for a small adjustment, an action for a business operation, an override
for a replacement implementation, or a managed route for an unrelated endpoint.
Existing Meta configuration, callbacks, plugins and schema_constructor
remain supported.
Register before initialisation¶
Create Architect() without an app, register your extensions, then call
init_app inside your application context, after configuring your models.
This lets flarchitect resolve generated paths, detect conflicts and include
all the routes in OpenAPI from the start. Registration after init_app raises
a clear error. Create a separate Architect instance for each application.
architect = Architect()
@architect.hook(Order, "before_create")
def set_owner(order, context):
order.owner_id = context.user.id
return order
with app.app_context():
architect.init_app(app)
No extensions are required for ordinary CRUD. Existing Architect(app) setup
continues to work when you do not need these registrations.
Choose the smallest extension¶
Need different fields, roles, allowed methods or paths? Start with model
Metaconfiguration.Need tenant or owner isolation? Use
API_ACCESS_POLICY.scope_queryand the correspondingcan_read,can_create,can_updateandcan_deletechecks. Query hooks are customisation, not a replacement for access policies.Need to adjust a query or a record before saving? Use
hook.Need to approve an order or cancel a booking? Use
action.Need a different implementation of one CRUD operation? Use
override.Need a health endpoint or a non-model response? Use
route.
Hooks: keep the generated implementation¶
All hooks take (value, context). Return a replacement value, or return
None to keep the current value after modifying it. Multiple hooks run in
registration order, after the corresponding legacy callback.
@architect.hook(Order, "query")
def open_orders(query, context):
return query.filter(Order.status == "open")
@architect.hook(Order, "before_update")
def check_order(order, context):
if order.quantity < 1:
abort(422, "Quantity must be at least one")
return order
Available events:
query: receives the collection Query before sorting, counting and pagination. Access-policy scope is reapplied afterwards. It does not filter detail reads or mutations; use an access policy for restrictions that must apply everywhere.before_create: receives the constructed record before it is saved.before_update: receives the record with validated changes applied, before saving.before_delete: receives the record before the built-in deletion.after_operation: receives the record or list of records before serialisation; pagination metadata stays managed by the framework. Creates and updates supply the record, and actions supply their handler’s return value. Deletion supplies the existing(None, 200)success tuple.
after_operation is for response adjustments, not validation or external
side effects: ordinary generated writes have already committed at that point.
Use a before hook for validation. Extra response fields must also be declared
in your output schema.
Actions: add business logic¶
@architect.action(Order, "approve", methods=["POST"])
def approve_order(order, context):
order.approved = True
return order
This adds POST /api/orders/<id>/approve using the model’s actual path and
primary-key converter. It also follows API_PREFIX and alternate
Meta.endpoint paths. Existing CRUD and relationship routes remain available.
POST is the default; GET, PATCH, PUT and DELETE are also supported. Actions are
record-level; use route for a collection-wide or unrelated operation.
The framework loads the record within the configured policy scope, checks read
access and, for writes, update access (delete access for DELETE). These policy
calls receive the action name, such as approve. Authentication, role and
rate-limit settings are inherited from the model. A blocked method or read-only
model cannot gain a write endpoint through an action.
Use permission for an additional business permission; it must return a
truthy value to allow the action. It cannot bypass the model’s policy checks.
from marshmallow import Schema, fields
class ApprovalInput(Schema):
reason = fields.String(required=True)
@architect.action(
Order,
"approve",
input_schema=ApprovalInput,
roles=["manager"],
permission=lambda order, context: order.status == "pending",
)
def approve_order(order, context):
order.approval_reason = context.data["reason"]
order.status = "approved"
return order
Action input is not inferred from the model: declare input_schema when the
action accepts a body, then read validated values from context.data. Output
defaults to the model’s generated schema. Override output_schema for a
different response, or use output_schema=None for a plain dictionary.
summary, group_tag, roles, roles_any_of and auth are passed to
the existing schema wrapper. auth=False is an explicit authentication opt-out;
it does not disable access policies.
Overrides: replace one operation¶
@architect.override(Order, "create")
def create_order(data, context):
return Order(name=data["name"].strip().upper())
Only the generated create implementation changes. Input validation, output schema, authentication, roles, rate limits and OpenAPI stay in place. An override applies to the model’s canonical and alternate CRUD paths, not relationship operations. Registering a second override for the same operation, or an override for an operation that has no generated route, fails during setup.
Supported contracts:
create(data, context): receives validated input and returns a model instance. The framework adds it to the session and flushes its generated ID.update(record, context): receives the scoped, authorised record; read the validated patch fromcontext.dataand apply your changes explicitly. Return a model instance.delete(record, context): receives the scoped, authorised record. Perform the deletion withcontext.session.delete(record)or implement your own soft delete. ReturnNone; the framework emits the standard success response.retrieve(record, context): receives the visible record and returns the value to serialise with the existing output schema.list(query, context): receives the generated query, after normal filters and query hooks, and must return a SQLAlchemy Query. Sorting, soft-delete filtering, counts and pagination still run afterwards. Policy scope is reapplied so a replacement query cannot drop it. Keep the query’s model shape; use a managed route for reports with unrelated rows or aggregates.
Mutation overrides replace the persistence implementation, including legacy
add/update/remove callbacks. The new before_create and before_update
hooks still run on the returned model. A delete override owns its own deletion
behaviour; use the built-in deletion and before_delete when you only need a
small adjustment. Generated setup/return callbacks and operation plugins remain
around the replacement operation.
Transaction ownership¶
For write actions and mutation overrides, flarchitect flushes changes before
serialisation and commits only after the response is successfully constructed.
Handler exceptions, validation/serialisation errors and unsuccessful responses
roll back the session. Handlers must not commit or close the session themselves.
Use context.session.flush() when you need an ID earlier.
The transaction covers the resolved SQLAlchemy session only. External services, email, and other databases cannot be rolled back by flarchitect. Keep durable external work behind an outbox or your application’s own delivery mechanism. GET actions are read-only: their session is rolled back after the response. Ordinary generated CRUD keeps its existing commit timing.
One context everywhere¶
OperationContext is exported from flarchitect for type annotations.
It provides model, session, operation, data (validated input),
user (the current authenticated user, possibly None) and request.
Operation names are list, retrieve, create, update, delete, or
an action’s registered name. Context instances belong to one request.
Managed routes: one decorator¶
@architect.route("/health", methods=["GET"], auth=False)
def health():
return {"healthy": True}
@architect.route("/echo", methods=["POST"], input_schema=ApprovalInput)
def echo(deserialized_data):
return deserialized_data
Paths are full Flask paths: API_PREFIX is not added to route. Use Flask
path variables as usual. endpoint sets an explicit Flask endpoint name;
remaining options go to schema_constructor. Without an output schema,
dictionaries are wrapped in the normal response envelope. These routes receive
the same wrapper validation, authentication, rate limiting and documentation
as manual routes, but do not automatically load records or manage transactions.
Conflicting paths/methods raise a setup error rather than silently replacing a
route. To replace generated CRUD, use override. Existing Flask blueprints
and @app.route plus @architect.schema_constructor remain available.
Runnable example¶
See demo/extensions/app.py for a complete in-memory application containing
a hook, action, create override and managed route. Run it from the repository:
python -m demo.extensions.app
curl -X POST http://127.0.0.1:5000/api/orders \
-H 'Content-Type: application/json' -d '{"name":"new order"}'
curl -X POST http://127.0.0.1:5000/api/orders/1/approve
curl http://127.0.0.1:5000/docs/apispec.json
The demonstration disables authentication for local use; configure authentication and access policies before exposing an application to other users.