Serve and Call the HTTP API
A service serves an HTTP API derived from its Manifest: a route per command, the state of every aggregate, the snapshot and event queries, the service's datasets, and the Manifest and its OpenAPI document. There is no controller to write. This page covers what a client does with it: authenticate, send a command, wait for the outcome, retry safely, and read errors. HTTP API lists every route, header and field.
The routes
| Route | Serves |
|---|---|
POST /<context>/<aggregate>/{id}/<command> | A command to an aggregate |
POST /<context>/<aggregate>/<command> | A creating command whose aggregate id the server gives; of an aggregate whose id is its owner's, any command to the caller's own |
DELETE /<context>/<aggregate>/{id}, POST …/{id}/wow.lifecycle.<command> | The lifecycle commands; of an aggregate whose id is its owner's, also DELETE /<context>/<aggregate> and POST /<context>/<aggregate>/wow.lifecycle.<command> |
GET /<context>/<aggregate>/{id}/state, …/state/trace | An aggregate's state and history (Read State); of an aggregate whose id is its owner's, also GET /<context>/<aggregate>/state |
POST /<context>/<aggregate>/snapshot/query, …/events/query | The query models (Query Snapshots, Query Event Streams) |
POST /wow/dataset/<context>/<dataset>/query | A dataset of the service |
<METHOD> /<context>/<path> | A service route (@ServiceRoute, Service Routes): its function's answer as JSON; 204 No Content for a function that returns Unit, and for one that returns a nullable value when it is null, which the Manifest marks responseOptional and the OpenAPI document lists beside 200 |
GET /wow/manifest, GET /wow/openapi | The Manifest and the OpenAPI 3.1 document of these routes |
An aggregate whose id is its owner's, such as the eShop's basket, has one per owner: its routes without an id mean the caller's own aggregate, and no route generates its id (One aggregate per owner). The routes are on the public port. The internal port serves the cluster and other services, never a client. An internal command (@InternalCommand, Keep a command internal) has its routes on the internal port alone: the public port answers them as unknown paths, and its OpenAPI document and generated clients leave the command out.
Who the caller is
Every route authenticates its request through the application's CallerResolver bean, which turns the request's credentials into a caller: its tenant, its owner and space, its subject and attributes. In a development profile (a profile of wow.dev-profiles), with CoSec off and no resolver of the application's, the host reads the caller from headers, standing in for an identity provider: Wow-User names the subject, Wow-Owner the owner of its scope, Wow-Roles its roles (comma-separated), Wow-Tenant its tenant (wow.tenant.default without it) and Wow-App the application it calls in (the request fact app_id). Any client sets them, so a node in development by an internal port on the loopback interface alone serves every request as the anonymous caller instead, and a node anywhere else without a resolver refuses to start:
BUYER=(-H 'Wow-User: alice' -H 'Wow-Owner: alice')
EMPLOYEE=(-H 'Wow-User: erin' -H 'Wow-Roles: employee')
curl localhost:8083/eshop-ordering/order/order-1/state "${BUYER[@]}"- A buyer is an owner-level caller: its scope has an owner, so it reaches only its own aggregates.
- An employee is a tenant-level caller: no owner, so it reaches every aggregate of its tenant, and action-level authorization decides what it may do.
- A request without
Wow-Useris the anonymous caller of the default tenant. A resolver that returnsnullanswers401 wow.auth.unauthenticated. A service that is anonymous on purpose declares a resolver bean returningCallerResolver.ANONYMOUS.
With wow-cosec, CoSec's resolver does this from the authenticated principal and its roles (Secure a Deployment). The eShop's services run that way behind the CoSec gateway: their cosec profile reads the identity the gateway forwards (wow.cosec.mode: gateway), the development headers above step aside, and an employee is a principal with the role employee (wow.cosec.tenant-level.roles). The tenant, owner and space always come from the caller, never from the path or the body (on a CoSec host the space is the one CoSec accepted from CoSec-Space-Id); a caller allowed to act across scopes names another in Wow-Scope.
Send a command
curl -X POST localhost:8083/eshop-ordering/order/order-1/pay-order \
-H 'Content-Type: application/json' -H 'Wow-User: alice' -H 'Wow-Owner: alice' \
-H 'Wow-Request-Id: 01JB2T6Q0Z' \
-d '{"paymentId":"p-1","amount":29.00}'The body is the command's JSON. A success answers 200 with the command's result: its status (succeeded), the aggregate's ref and version after the commit, and the results the decision added. If-Match: "n" sends the command only if the aggregate is at version n, else 412.
Choose how long to wait
By default a command answers once its snapshot holds the commit, so a read right after sees it. Wow-Wait asks for another point, as a chain of hops:
Wow-Wait | Answers when |
|---|---|
accepted | The command passed admission (202) |
committed | Its events are appended |
snapshot | Its snapshot holds the commit (the default) |
committed > handled(OrderProcessSaga.onOrderAwaitingValidation) > committed(eshop-inventory) | The saga derived its commands and the inventory committed them |
A wait follows sagas across services: after a saga's handled hop, the next hops apply to the commands it derived. Wow-Wait-Timeout bounds the wait (10s by default, at most wow.wait.max-timeout). Waiting for Outcomes explains the model.
Retry safely
Give every command a Wow-Request-Id. A retry with the same id is answered as already done, with duplicate: true and the original version, and is never decided twice. So:
504 wow.wait.timeout: the command may have committed. Retry with the same request id. Wow's Kotlin client leaves this retry to its caller, since only the caller knows whether to wait longer; it sends the others below again by itself.503 wow.command.unknown,wow.storage.unavailable,wow.node.closing: retry with the same request id.429 wow.command.busy: retry afterRetry-After.- A rejection (
4xxwith a domain code): do not retry; the same request is rejected again.
A creating command sent without an id gets one; a retry with the same request id gets the same one.
Stream the outcome
With Accept: text/event-stream, a command answers one server-sent event per hop it reaches, named after the hop's stage, its data the result at that hop, until the wait ends. A client shows progress without polling: accepted, committed, then each service the chain crosses. Accept is weighed by its quality values: the stream is answered when text/event-stream is named with a quality above zero and no lower than application/json's, a wildcard alone answers JSON, q=0 refuses a type, and an Accept that refuses every type the route answers in is answered 406.
A snapshot list streams its rows the same way, resumable with Last-Event-ID (Query Snapshots).
Read an error
Every error is an RFC 9457 problem, application/problem+json: its type is urn:wow:error:<code>, code and status repeat it, violations list what a request broke field by field, and a command's problem carries its whole result under result. A client branches on code. The status of a domain code is its @ErrorCode(status) (Rejections and Error Codes); Error Codes lists the framework's.
Generated clients
Clients rarely write these requests by hand: the TypeScript client is generated from the Manifest, and Kotlin services call each other through the typed command client (Call Other Services).