REST API¶
dosi-server (crate crates/dosi-server) serves an
OSI semantic model over HTTP: validate models, browse the compiled semantic
layer, compile metric queries to dialect SQL, and execute them against a
warehouse. JSON only — the same machine contract as dosi --format json. For
the shell equivalent of every endpoint, see the CLI reference.
Interactive docs: every running server hosts its own OpenAPI 3.1 spec at
/openapi.json and a Swagger UI at
/docs (generated with
utoipa; the UI assets are vendored, so
both work offline). This page is the narrative companion; the spec is the
authoritative schema.
Starting the server¶
# compile-only, in-memory DuckDB for execution
cargo run -p dosi-server -- --model fixtures/orders/model.yaml
# with warehouse connectors and a connections file (a `datasources:` YAML
# in Datus agent.yml vocabulary, or a full agent.yml)
cargo run -p dosi-server --features exec-all -- \
--model model.yaml --connections dosi-connections.yaml --bind 0.0.0.0:8080
| Flag | Env | Default | Meaning |
|---|---|---|---|
--model |
DOSI_MODEL |
required | OSI model file (.yaml/.json) |
--connections |
DOSI_CONNECTIONS |
./dosi-connections.yaml → ~/.config/dosi/connections.yaml → ./conf/agent.yml → ~/.datus/conf/agent.yml (legacy osi- paths still discovered) |
warehouse profiles — Datus agent.yml datasources: vocabulary (connectors.md) |
--bind |
DOSI_BIND |
127.0.0.1:8081 |
listen address |
--db |
— | in-memory | DuckDB file for connection-less executes |
--max-concurrent-executions |
DOSI_MAX_EXECUTIONS |
16 | execute concurrency cap |
--pool-size |
DOSI_POOL_SIZE |
8 | connection-pool cap per profile |
--execute-timeout-secs |
— | 60 | budget per warehouse execution |
--request-timeout-secs |
— | 30 | budget for non-execute requests |
--auth-token |
DOSI_SERVER_TOKEN |
off | require Authorization: Bearer <token> on /v1/* |
--disable-execute |
— | off | compile-only deployment (execute → 403) |
--osi-datus / --osi-basic |
— | --osi-datus |
engine mode, server-wide: datus honors DATUS custom_extensions; basic is strict standard OSI — extensions ignored, logged as warnings at startup and reported by /v1/validate (cli.md) |
The model is loaded, validated, and compiled once at startup — the process exits on an invalid model — and the compiled IR is shared immutably across requests. Warehouse executors are built once per profile, so MySQL-family and Postgres connections are pooled and reused.
Endpoints¶
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
liveness — never requires auth |
| GET | /ready |
readiness |
| GET | /docs, /openapi.json |
Swagger UI / OpenAPI spec — never require auth |
| GET | /v1/model |
model name, path, engine mode, object counts, datus_ext_version |
| GET | /v1/capabilities |
engine / OSI-spec / datus-ext versions and every DATUS extension key this engine reads, with the version that introduced it and what it costs to ignore it (datus-extensions.md) |
| GET | /v1/datasets |
datasets with source, keys, field counts |
| GET | /v1/metrics |
metrics with inferred kind and datasets |
| GET | /v1/dimensions |
dimensions (dataset.field) with time flags |
| GET | /v1/connections |
{name, dialect, available} — never endpoints or secrets |
| POST | /v1/validate |
validate inline model text (in the server's engine mode; response includes warnings) |
| POST | /v1/query/compile |
metric query → SQL |
| POST | /v1/query/explain |
metric query → logical plan (text) |
| POST | /v1/query/execute |
metric query → rows |
The query body¶
compile, explain, and execute share one body: a MetricQuery plus
dialect (default duckdb), pretty, and — execute only — connection
(a profile name; omitted = local DuckDB).
{
"metrics": ["revenue", "order_count"], // required
"group_by": [
{"field": "customers.region"}, // dataset.field
{"field": "orders.order_date", "grain": "month"} // day|week|month|quarter|year
],
"where_sql": "status = 'completed'", // pre-aggregation SQL filter
"time_range": {"start": "2024-01-01", "end": "2025-01-01",
"dimension": "orders.order_date"}, // half-open [start, end)
"order_by": [{"key": "order_date__month", "desc": true}],
"limit": 12,
"dialect": "postgres",
"connection": "warehouse-prod" // execute only
}
$ curl -s localhost:8081/v1/query/compile -H 'content-type: application/json' \
-d '{"metrics":["revenue"],"group_by":[{"field":"orders.order_date","grain":"month"}],"dialect":"postgres"}'
{"dialect":"postgres","sql":"SELECT DATE_TRUNC('MONTH', orders.order_date) AS order_date__month, ..."}
$ curl -s localhost:8081/v1/query/execute -H 'content-type: application/json' \
-d '{"metrics":["revenue"],"group_by":[{"field":"customers.region"}],"connection":"pg"}'
{"dialect":"postgres","sql":"...","columns":["region","revenue"],
"rows":[{"region":"east","revenue":340},{"region":"west","revenue":110}],"row_count":2}
$ curl -s localhost:8081/v1/validate \
-d "$(jq -Rs '{model: .}' < model.yaml)" -H 'content-type: application/json'
{"valid":true,"issues":[],"compile_errors":[]}
Arrow IPC streaming¶
POST /v1/query/execute with Accept: application/vnd.apache.arrow.stream
returns the result as a streamed Arrow IPC body instead of JSON: record
batches flow from the warehouse adapter through the IPC writer into the
response with no row materialization and no per-value JSON encoding. Columnar
consumers (Polars, pandas/pyarrow, DataFusion, another dosi) read it
zero-parse; benefit scales with result size. The JSON response stays
byte-identical for clients that don't opt in.
$ curl -s localhost:8081/v1/query/execute -H 'content-type: application/json' \
-H 'accept: application/vnd.apache.arrow.stream' \
-d '{"metrics":["revenue"],"group_by":[{"field":"customers.region"}]}' \
| python3 -c 'import pyarrow.ipc,sys; print(pyarrow.ipc.open_stream(sys.stdin.buffer).read_all())'
Semantics:
- Pre-flight errors keep the JSON envelope: compile/profile/warehouse
failures are detected before the response commits, so you still get the
structured
{"error": {code, message, hint}}with the proper HTTP status. - Mid-stream failures terminate the body (standard for streaming APIs) — the IPC stream ends without the end-of-stream marker; re-request.
--execute-timeout-secsbounds time-to-first-byte on this path, not total stream time; the concurrency permit is held until the stream finishes.- The CLI equivalent is
dosi query --execute --format arrow(IPC on stdout). - Availability: on by default (server feature
arrow); a Flight SQL server endpoint is future work — this REST body is the current columnar egress.
Errors¶
Error bodies wrap the engine's structured errors verbatim, with the same
stable machine codes as dosi --format json:
{"error": {"code": "unknown_metric",
"message": "unknown metric \"revenu\"",
"candidates": ["revenue", "order_count", "..."]}}
| HTTP | When |
|---|---|
| 400 | planner rejections (unknown_metric, ambiguous_dimension, no_join_path, ...), bad JSON, unknown dialect, profile config errors |
| 401 | missing/wrong bearer token (only when --auth-token is set) |
| 403 | /v1/query/execute with --disable-execute |
| 429 | all execution slots busy (Retry-After: 1) |
| 501 | planner not_implemented |
| 502 | warehouse unreachable / credentials rejected / SQL rejected (connection, auth, sql_rejected, driver) |
| 504 | warehouse timeout or the server's own execute timeout |
Concurrency model¶
- Compile/explain/list are pure CPU over the shared in-memory IR and run inline on the async workers — no locks, no I/O. A release build sustains thousands of compile requests per second at single-digit-millisecond p50.
- Execute is blocking warehouse I/O: a semaphore
(
--max-concurrent-executions) bounds it,spawn_blockingkeeps it off the async workers, and--execute-timeout-secsabandons the response. Known v1 limit: an abandoned call still runs to completion in the background, holding its permit until it finishes. - MySQL-family and Postgres profiles pool connections (
--pool-sizeper profile); ClickHouse/Trino reuse HTTP keep-alive; DuckDB spawns a subprocess per call.