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 acrossdata/meta/revision_info)include_deleted=(soft-delete visibility)- revisions & versioning semantics
- blob (
Binary) handling - query / search conventions (
qbrecommended)
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:
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
returnswill be returned asUNSET(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 bareResourceMetaonly-revision_info(aliasonly-revision-info) → the bareRevisionInfo
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:
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 ResourceDecodeError → HTTP 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 UnindexedQueryError → HTTP 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.
partialis treated as a structural selector (similar to JSON Pointer style paths), and is passed intofilter_struct_partial()/get_partial().⚠️
partialis not a boolean.partial=trueselects a (non-existent) field literally namedtrueand so clears the section to{}. Passing a boolean-looking value emits aSpecStarWarning. 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 todatameta/<path>→ applies tometainfo/<path>→ applies torevision_info(**note the prefix isinfo/, section name isrevision_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/orinfo/.
For legacy endpoints (deprecated aliases) the default category differs:
/metaendpoints setdefault_category="meta"/revision-infoendpoints setdefault_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
ResourceIsDeletedErrorif 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-Typeit wins —application/json-patch+json(6902) orapplication/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) returns422with 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}withmode=update(default) PATCH /{model}/{resource_id}withmode=update(default)-
Semantics:
-
Creates a new revision
- Sets
parent_revision_idto 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_idagainst 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-opPATCH) produces a payload byte-identical to the current revision's stored bytes, SpecStar does not create a new revision. The endpoint returns200with the existingrevision_id—total_revision_countis unchanged.This keeps history clean (no churn from clients that re-PUT the same body on every save) but it means a successful
200does not guarantee a new revision was recorded. Code that depends on "every successful write = one new audit row" should compare the returnedrevision_idagainst 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_statusis only allowed undermode=modify
Practical guidance:
- Use
updatefor normal production writes / audit history.- Use
modifyfor 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
datain 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-Typeuses: -
content_typefromBinaryif 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: pending → uploaded → finalized (or aborted)
The response from Step 1 includes an upload_method field:
"proxy"— Upload bytes viaPUT .../content(Step 3)."single_put"— Upload bytes directly to theupload_urlreturned 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 .../contentstores bytes in temporary memory — does not write to the blob store.POST .../finalizereads the buffered bytes and callsblob_store.put(...)— only then writes to the blob store.POST .../abortdiscards temporary bytes.
This makes the upload-session API available with any blob store backend.
Query / search conventions (recommended: qb)¶
For list/search endpoints, SpecStar supports multiple query styles, but recommends qb.
qb (recommended)¶
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
qbis provided, it must not be combined with: -
data_conditions conditionssorts- metadata filter query params such as
is_deleted,created_time_start,created_time_end,updated_time_start,updated_time_end,created_bys, andupdated_bys - In practice, only
limitandoffsetshould be sent alongsideqb. limit/offsetin 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 fieldsconditions: 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:
DataSearchConditionResourceMetaSearchSortResourceDataSearchSort
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):
ResourceIDNotFoundErrorResourceIsDeletedError(unlessinclude_deleted=true)RevisionIDNotFoundErrorRevisionNotFoundError
Note
- Read routes currently do not consistently distinguish
404vs403vs409. Many failures collapse into404.
Revision list¶
GET /{model}/{resource_id}/revision-list
-
400 Bad Request
-
invalid
sort(must becreated_timeor-created_time) - invalid
limit(< 1) -
invalid
offset(< 0) -
404 Not Found
-
from_revision_idprovided 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
datais 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_statusonly allowed withmode=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_statusis only valid withmode=modify; otherwise returns 400.
Recommended client-side conventions¶
-
Treat
404on 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
qbfor search/list queries: -
easier to version and safer than ad-hoc JSON conditions
-
Use
updatemode for immutable audit history; usemodifyfor draft workflows.
Optional future improvements¶
If you want more REST-accurate mapping, consider:
PermissionDeniedError→ 403ResourceNotFoundErrorfamily → 404ResourceConflictErrorfamily (UniqueConstraintError,DuplicateResourceError, etc.) → 409- avoid broad
except Exception→ 404/400 (it can hide real server bugs)