Skip to main content

REST Query and Command Architecture

How remote clients reach the same behaviour as local ones over authenticated HTTP.

Remote RECAP clients use REST query and command endpoints. Query transport is ordinary JSON RPC over HTTP, not a schema-specific query language.

flowchart LR
    Client[RecapClient.from_url]
    Client -->|queries and counts| RESTQuery[REST query endpoints]
    Client -->|create, update, copy, lifecycle| REST[REST command endpoints]
    RESTQuery --> Routes[FastAPI routes]
    REST --> Routes[FastAPI routes]
    Routes --> Commands[CommandService]
    Commands --> Auth[Authorization]
    Commands --> Tx[One database transaction]
    Commands --> Revision[Revision and idempotency checks]
    Commands --> Audit[Mutation audit]

Remote RECAP client read and write transports

REST query endpoints carry namespace-scoped query specifications and reconstruct the corresponding local query behavior. REST command routes carry aggregate command payloads to CommandService, which owns authorization, transactions, revisions, idempotency, and audit outcomes.

Query requests use JSON envelopes such as entity, projection, namespace_path, and serialized spec; results return the entity, projection, and serialized items (or a count for the count endpoint). Local and remote clients therefore share QueryDSL semantics without sharing database access.

For readers who cannot view the diagram, reads use POST /api/v1/query and counts use POST /api/v1/query/count. Writes enter FastAPI routes, then pass through command authorization, one transaction, revision and idempotency checks, and audit recording.

This split means remote clients do not require access to the server’s database filesystem. The client API remains the same for local and remote builders, but remote writes cross an authenticated HTTP boundary.

REST route families

Create routes place the entity collection before the namespace path:

POST /api/v1/process-templates/{namespace_path}
POST /api/v1/resource-templates/{namespace_path}
POST /api/v1/resources/{namespace_path}
POST /api/v1/process-runs/{namespace_path}

Update routes identify immutable owning aggregates by UUID:

PATCH /api/v1/process-templates/{template_id}
PATCH /api/v1/resource-templates/{template_id}
PATCH /api/v1/resources/{resource_id}
PATCH /api/v1/process-runs/{process_run_id}

Namespace commands use a path for creation and UUID for updates:

PUT   /api/v1/namespaces/{namespace_path}
PATCH /api/v1/namespaces/{namespace_id}

Resource and process-run copies are derived-aggregate commands:

POST /api/v1/resources/{source_resource_id}/copies
POST /api/v1/process-runs/{source_process_run_id}/copies

The destination namespace is required in each copy request body. Lifecycle transitions use:

POST /api/v1/lifecycle/{object_type}/{object_id}

A copy request names its destination in the body rather than the URL, because a copy is a new aggregate in a different namespace rather than an edit of the source:

{
  "destination_namespace": "beamline/amx",
  "name": "AMXRobot",
  "changes": {"properties": {"tool": "new-gripper"}}
}

Request headers and concurrency

Write routes carry two headers that make retries safe:

Operation Idempotency-Key If-Match
Create, copy Required Not used
Update, lifecycle transition Required Required

Idempotency-Key lets a client retry a request that may have already succeeded. The server fingerprints the method, route, namespace, source ID, and body; a repeat of the same key with the same fingerprint replays the stored response instead of mutating twice, while a repeat with a different fingerprint is a conflict. Replays re-check authorization before returning the saved result, so a key cannot be used to replay a response the actor is no longer allowed to see.

If-Match carries the revision the client believes it is editing. Successful responses return the new revision as an ETag. A mismatch means another writer moved first, and the request fails rather than silently overwriting.

What one command does

Each command handler owns exactly one database transaction:

authorize -> fingerprint -> claim or replay idempotency key
-> load and validate aggregate -> mutate -> increment revision
-> record success audit -> commit

A failure rolls the mutation back and records the failure through a separate short transaction, so a rejected write leaves an audit trail without leaving partial data.

Namespace visibility

Visibility depends on the aggregate. Process-run access requires the target and the request context to name the same namespace. Templates and resources can be visible through ancestor namespaces when authorization allows it. Archived entities are excluded from results unless a query asks for them explicitly.

Builders do not issue incremental remote writes. They accumulate immutable draft payloads and submit one aggregate command at save(). This keeps local and REST builder behavior aligned and makes validation failures side-effect free.

Query models are canonicalized by stable entity identity at the client boundary. The same entity received through separate queries or relationship paths resolves to one in-memory model; later fuller loads upgrade that canonical model. Use load="none", targeted include(...), or load="eager" to control hydration.

Resource-tree and process-run loading use bounded, depth-independent SQL statement budgets rather than one query per child. Targeted loading remains preferable when a complete tree is unnecessary.

query.export(format, destination) delegates to a registered exporter. The registry is an extension point, not a built-in claim about any serialization format.