PH1.3 — API platform: pagination, idempotency, ETag, soft-delete repository, crud_router #68

Merged
bart merged 5 commits from feature/api-platform into main 2026-09-11 11:50:08 +00:00
Collaborator

What this does

Builds the shared plumbing every master-data screen (crews, vehicles, sites, catalogue, ...) will need: consistent pagination and filtering, safe concurrent edits (so two people editing the same record can't silently overwrite each other), protection against a flaky connection causing the same "create" to happen twice, soft delete instead of permanent delete, and a required "reason" on every change so the audit trail actually says why something changed.

Nothing user-facing changes yet — this is the reusable factory that upcoming CRUD tickets will build their actual endpoints on top of, not an endpoint itself.

Known limitation, flagged for later

The duplicate-request protection currently lives in memory rather than the database, because the next available migration slot is already claimed by another in-flight ticket. Practically this means: on an API restart or with multiple API instances running, that specific protection resets — worst case is a retried request isn't deduplicated (same as if this feature didn't exist yet), never a wrong or double result. Flagged as a follow-up for whoever picks up the next migration.

Also made two small, isolated additions to shared files outside this ticket's own scope, needed to get "reason" flowing through to the audit log — called out on the issue for visibility.

Closes #11.

🤖 Generated with Claude Code

https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM

## What this does Builds the shared plumbing every master-data screen (crews, vehicles, sites, catalogue, ...) will need: consistent pagination and filtering, safe concurrent edits (so two people editing the same record can't silently overwrite each other), protection against a flaky connection causing the same "create" to happen twice, soft delete instead of permanent delete, and a required "reason" on every change so the audit trail actually says why something changed. Nothing user-facing changes yet — this is the reusable factory that upcoming CRUD tickets will build their actual endpoints on top of, not an endpoint itself. ## Known limitation, flagged for later The duplicate-request protection currently lives in memory rather than the database, because the next available migration slot is already claimed by another in-flight ticket. Practically this means: on an API restart or with multiple API instances running, that specific protection resets — worst case is a retried request isn't deduplicated (same as if this feature didn't exist yet), never a wrong or double result. Flagged as a follow-up for whoever picks up the next migration. Also made two small, isolated additions to shared files outside this ticket's own scope, needed to get "reason" flowing through to the audit log — called out on the issue for visibility. Closes #11. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
Repository[ModelT] against any master-data model: list/get filter
deleted_at IS NULL by default, soft_delete() sets deleted_at rather
than issuing DELETE (hard rule #1). Filter values are coerced to each
column's Python type so e.g. a boolean/int filter compares correctly
instead of matching against a literal query string.

Also adds the throwaway `Widget` test aggregate (tests/unit/api/
conftest.py) later test files in this ticket build on, exercising the
real SoftDeleteColumns/Auditable mixins end to end without a real
domain table.

PH1.3, issue #11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
list_query_params(filterable_fields=..., sortable_fields=...) is a
FastAPI dependency factory parsing page/size/sort and an allowlisted
set of filter query params into a polaris.db.repository.ListQuery.
Sorting an unlisted field is a 422; an unrecognised filter param is
silently ignored. Page[T] is the {items, total} response shape
(docs/09-api.md).

PH1.3, issue #11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
compute_etag() derives a strong ETag from a resource's id/updated_at;
check_if_match() 412s on a stale If-Match and is a no-op when the
header is absent (permissive — see the docstring for the reasoning).

Adds PreconditionFailedError to polaris.core.errors (412), following
the same pattern as the existing NotFoundError/ConflictError/etc, so
polaris.api.problem_details' existing PolarisError handler picks it
up with no further wiring.

PH1.3, issue #11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
InMemoryIdempotencyStore is a thread-safe, TTL-evicting (24h per
docs/09-api.md) IdempotencyStore keyed by actor+method+path+header
value. It is process-local, not durable: 0003 in the Alembic chain is
already claimed by PH1.2 (feature/db-schema-planning) and this ticket
has no owned path under backend/alembic/versions/**, so a durable
idempotency_key table is a seam request against whoever takes the
next migration slot, not this ticket — see the module docstring and
the issue #11 comment thread for the full reasoning and trade-offs.

PH1.3, issue #11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
crud_router(model=..., create_schema=..., update_schema=...,
read_schema=..., prefix=..., read_roles=..., write_roles=..., ...)
builds a FastAPI APIRouter with list/get/create/update/soft-delete
endpoints wired to this ticket's other building blocks: pagination,
Idempotency-Key replay, If-Match/ETag, the soft-delete Repository, and
require_role. Every mutation requires a non-empty X-Reason header
(missing -> 422 for free via FastAPI's own required-header
validation) and writes an audit row with actor/reason/diff in the
same transaction.

This module mounts no routes itself and main.py is untouched — later
CRUD-aggregate tickets import crud_router and drop a module exporting
`router = crud_router(...)` into api/routers/<aggregate>/<concern>.py,
which PH0.2's router discovery picks up automatically. Tests exercise
the factory against the throwaway Widget aggregate (tests/unit/api/
conftest.py), per the ticket brief: there is no real mounted endpoint
to test yet.

Deliberately omits `from __future__ import annotations` (unlike every
other module in this codebase) — see the top-of-file comment for why
dynamically-built route handlers need eager (non-postponed) parameter
annotations for FastAPI's get_type_hints() to resolve them.

Two small, isolated additions to files this ticket doesn't own, so a
mutation's `reason` reaches the audit_log.reason column the existing
after_flush hook already writes: `core.context.reason_var` (next to
the existing actor_sub_var/actor_role_var) and a one-line read of it
in `db.audit._audit_row`. Both are additive, don't touch any existing
behaviour, and are covered by crud_test.py's audit-row assertions.

docs/09-api.md: `reason` is a required X-Reason header, not optional
— issue #11's acceptance criteria settle this (a missing reason is a
422), superseding the doc's earlier "optional reason" wording.

Closes #11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YioTVKBPoE6thZqbnTtnM
bart merged commit b4d28cbe99 into main 2026-09-11 11:50:08 +00:00
bart deleted branch feature/api-platform 2026-09-11 11:50:08 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
patrick/Polaris!68
No description provided.