← Back to Library

API Conventions

concept ·  app devs
api-conventionsbulk-firstidentitynamespacesauthpagination

API Conventions

In one sentence. API Conventions is the set of cross-cutting rules — bulk-write semantics, identity/versioning, soft-delete-by-default, namespace-scoped authorization, response caching windows, and pagination — that every WIP REST and MCP surface follows the same way, so a caller who has read this doc once does not need to re-derive it per store.

Why it exists. WIP exposes several independently-implemented stores (document-store, template-store, def-store, registry) plus an MCP layer over them. The conventions exist so callers of any of them get identical semantics for writes, versioning, deletion, and authorization without deriving them per-endpoint, and so idempotent bootstrap scripts can provision a namespace, its templates, and its vocabularies against a fresh — or partially-provisioned — instance and simply be re-run on failure, "without ugly GET → 404 → POST dances or silent schema drift" (wip://conventions, "Idempotent Bootstrap"). The on_conflict=validate design explicitly favors loud failures over silent ones: "silent guardrails are worse than loud ones" (wip://conventions, "Template create with conflict validation").

The model

WIP has one identity mechanism per entity type; none of them are interchangeable:

EntityIdentityVersioning behaviour
Templatetemplate_id + versionUpdating creates a new version; the previous version stays active — not superseded — until explicitly deactivated (deactivate_template). Multiple versions can accept documents simultaneously.
Documentthe template's declared identity_fields, hashedSame hash → new version of the same document_id (update); different hash → new document (create). create_document is the same call for both — an upsert. Zero identity_fields = append-only: every submission is a new document, and PATCH is rejected outright.
Term(namespace, terminology_id, value)Unique within its terminology; no separate versioning axis described in scope.

Bulk write responses carry one status per submitted item — a 200 OK on the envelope says nothing about any individual item:

StatusMeaning
createdNew entity written
updatedExisting entity versioned (or overwritten in place, for versioned: false templates)
unchangedNo-op — identical resubmission, or an empty/no-op PATCH
skippedBulk-only: duplicate term create (2+ items) skipped rather than erroring
errorFailed; carries a machine-readable error_code alongside the human error message

PATCH (update_document) applies an RFC 7396 JSON Merge Patch and can fail with any of: not_found, forbidden, archived, identity_field_change, append_only, concurrency_conflict, validation_failed, reference_violation, internal_error.

Namespace authorization is a two-axis model: a permission tier (read / write / admin grant, or superadmin) and an isolation mode governing what the namespace's documents may reference:

Isolation modeA document in this namespace may reference
open (default)own namespace + the wip namespace + allowed_external_refs
strictown namespace + explicit allowed_external_refs only — wip is not automatic

How it works

⚠ GAP: this doc's source_scope is wip://conventions (policy text) plus exactly one implementing module, the auth middleware. The store-level code that actually loops over bulk items, resolves template/document versions, or paginates a list response is not in scope — those live per-store (document-store, template-store, def-store, registry), each with its own doc in this library. This section documents the one mechanism whose implementation is in scope: authentication.

AuthMiddleware.dispatch (libs/wip-auth/src/wip_auth/middleware.py:69-121) runs on every request:

  1. Public-path short-circuit. If request.url.path exactly matches an entry in self.public_paths — the universal set {"/", "/health", "/ready", "/docs", "/redoc", "/openapi.json"} plus whatever service-specific paths were passed to the constructor — the middleware sets the current identity to None, calls the downstream handler, and resets. No provider runs, so a stale or wrong credential header on a public path cannot 401 it.
  2. Provider iteration. For any other path, the middleware tries self.providers in the order given to the constructor. The first provider whose authenticate(request) returns a non-None identity wins; iteration stops there.
  3. Provider exception short-circuit. If a provider raises HTTPException (e.g., invalid credentials), the middleware converts it directly into a JSONResponse with the same status_code, detail, and headers, and returns immediately — remaining providers in the list are never tried.
  4. No-match is not a rejection. If no provider returns an identity, identity stays None and the request still proceeds to call_next. The middleware itself never rejects an unauthenticated request; that is left to route-level dependencies (e.g. require_identity), which is what lets public and protected routes share one app and one middleware stack.
  5. Context propagation. Whatever identity resolved to (None or a UserIdentity) is stored via set_current_identity(identity), which returns a token; call_next runs with that context set; reset_current_identity(token) restores the prior value in a finally block. The module notes this token-based save/restore exists specifically so a nested in-process call (e.g., the Registry called via ASGITransport) doesn't wipe the outer request's identity.

create_auth_middleware(providers, public_paths) (middleware.py:124-154) is the factory a service actually wires up: it returns a middleware subclass with providers and public_paths pre-bound for app.add_middleware(...). The module's own usage example pairs an API-key provider with an OIDC provider: providers = [APIKeyProvider(keys), OIDCProvider(issuer_url="...")].

Rules & invariants

Gotchas

See also

Generated from real source code, not hand-written — see the WIP Technical Library.