Skip to main content

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)

Source distribution for scrudful 0.4.0
File Size Uploaded Provenance
scrudful-0.4.0.tar.gz 157.6 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for scrudful 0.4.0
File Interpreter ABI Platform
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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page