HTTP/JSON gateway, API keys, sessions, replica reads, builtin:// migrations
English translation of docs/gateway.md (the Russian text is the original). A guide to the "cheap" features of 1.0 (customer answer 1, session 6) [S §12.4, §12.8, §13.2, §16.3]; the decisions are D-8200..D-8249 in decisions.md.
The HTTP/JSON gateway of pub(api) services#
The same port as gRPC (expose): gRPC is told apart by content-type, the gateway by the prefix /api/. The listener is HTTP/2 (h2c or TLS); HTTP/1.1 is not supported on it.
POST /api/<Service>/<rpc> Content-Type: application/json
{"id": 7} -> 200 {"result": {"id": "7", "name": "Hammer", ...}}- Unary rpcs of
pub(api)services that are not@internalare served.<Service>is the short or the qualified name (shop.catalog.Catalog). Stream rpcs answer501. - The body is a JSON object with the parameters (canonical protobuf JSON:
lowerCamelCaseor the OxL names; 64-bit integers are accepted as numbers or strings and written as strings). An empty body means no parameters. A parameter without a default is required: a missing one is400naming the field. - The response is the JSON of the response message; the value is in
result(always, a zero value too:{"result": "0"},false,""). - The call runs through the same handler as gRPC: authentication, policies, idempotency (
idempotency-key), deadline (grpc-timeout),@ratelimit. - An error has the body
{"code": 5, "status": "NOT_FOUND", "message": "...", "details": [{"@type": "type.googleapis.com/pkg.Missing", "value": {...}}]}; a typed error is indetailsas JSON.
| gRPC | HTTP | gRPC | HTTP |
|---|---|---|---|
| INVALID_ARGUMENT, FAILED_PRECONDITION, OUT_OF_RANGE | 400 | UNAUTHENTICATED | 401 |
| PERMISSION_DENIED | 403 | NOT_FOUND / no such rpc | 404 |
| ALREADY_EXISTS, ABORTED | 409 | RESOURCE_EXHAUSTED | 429 |
| CANCELLED | 499 | UNKNOWN, INTERNAL, DATA_LOSS | 500 |
| UNIMPLEMENTED | 501 | UNAVAILABLE (with Retry-After) | 503 |
| DEADLINE_EXCEEDED | 504 | — | — |
A method other than POST is 405 (Allow: POST), a body that is not application/json is 415, a body above the limit is 413.
Authentication: apikey and session#
cluster shop {
auth { jwt(issuer: "https://id.example"); apikey; session(cookie: "sid") }
role all { serves Shop; expose 443 tls(...) }
}Order of checks: Authorization: Bearer (jwt), then API key, then session cookie. A credential that is presented and wrong is UNAUTHENTICATED; none at all is Anonymous.
apikey / apikey(header: "x-shop-key"). The key is in the header (default x-api-key) or in Authorization: ApiKey <key>. The server stores only the SHA-256 of a key, in a file
(OXL_AUTH_APIKEYS or --auth-apikeys; without a file every key is rejected):
{"keys": [{"id": "ci", "sha256": "<hex sha256 of the key>", "sub": "svc-ci", "roles": ["Admin"], "tenant": "acme", "mfa": false,
"claims": {"team": "x"}, "expires": 1893456000}]}The file is re-read when it changes (at most once a second): keys are added and revoked without a restart; a broken file keeps the old keys. A key gives Principal.User(sub, roles, ...),
claims["apikey"] is the key id. A new key: python3 -c "import secrets,hashlib; k='oxl_'+secrets.token_urlsafe(32); print(k, hashlib.sha256(k.encode()).hexdigest())".
session(cookie: "sid"). A server-side session: std.rpc.createSession(user, roles, ttl, tenant?, mfa?) -> str | SessionError returns an opaque id (256 bits) - the cookie value
(Set-Cookie: sid=<id>; HttpOnly; Secure; SameSite=Lax; Path=/); endSession(id) ends it. Sessions live in the replicated KV store _oxl_sessions as the SHA-256 of the id with the
principal and an expiry (up to 30 days). A revocation is seen on every node within a second (cache). When the store is unavailable the call is UNAUTHENTICATED (fail closed).
CSRF is the application's business: the gateway takes only application/json (a preflight in browsers); set cookies SameSite=Lax or stricter.
Reads from replicas (std.kv)#
let fast = kv.reading(.Follower) // Consistency: Linearizable (default) | Follower | Stale
let v = fast.get("k")
let s = kv.reading(.Stale, Duration.ofSeconds(2)).scan("user/", 0)Linearizable- the shard leader after ReadIndex: the behaviour so far.Follower- a replica of this node (if it is not the leader) asks the leader for a read index (the leader confirms its leadership and answers its applied index), waits for it locally and reads its own copy. Still linearizable: the read sees every write that completed before it; the leader does not serve the data. No replica on the node, or the node leads: the ordinary path.Stale(maxStale)- a replica without asking the leader, while it heard from the leader withinmaxStale(and the group is not quiesced): bounded staleness, monotonic within the process (never older than what it read before), may miss the writes of the lastmaxStaleand the process's own writes; otherwise the ordinary path.- Writes and transactions go through the leader. Outside a cluster the modes change nothing.
replicaReads()counts the reads a replica served. builtin://ORM tables are read on the leader only (replica reads for SQL come after 1.0).
builtin:// migrations#
oxl db diff is unchanged. oxl db migrate|rollback|status --db builtin://name[?shards=N] [--data-dir dir] apply migrations/NNNN_*.sql to the shard files of a stopped node
(name.db or name.shard<i>.db): to all shards or to none, each with its own _oxl_migrations table; a shard without the table of a migration (tables are created as rows arrive) is skipped with a message;
shards that disagree are an error. A running or clustered (Raft replicas) store is not changed by this command: stop the nodes, or run db.execute("ALTER ...") from the program - DDL is replicated over the shards.
oxl gen --go and --kotlin#
See interfaces.md. The Go and Kotlin clients take API keys and tokens as metadata: oxlrt.WithMetadata("x-api-key", key), OxlChannel.forTarget(addr, metadata = mapOf("x-api-key" to key)).
Source: docs/gateway.en.md