Skip to content

API conventions

This document describes SpecStar’s HTTP API conventions — especially the parts that are not obvious from plain REST CRUD:

  • returns= (select response sections)
  • partial= (field-level projection across data / meta / revision_info)
  • include_deleted= (soft-delete visibility)
  • revisions & versioning semantics
  • blob (Binary) handling
  • query / search conventions (qb recommended)

This document reflects the current implementation in route templates.


Canonical read API: GET /{model}/{resource_id}

Response shape (section-based)

SpecStar returns a unified response envelope:

{
  "data": "...",
  "revision_info": "...",
  "meta": "..."
}

Each section can be included or omitted via returns.

List endpoints (GET /{model}) wrap each item in the same envelope and return an array — the bare resource body is not returned directly.

Detecting truncation on lists

GET /{model} always sets an X-Has-More response header (true/false) so a page is never silently truncated, regardless of the configured page size. Add ?with_total=true for an X-Total-Count header (an extra count query, hence opt-in). For a guaranteed full scan in code, use ResourceManager.iter_all(query=None, *, batch_size=1000), which pages through every match internally instead of relying on a single limit.

The three id fields

A response carries three distinct identifiers, which serve different purposes:

Field Where it lives Role
resource_id meta.resource_id Path id. Stable across revisions. Use this in every /{model}/{resource_id}/… URL.
revision_id revision_info.revision_id The id of one revision of a resource. Required for switch and revision-targeted endpoints. Format: {model}:{resource_id}:{revision_number}.
uid meta.uid (when present) Internal unique key — differs per revision. Do not use in URLs.

Only resource_id appears in default route paths.

returns query parameter

returns is a comma-separated list of sections to include.

  • Allowed values:

  • data

  • revision_info
  • meta

  • Default:

  • returns=data,revision_info,meta

Examples:

  • Full response (default)

GET /user/123

  • Data only

GET /user/123?returns=data

  • Meta only

GET /user/123?returns=meta

  • Data + meta (no revision info)

GET /user/123?returns=data,meta

Note: Any section not listed in returns will be returned as UNSET (omitted in the serialized output, depending on encoder behavior).

Bare objects: only-*

returns=data still wraps the result in the { "data": ... } envelope. To get a bare, unwrapped section (a front end that wants a plain object), use a single only-* value:

  • only-data → the data object itself, e.g. {"title": "..."}
  • only-meta → the bare ResourceMeta
  • only-revision_info (alias only-revision-info) → the bare RevisionInfo

An only-* value must be used alone — combining it with any other value (e.g. only-data,meta) is a 422, as is an unknown section. revision-info (hyphen) is accepted as an alias of revision_info in the regular list too.

Server default: default_get_returns

When the client omits ?returns=, the response shape comes from default_get_returns (set on SpecStar(...) / configure(...) / add_model(...); accepts a list or comma-string). It defaults to the full envelope data,revision_info,meta. Set it to only-data if your clients expect bare objects by default:

spec.configure(default_get_returns="only-data")  # GET /{model}/{id} → bare data

Leaving it unset emits a one-time SpecStarWarning at startup (the envelope is the default and surprises front ends); setting it explicitly — to any value, including the envelope — silences that.


Strictness: unknown fields on write

