Nested Resources¶
NestedConfig binds a child resource to a parent from the URL. It works with
SQLAlchemy (sync/async), Tortoise (async), and Peewee (sync). Existing viewsets
retain their behavior when nested is omitted.
from fastapi_viewsets import BaseViewset, NestedConfig
comments = BaseViewset(
endpoint="/posts/{post_id}/comments",
model=Comment,
response_model=CommentSchema,
db_session=get_session,
nested=NestedConfig(parent_model=Post, foreign_key="post_id"),
)
comments.register()
app.include_router(comments)
Each generated route resolves the parent once and restricts the child operation to that parent. Missing parents and children belonging to a different parent return 404. Search, filters, ordering, and pagination operate within this scope.
Full example¶
This example uses SQLAlchemy with a real foreign key. A comment can be created
with just {"text": "Hello"}; the URL supplies post_id.
Install the SQLAlchemy extra with pip install "fastapi-viewsets[sqlalchemy]".
from typing import Optional
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, ForeignKey, Integer, String
from fastapi_viewsets import BaseViewset, NestedConfig
from fastapi_viewsets.orm.sqlalchemy_adapter import SQLAlchemyAdapter
adapter = SQLAlchemyAdapter(database_url="sqlite:///nested.db")
Base = adapter.get_base()
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
class Comment(Base):
__tablename__ = "comments"
id = Column(Integer, primary_key=True)
post_id = Column(Integer, ForeignKey("posts.id"), nullable=False)
text = Column(String, nullable=False)
class PostSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: Optional[int] = None
title: str
class CommentSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: Optional[int] = None
post_id: int
text: str
Base.metadata.create_all(adapter.engine) # Use migrations in production.
app = FastAPI()
posts = BaseViewset(
endpoint="/posts", model=Post, response_model=PostSchema,
db_session=adapter.get_session, orm_adapter=adapter,
)
posts.register()
comments = BaseViewset(
endpoint="/posts/{post_id}/comments", model=Comment,
response_model=CommentSchema,
db_session=adapter.get_session, orm_adapter=adapter,
nested=NestedConfig(parent_model=Post, foreign_key="post_id"),
)
comments.register()
app.include_router(posts)
app.include_router(comments)
For async SQLAlchemy or Tortoise use AsyncBaseViewset with the appropriate
adapter/session factory. Parent and child use the same adapter and session
factory. The library does not add database columns, constraints, or migrations.
Configuration¶
| Setting | Meaning |
|---|---|
parent_model |
Parent ORM class; its single primary key must be exposed as id |
foreign_key |
Child ORM field or supported raw FK accessor |
path_param |
Parent path parameter name; defaults to foreign_key |
parent_soft_delete |
Optional SoftDeleteConfig for filtering the parent lookup |
SQLAlchemy uses the mapped scalar attribute, such as post_id, even when the
database column has a different name. Tortoise supports post and post_id for a
ForeignKeyField. Peewee supports the ForeignKeyField name and its raw-ID
accessor (post_id by default; object_id_name can customize it).
The configuration rejects missing fields, primary-key fields, declared
relations to another parent model/key, and conflicting CRUD parameter names.
Parent IDs support integer, string, and UUID model types. Invalid integer/UUID
path values return 422. Composite parent keys and relations targeting fields
other than the parent's id are not supported.
You can put the parent path in an outer router:
comments = BaseViewset(
endpoint="/comments", model=Comment, response_model=CommentSchema,
db_session=get_session,
nested=NestedConfig(parent_model=Post, foreign_key="post_id", path_param="post_pk"),
)
comments.register()
parent_router = APIRouter(prefix="/posts/{post_pk}")
parent_router.include_router(comments)
app.include_router(parent_router)
The final mounted route must contain the configured path parameter. A request without it fails validation; the viewset never falls back to an unscoped query.
Request and response behavior¶
| Operation | Behavior |
|---|---|
| LIST | Verifies the parent and filters children before pagination |
| GET | Looks up the child by both its ID and parent |
| POST | Binds the foreign key to the resolved parent's ID |
| PUT/PATCH | Restricts lookup/update to the parent and prevents reparenting |
| DELETE | Deletes only a child within the parent scope |
| RESTORE | Restores only a deleted child within the parent scope, when soft deletion is enabled |
URL binding takes precedence over FK values in JSON bodies. The server supplies
the bound value before validating the body and enforces it again in the ORM
operation. Ordinary body constraints and validators are preserved. The original
response schema is not modified, and the parent field is optional in the generated
request schema. Schemas may omit the FK entirely. Flat string aliases and
AliasChoices are supported; deep AliasPath bindings are rejected during
registration. PATCH retains the existing partial-model validation rules.
Query parameters cannot override the parent scope. Scoped physical deletion uses normal ORM instance deletion, preserving its lifecycle and cascade behavior.
Parent context and permissions¶
Inside a generated handler, self.get_parent() returns the resolved parent
object. It is also available as request.state.viewset_parent. Context is local
to each request and is propagated to synchronous handlers running in worker
threads. The router itself never stores the current parent object.
Override resolve_parent(request) to add application-specific parent access
checks. The async viewset uses an async hook:
from fastapi import HTTPException, Request
from fastapi_viewsets import AsyncBaseViewset
class AuthorizedComments(AsyncBaseViewset):
async def resolve_parent(self, request: Request):
parent = await super().resolve_parent(request)
# Your authentication dependency supplies request.state.user.
if not request.state.user.can_access(parent):
raise HTTPException(status_code=403, detail="Forbidden parent")
return parent
The default resolver raises 404 for missing parents. In a synchronous viewset,
use def resolve_parent(...) and call super().resolve_parent(request).
Authentication configured through register() runs before parent lookup for
protected operations. FK scoping does not automatically implement ownership or
tenant permissions; use your normal authentication dependency and this hook.
For custom routes added outside register(), opt into the context dependency
explicitly and configure their authentication as usual:
from fastapi import Depends, Request
@app.get("/posts/{post_id}/summary", response_model=PostSchema,
dependencies=[Depends(comments.parent_context)])
def parent_summary(post_id: int, request: Request):
return request.state.viewset_parent
get_parent() and nested CRUD methods require the current viewset's
parent_context dependency. Direct calls without that request context raise a
clear error instead of querying all children. One configured parent is supported
per viewset; for additional ancestor URL parameters, validate their relationship
in resolve_parent().
Combining with soft deletion¶
policy = SoftDeleteConfig()
comments = BaseViewset(
endpoint="/posts/{post_id}/comments", model=Comment,
response_model=CommentSchema, db_session=get_session,
soft_delete=policy,
nested=NestedConfig(parent_model=Post, foreign_key="post_id", parent_soft_delete=policy),
)
comments.register()
Both models must have the configured marker column. Child and parent policies
are independent: configure parent_soft_delete explicitly; the library does not
infer it from a separate parent router. A deleted parent blocks every nested
operation, including child restoration. Restore the parent before restoring a
child through POST /posts/{post_id}/comments/{id}/restore.
Soft deletion does not cascade marker changes to children. See Soft Delete & Restore for restoration permissions and conflicts.
Custom ORM adapters¶
Existing adapters need no changes while nested=None. Supporting nesting is
explicit: set supports_nested = True, accept and enforce the optional scope
keyword in CRUD and supported restore methods, and normalize FK field aliases
through get_nested_field(model, field). Parent lookup uses the existing
get_element_by_id or get_element_by_id_async method. The base class supplies
optional hooks for parent ID types and declared-relation validation, without
adding abstract methods. scope contains trusted server-side equality predicates;
it must not be replaced by client query filters or mutable fields in request bodies.