scrudful
Helpers for building SCRUDful REST APIs (Search, Create, Read, Update, Delete) that get HTTP conditional requests right, for Django REST Framework, django-ninja and FastAPI.
A decorated view or endpoint:
evaluates If-Match, If-None-Match, If-Modified-Since and If-Unmodified-Since as specified by RFC 9110 section 13, answering 304, 400, 412 or 428 without calling your code when appropriate;
sets ETag and Last-Modified on the responses that should carry them;
adds Link headers pointing at the JSON schema and the JSON-LD context of the response body (RFC 8288);
exposes ETag, Link and Location to browsers through Access-Control-Expose-Headers.
The HTTP logic is framework-agnostic and lives in scrudful.conditional and scrudful.utils; scrudful.rest_framework, scrudful.ninja and scrudful.fastapi are thin adapters. The package has no required dependencies.
Installation
$ pip install "scrudful[drf]" # Django >= 4.2, djangorestframework >= 3.15
$ pip install "scrudful[ninja]" # Django >= 4.2, django-ninja >= 1.7, < 2
$ pip install "scrudful[fastapi]" # fastapi >= 0.100
Python 3.10 or newer is required.
How it works
You give the decorator validator callbacks: etag_func and/or last_modified_func, which return the validators of the current representation of the target resource (or None if there is none). With them configured:
Status |
When |
|---|---|
304 |
GET/HEAD: If-None-Match matches the ETag (weak comparison), or, with no If-None-Match, the resource is not newer than If-Modified-Since. The response carries ETag and Last-Modified. |
400 |
If-Match or If-None-Match is present but malformed (for example an unquoted tag). |
412 |
If-Match does not match (strong comparison), or, with no If-Match, the resource is newer than If-Unmodified-Since; or If-None-Match matches on a method other than GET/HEAD (for example If-None-Match: *, “create only”, on a PUT to an existing resource). |
428 |
PUT, PATCH or DELETE on an existing resource without If-Match, without If-None-Match: * and without a valid If-Unmodified-Since. If-Match or If-Unmodified-Since is enough to update; the body lists them. |
The error responses are JSON ({"detail": ...}), and none of them calls your view.
Rules worth knowing:
Validators are opt-in. Preconditions, and the 428 requirement, apply only when etag_func or last_modified_func is configured. Without them a decorated view behaves like an undecorated one, apart from the Link and expose headers.
Invalid dates are ignored. An invalid If-Modified-Since or If-Unmodified-Since is treated as absent, so a lone invalid If-Unmodified-Since on a write is answered with 428.
Callbacks returning ``None`` mean “no current representation”: the preconditions are skipped and your view produces its own 404. The exception is PUT, which may create the resource: If-None-Match: * passes and If-Match gives 412. POST is not an exception: when both callbacks return None its preconditions are skipped too, so even a failing If-Match reaches your view.
Callbacks run first, before your view’s own lookup and permission checks, so they must apply the same scoping (for example view.get_queryset() with DRF, or the current user from a dependency with FastAPI). Otherwise a resource the view would hide reveals its validators (304) and its existence (412 or 428 instead of 404). The django-ninja adapter avoids this by design: its callbacks get the object your load callable authorized.
Validators on responses. 2xx responses to GET/HEAD get ETag and Last-Modified; 2xx responses to PUT/PATCH get freshly recomputed ones (the callbacks run again, so they describe the new representation); POST, DELETE and non-2xx responses get none. Headers your view sets itself are never overwritten.
ETags. str() of the callback’s result is used. A valid entity-tag ('"abc"', 'W/"abc"') is sent as is, anything else is wrapped in double quotes. A value that cannot be an entity-tag (it contains a double quote, a space, a control character or a character above U+00FF) raises ValueError. No library version is appended. Weak tags are allowed, but If-Match uses the strong comparison, which a weak tag never passes: with a weak etag_func only If-Unmodified-Since (or If-Match: *) can make a write succeed.
Datetimes. last_modified_func returns a datetime or an epoch timestamp. Aware datetimes are converted to UTC and sub-second precision is discarded. A naive datetime is taken to be UTC by the FastAPI adapter and to be in settings.TIME_ZONE by the DRF and django-ninja adapters (the same thing when TIME_ZONE is "UTC"). Prefer aware datetimes.
Link headers. The schema and context links are added to every response your view returns and are appended to any Link value it set. Each *_or_func argument is a value or a callable.
Concurrency
Preconditions are evaluated before your view runs, so two concurrent requests carrying the same If-Match can both pass. For strict lost-update protection the write itself must re-check atomically: with Django, run the request in a transaction (ATOMIC_REQUESTS = True or transaction.atomic) and lock the row with select_for_update() (the example project does it in its validator callbacks); with any storage, make the write conditional, for example UPDATE ... WHERE version = <expected>.
CORS
ETag, Link and Location are added to Access-Control-Expose-Headers (merged with an existing value). CORS middleware that sets that header itself replaces it on cross-origin requests, so list the names in the middleware’s own configuration: expose_headers for Starlette’s CORSMiddleware, CORS_EXPOSE_HEADERS for django-cors-headers.
Django REST Framework
Callbacks are called as func(view, request, **url_kwargs). @scrudful_viewset reads its configuration from an inner Meta class (list_* options configure the list response):
from rest_framework import viewsets
from rest_framework.reverse import reverse
from scrudful.rest_framework import scrudful_viewset
from items.models import Item
from items.serializers import ItemSerializer
# The callbacks run before the view's lookup: use its queryset.
def item_etag(view, request, pk):
items = view.get_queryset().filter(pk=pk)
version = items.values_list("version", flat=True).first()
return None if version is None else f"v{version}"
def item_last_modified(view, request, pk):
items = view.get_queryset().filter(pk=pk)
return items.values_list("updated_at", flat=True).first()
def item_schema_url(view, request, **kwargs):
return reverse("item-schema", request=request)
@scrudful_viewset
class ItemViewSet(viewsets.ModelViewSet):
queryset = Item.objects.all()
serializer_class = ItemSerializer
class Meta:
etag_func = item_etag
last_modified_func = item_last_modified
schema_link_or_func = item_schema_url
schema_type_or_func = "application/schema+json"
For single methods use @scrudful(...) on a view method or function view (below @api_view), and @scrudful_api_view(["GET", "PUT"], ...) as a replacement for @api_view. OPTIONS requests are answered with an OpenAPI-like description of the methods the route serves (ScrudfulMetadata). See examples/scrudful_rest_framework for a complete project.
Caveats of scrudful_viewset:
The item link callbacks also run for create and for OPTIONS and POST on the list route, where there is no pk. Accept **kwargs, as item_schema_url does above.
An explicit @scrudful on update applies to PATCH when partial_update is DRF’s stock one (decorate a subclass of a decorated viewset with scrudful_viewset too, or it inherits the parent’s wrapper). With a user-defined partial_update that calls update, the Meta-configured wrapper of partial_update wins: decorate partial_update explicitly to override it.
OPTIONS uses ScrudfulMetadata only when the viewset’s metadata_class is DRF’s default SimpleMetadata. A custom metadata_class (or None, or a project-wide DEFAULT_METADATA_CLASS) is kept; subclass ScrudfulMetadata to get its output with your own additions.
Methods the class does not have are not added (a read-only viewset answers 405 to writes).
FastAPI
Callbacks are called with the keyword arguments FastAPI resolved for the endpoint (path, query, body and dependency values, plus the endpoint’s own Request/Response parameters), so accept **kwargs for the ones you don’t need. Callbacks and endpoints may be def (run in the threadpool) or async def.
from datetime import datetime, timezone
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from scrudful.fastapi import (
CONDITIONAL_READ_RESPONSES,
CONDITIONAL_WRITE_RESPONSES,
scrudful,
)
app = FastAPI()
users = {
1: {
"name": "John",
"version": 1,
"updated_at": datetime(2021, 5, 25, tzinfo=timezone.utc),
}
}
def user_etag(user_id: int, **kwargs: Any) -> str | None:
user = users.get(user_id)
return None if user is None else f"user-{user_id}-v{user['version']}"
def user_last_modified(user_id: int, **kwargs: Any) -> datetime | None:
user = users.get(user_id)
return None if user is None else user["updated_at"]
@app.get("/users/{user_id}", responses=CONDITIONAL_READ_RESPONSES)
@scrudful(etag_func=user_etag, last_modified_func=user_last_modified)
async def get_user(user_id: int) -> dict:
if user_id not in users:
raise HTTPException(404, "User not found")
return users[user_id]
@app.put("/users/{user_id}", responses=CONDITIONAL_WRITE_RESPONSES)
@scrudful(etag_func=user_etag, last_modified_func=user_last_modified)
async def replace_user(user_id: int, user: dict) -> dict:
if user_id not in users:
raise HTTPException(404, "User not found")
record = users[user_id]
record.update(
user, version=record["version"] + 1, updated_at=datetime.now(timezone.utc)
)
return record
Notes:
@scrudful(...) must sit below the route decorator. Above it, FastAPI registers the undecorated function and the decorator silently has no effect. All arguments are keyword-only.
The 304/400/412/428 responses are produced without calling the endpoint, so FastAPI doesn’t know about them. Add CONDITIONAL_READ_RESPONSES (GET/HEAD) or CONDITIONAL_WRITE_RESPONSES (PUT/PATCH/DELETE) to the route’s responses= to document them in OpenAPI; merge them with your own: responses={**CONDITIONAL_READ_RESPONSES, 404: {...}}.
FastAPI answers 405 to HEAD on a route declared with @app.get. Use @app.api_route(path, methods=["GET", "HEAD"]) to serve HEAD.
To have a link callback build absolute URLs, declare a request: Request parameter on the endpoint (request.url_for(...)).
See examples/fastapi/api.py for a complete application (run it with python examples/fastapi/api.py).
django-ninja
scrudful.ninja follows one rule: validators and precondition results are computed only from what authorization already approved. Its decorators run inside the operation, after ninja’s authentication, throttling and parameter validation; an item operation’s load(request, **params) is the fetch-if-allowed callable, the validator callbacks get the object it returns, and so does the handler. It does not need Django REST Framework.
from typing import List
from django.shortcuts import get_object_or_404
from ninja import NinjaAPI
from scrudful.ninja import (
CONDITIONAL_READ_RESPONSES,
CONDITIONAL_WRITE_RESPONSES,
ScrudfulRouter,
scrudful,
scrudful_create,
scrudful_list,
)
api = NinjaAPI(auth=..., default_router=ScrudfulRouter())
roles = ScrudfulRouter() # adds OPTIONS to every path with a scrudful operation
api.add_router("/roles", roles)
def authorized_role(request, pk: int, **params):
# Missing and hidden are both 404, for every method and If-* header.
return get_object_or_404(Role, pk=pk, rbac_context=request.auth)
ROLE = dict(
etag_func=lambda role: role.etag,
last_modified_func=lambda role: role.modified_at,
schema_link_or_func="/schemas/role",
context_link_or_func="/contexts/role",
)
@roles.get("/{int:pk}", response=RoleOut,
openapi_extra={"responses": CONDITIONAL_READ_RESPONSES})
@scrudful(load=authorized_role, **ROLE)
def get_role(request, pk: int, obj: Role):
return obj
@roles.put("/{int:pk}", response=RoleOut,
openapi_extra={"responses": CONDITIONAL_WRITE_RESPONSES})
@scrudful(load=authorized_role, **ROLE)
def replace_role(request, pk: int, payload: RoleIn, obj: Role):
...
@roles.post("", response={201: RoleOut}) # 201, Location, item validators
@scrudful_create(location_func=lambda request, role: f"/roles/{role.pk}", **ROLE)
def create_role(request, payload: RoleIn):
return Role.objects.create(**payload.dict())
@roles.get("", response=List[RoleOut]) # the scrud list envelope, paginated
@scrudful_list(href_func=role_url, **ROLE)
def list_roles(request):
return Role.objects.filter(rbac_context=request.auth).order_by("pk")
Notes:
@scrudful(...), @scrudful_create(...) and @scrudful_list(...) must sit below the operation decorator (above it, they raise TypeError).
load returns the object, None (“no current representation”, RFC 9110 semantics, e.g. for a PUT that may create) or raises, through ninja’s exception handlers; an object it returns exists even if its validators are None. Whether PUT may create and how hidden rows are reported is yours to choose; the docs show the four patterns.
Use ScrudfulRouter (and NinjaAPI(default_router=ScrudfulRouter(...)) for operations on the API itself): it answers OPTIONS with the semantic-http “HTTP Schema Resource”, the same for every client, without auth. Scrudful operations on a plain Router get no OPTIONS.
Entity(...) declares request and per-status response entities and the error entity whose Links scrudful’s 400, 412 and 428 bodies carry; a handler parameter annotated ScrudfulResponse sets the status, Location, validators or Links of one response.
async def handlers are supported; sync callables then run through sync_to_async.
scrudful.collection builds the JSON Schema and JSON-LD context of the list envelope.
See examples/ninja for a complete project, and the documentation’s django-ninja page for the details.
Development
$ uv sync
$ uv run pytest
$ uv run --group docs make -C docs html
Releasing
A merge request that changes the package (scrudful/, or the published parts of pyproject.toml) sets the version to release; the version-check job fails until it does. Pick the bump and commit pyproject.toml and uv.lock:
$ uv version --bump minor # or --bump patch / --bump major, or: uv version 1.0.0
Merging to master publishes that version to PyPI and creates the vX.Y.Z tag and GitLab release. Merges that leave the version unchanged (docs, tests, CI) publish nothing.
License
MIT. The conditional-request core adapts code from Django (BSD-3-Clause); its license is in LICENSES/Django-BSD-3-Clause.txt.
Metadata
Release files for scrudful 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| scrudful-0.4.0.tar.gz | 157.6 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| scrudful-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 223.7 kB
Release files / scrudful-0.4.0.tar.gz
| Download URL | scrudful-0.4.0.tar.gz |
|---|---|
| Size | 157.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
286e43ec399ce65de2c21ac414bfa9c9237df8b98848ec2e1abe5d486a33663d
|
|
BLAKE2b-256 checksum How to use checksums |
3b913bf22041acd6b2149fdc6c6ecfc6451553936b7223415e1c315673b4437c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.0
|
Release files / scrudful-0.4.0-py3-none-any.whl
| Download URL | scrudful-0.4.0-py3-none-any.whl |
|---|---|
| Size | 66.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
719d60934b95aa9dff94abcda5d92b1e9bbf704e85662f2b0bf7011b0c784449
|
|
BLAKE2b-256 checksum How to use checksums |
2787439d8ce5f31f14422890f6761872b2625c3d99cc04a329ab399715d17022
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.0
|
Security for scrudful 0.4.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.