By default, fields not declared on the resource Struct are silently dropped on POST / PUT writes (matching msgspec's default behavior). A typo'd field name therefore looks like a successful write but the value is gone.

Opt into strict mode via SpecStar(forbid_unknown_fields=True) or spec.configure(forbid_unknown_fields=True). With it on, any unknown top-level key in the request body returns 422 Unprocessable Entity with a list of the offending fields.

# default (off): silently dropped
curl -X POST /post -d '{"title":"t","body":"b","ghost":"X"}'   # 200 OK; "ghost" gone

# strict
curl -X POST /post -d '{"title":"t","body":"b","ghost":"X"}'   # 422
# detail: Unknown field(s) for Post: ['ghost']. Allowed fields: ['body','title']

This check applies to dict / JSON-body / Pydantic inputs into create() / update() / modify() (REST + programmatic). msgspec.Struct instances pass through unchanged — they can't carry extra fields by construction.


Undecodable stored data: on_decode_error

If a stored row can't be decoded into the current model (typically an incompatible schema change without a version bump), the read behavior is configurable via SpecStar(on_decode_error=...) / spec.configure(on_decode_error=...) (or per-model add_model(..., on_decode_error=...)). The policy lives in the ResourceManager, so HTTP routes behave identically to programmatic calls:

Policy GET /{model} (list) GET /{model}/{id} (single)
skip (default) omit the row + log a SpecStarWarning; /count still counts it degrades to error (nothing to skip)
error raise ResourceDecodeErrorHTTP 422 raise → HTTP 422
raw return the row with data as an UndecodableData same

Under raw, the envelope is unchanged (meta / revision_info, including schema_version, are still valid) — only data becomes an UndecodableData:

{"data": {"decode_error": "Object missing required field 'x'",
          "data": { /* best-effort parsed dict */ },
          "raw_base64": null},
 "revision_info": "...", "meta": "..."}

UndecodableData.data holds the parsed dict when the bytes are still parseable (the common case); if even that fails (e.g. the encoding was changed), data is null and raw_base64 preserves the original bytes losslessly.


Filtering on a non-indexed field: on_unindexed_query

Only indexed fields (and ResourceMeta attributes such as created_by, is_deleted, created_time) live in the searchable indexed_data. A filter condition on any other field can never match, so the query silently under-returns — usually "returns nothing". This is a common footgun, so the behavior is configurable via SpecStar(on_unindexed_query=...) / spec.configure(on_unindexed_query=...) (or per-model add_model(..., on_unindexed_query=...)). Like on_decode_error, the policy lives in the ResourceManager, so search/list/count over HTTP behave identically to programmatic calls:

Policy Behavior when a condition names a non-indexed field
warn (default) emit a SpecStarWarning naming the field(s), then run the query anyway (it under-returns)
error raise UnindexedQueryErrorHTTP 400, naming the field(s) and listing the indexed ones
spec.add_model(Doc, indexed_fields=[("name", str)])
# filtering on "note" (not indexed) → SpecStarWarning by default, or HTTP 400
# under on_unindexed_query="error". Index it, or filter on a meta attribute.

Only filter conditions are checked; vector-distance conditions have their own validation. The default warn is non-breaking — it only adds a warning, the result set is unchanged.


Field projection: partial / partial[]

SpecStar supports field-level projection through partial (or partial[] for axios / repeated-query compatibility).

The partial path format

  • Paths are slash-prefixed:

  • /field

  • /nested/field
  • If the user passes field (no leading slash), SpecStar normalizes it to /field.

partial is treated as a structural selector (similar to JSON Pointer style paths), and is passed into filter_struct_partial() / get_partial().

⚠️ partial is not a boolean. partial=true selects a (non-existent) field literally named true and so clears the section to {}. Passing a boolean-looking value emits a SpecStarWarning. Use field paths (partial=/name) or omit it.

Prefix routing: project across data, meta, revision_info

Each partial field can be routed to a specific section by using a prefix:

  • data/<path> → applies to data
  • meta/<path> → applies to meta
  • info/<path> → applies to revision_info (**note the prefix is info/, section name is revision_info)

Examples:

  • Only some fields of data

GET /user/123?returns=data&partial=/name&partial=/email

  • Only some fields of meta

GET /user/123?returns=meta&partial=meta/resource_id&partial=meta/updated_time

  • Only some fields of revision_info

GET /user/123?returns=revision_info&partial=info/revision_id&partial=info/status

  • Mixed projection across multiple sections

GET /user/123?returns=data,meta&partial=/name&partial=meta/updated_time

Default routing of unprefixed partial

Unprefixed partial paths are routed by the route’s default_category.

For canonical GET /{model}/{resource_id}:

  • default_category = "data"
  • Therefore:

  • partial=/name → applies to data

  • If you want meta/info, you must prefix with meta/ or info/.

For legacy endpoints (deprecated aliases) the default category differs:

  • /meta endpoints set default_category="meta"
  • /revision-info endpoints set default_category="info"

Soft deletion: include_deleted

Most read endpoints accept:

  • include_deleted=false (default)
  • include_deleted=true

Behavior (read)

include_deleted controls whether metadata can be retrieved for soft-deleted resources.

At the ResourceManager layer:

  • get_meta(resource_id, include_deleted=False):

  • raises ResourceIsDeletedError if resource is deleted

  • get_meta(resource_id, include_deleted=True):

  • returns metadata even if deleted

Route templates typically call get_meta(...) first, so include_deleted is a visibility switch that gates follow-up reads (data, revision info, etc.).

Note: Current read routes collapse most errors into HTTP 404 (see “HTTP error mapping”).


PATCH: two flavors

PATCH /{model}/{resource_id} accepts both standard REST patch formats on the same endpoint:

Body shape Standard Meaning
JSON array of ops RFC 6902 (JSON Patch) [{"op":"replace","path":"/qty","value":50}]
JSON object RFC 7386 (JSON Merge Patch) {"qty": 50} — partial update; null deletes a field

Disambiguation:

  • If you send an explicit Content-Type it wins — application/json-patch+json (6902) or application/merge-patch+json (7386).
  • Otherwise the body shape decides: array → 6902, object → 7386. The two are structurally disjoint for object resources, so there's no ambiguity.

The intuitive partial update (PATCH {"qty": 50}) therefore "just works" as a merge patch. A PUT remains a full replacement of the whole resource.

Programmatically, both flavors are first-class manager operations — pass a jsonpatch.JsonPatch (6902) or a MergePatch (7386) to ResourceManager.patch() / .modify():

from specstar import MergePatch

mgr.patch(rid, MergePatch({"qty": 50}))   # merge: keep other fields; null deletes

A single stray RFC 6902 op sent as an object (e.g. {"op": "replace", ...} without the surrounding array) returns 422 with a hint, rather than being silently merged.


Revisions and mutability

SpecStar has two update modes with different revision semantics:

update (default): append a new revision (immutable history)

  • Used by:

  • PUT /{model}/{resource_id} with mode=update (default)

  • PATCH /{model}/{resource_id} with mode=update (default)
  • Semantics:

  • Creates a new revision

  • Sets parent_revision_id to the previous current revision
  • Revision history is append-only under this mode

Optimistic concurrency (If-Match / expected_revision_id)

By default, concurrent PUT/PATCH writes are last-write-wins on the current revision pointer (history is still preserved). To protect against lost updates, opt into per-request optimistic concurrency:

  • Pass If-Match: <revision_id> header (HTTP-standard), or
  • Pass ?expected_revision_id=<revision_id> query param.

SpecStar checks the resource's current revision_id against the asserted value before applying the write; if they differ, the request is refused with 412 Precondition Failed and a structured detail:

{
  "detail": {
    "code": "PRECONDITION_FAILED",
    "message": "Precondition failed for 'user:abc': expected current revision 'user:abc:3', got 'user:abc:5'.",
    "expected_revision_id": "user:abc:3",
    "actual_revision_id": "user:abc:5"
  }
}

Clients can fetch, merge, and retry. The check is opt-in per request, so callers that don't care still get last-write-wins behavior.

Same-content writes are de-duplicated

If a PUT (or no-op PATCH) produces a payload byte-identical to the current revision's stored bytes, SpecStar does not create a new revision. The endpoint returns 200 with the existing revision_idtotal_revision_count is unchanged.

This keeps history clean (no churn from clients that re-PUT the same body on every save) but it means a successful 200 does not guarantee a new revision was recorded. Code that depends on "every successful write = one new audit row" should compare the returned revision_id against the prior one to detect a dedup.

modify (draft update): overwrite the current revision (not immutable)

  • Used by:

  • PUT /{model}/{resource_id}?mode=modify

  • PATCH /{model}/{resource_id}?mode=modify
  • Semantics:

  • Overwrites the current revision instead of creating a new one

  • This means the revision history is not immutable under modify
  • change_status is only allowed under mode=modify

Practical guidance:

  • Use update for normal production writes / audit history.
  • Use modify for draft workflows where “current revision is editable”.

Blob conventions (Binary)

SpecStar supports a first-class blob type:

class Binary(Struct):
    file_id: str | UNSET
    size: int | UNSET
    content_type: str | UNSET
    data: bytes | UNSET

Storage behavior

When writing a resource that contains Binary(data=...):

  • SpecStar extracts the bytes
  • Stores them into the configured IBlobStore
  • Populates:

  • file_id (hash of content)

  • size
  • optionally content_type
  • Clears data in the stored resource (so resource payload stays small)

Read behavior

Blob bytes are retrieved via the dedicated endpoint:

GET /{model}/{resource_id}/blobs/{file_id}

  • This endpoint performs a permission gate by calling resource_manager.get(resource_id) first.
  • Then it calls resource_manager.get_blob(file_id) to fetch bytes.
  • The response Content-Type uses:

  • content_type from Binary if available

  • otherwise application/octet-stream

Upload sessions

In addition to the one-shot POST /blobs/upload, SpecStar provides a two-step upload-session flow for larger files or when pre-signed URLs are needed:

Step Method Endpoint
1. Create session POST /blobs/upload-sessions
2. Check status GET /blobs/upload-sessions/{upload_id}
3. Upload bytes PUT /blobs/upload-sessions/{upload_id}/content
4a. Finalize POST /blobs/upload-sessions/{upload_id}/finalize
4b. Abort POST /blobs/upload-sessions/{upload_id}/abort

Lifecycle: pendinguploadedfinalized (or aborted)

The response from Step 1 includes an upload_method field:

  • "proxy" — Upload bytes via PUT .../content (Step 3).
  • "single_put" — Upload bytes directly to the upload_url returned in the session (e.g. an S3 presigned URL). Step 3 is skipped.

After finalization, the response is a Binary descriptor (same as the one-shot upload).

Fallback behavior

If the underlying IBlobStore does not natively support upload sessions (e.g. MemoryBlobStore, DiskBlobStore), the route layer provides a real stateful fallback facade:

  • PUT .../content stores bytes in temporary memory — does not write to the blob store.
  • POST .../finalize reads the buffered bytes and calls blob_store.put(...) — only then writes to the blob store.
  • POST .../abort discards temporary bytes.

This makes the upload-session API available with any blob store backend.


For list/search endpoints, SpecStar supports multiple query styles, but recommends qb.

qb is a Query Builder expression parsed by a safe AST parser (not eval).

Example:

  • qb=QB["age"].gt(18) & QB["status"].eq("active")

Rules:

  • If qb is provided, it must not be combined with:

  • data_conditions

  • conditions
  • sorts
  • metadata filter query params such as is_deleted, created_time_start, created_time_end, updated_time_start, updated_time_end, created_bys, and updated_bys
  • In practice, only limit and offset should be sent alongside qb.
  • limit / offset in URL can override defaults.

Quick error guide:

Situation HTTP Reason
malformed or unsupported QB expression 400 the expression itself could not be parsed safely
qb mixed with incompatible query inputs, including metadata filters 422 QB mode and structured-condition mode are intentionally separate

Structured JSON conditions

If not using qb, you can pass JSON strings:

  • data_conditions: filters over resource data fields
  • conditions: general filters (meta or data depending on operator / field_path usage)
  • sorts: mix of meta-sort and data-sort objects

These are parsed via json.loads(...) and converted into:

  • DataSearchCondition
  • ResourceMetaSearchSort
  • ResourceDataSearchSort

HTTP error mapping (route templates)

This section documents how current route templates map internal exceptions to HTTP responses.

Some routes intentionally normalize multiple internal errors into the same HTTP code.

Read routes (get.py)

Canonical GET resource

GET /{model}/{resource_id} (and deprecated aliases like /data, /meta, /full, /revision-info)

  • 404 Not Found

  • Most internal exceptions are caught and returned as:

    • HTTPException(404, detail=str(e))

This includes (but is not limited to):

  • ResourceIDNotFoundError
  • ResourceIsDeletedError (unless include_deleted=true)
  • RevisionIDNotFoundError
  • RevisionNotFoundError

Note

  • Read routes currently do not consistently distinguish 404 vs 403 vs 409. Many failures collapse into 404.

Revision list

GET /{model}/{resource_id}/revision-list

  • 400 Bad Request

  • invalid sort (must be created_time or -created_time)

  • invalid limit (< 1)
  • invalid offset (< 0)

  • 404 Not Found

  • from_revision_id provided but not found (detail="revision_id not found")

  • any other unhandled exception normalized to 404

Blob content

GET /{model}/{resource_id}/blobs/{file_id}

  • 403 Forbidden

  • permission gate fails (permission denied OR resource not found are currently collapsed)

  • 404 Not Found

  • FileNotFoundError (blob missing)

  • 400 Bad Request

  • NotImplementedError (blob store not configured)

  • 500 Internal Server Error

  • blob record exists but data is missing (detail="Blob data missing")

Create routes (create.py)

POST /{model}

  • 422 Unprocessable Entity

  • msgspec.ValidationError (type-level validation during decoding)

  • specstar.types.ValidationError (domain/business validation)

  • 409 Conflict

  • UniqueConstraintError → mapped via helper (raise_unique_conflict)

  • 400 Bad Request

  • any other exception normalized to 400

Update routes (update.py)

PUT /{model}/{resource_id}

  • 400 Bad Request

  • invalid argument combination: change_status only allowed with mode=modify

  • any other exception normalized to 400 (including “not found” today)

  • 422 Unprocessable Entity

  • msgspec.ValidationError

  • specstar.types.ValidationError

  • 409 Conflict

  • UniqueConstraintError → mapped via helper (raise_unique_conflict)

Note

  • Update route currently does not return 404; “resource not found” is folded into 400.

Patch routes (patch.py)

PATCH /{model}/{resource_id}

  • 400 Bad Request

  • most runtime errors normalized to 400:

    • resource not found / revision not found
    • permission denied
    • jsonpatch apply failures (invalid path, test op failed, etc.)
    • other unhandled exceptions
  • 422 Unprocessable Entity

  • msgspec.ValidationError

  • specstar.types.ValidationError

Note

  • change_status is only valid with mode=modify; otherwise returns 400.

  • Treat 404 on read endpoints as a generic “cannot fetch”. If you need to distinguish permission vs not-found, you must apply a stricter mapping (e.g. explicit exception handlers) or adjust templates.

  • Prefer qb for search/list queries:

  • easier to version and safer than ad-hoc JSON conditions

  • Use update mode for immutable audit history; use modify for draft workflows.


Optional future improvements

If you want more REST-accurate mapping, consider:

  • PermissionDeniedError → 403
  • ResourceNotFoundError family → 404
  • ResourceConflictError family (UniqueConstraintError, DuplicateResourceError, etc.) → 409
  • avoid broad except Exception → 404/400 (it can hide real server bugs)