Skip to content

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-secs bounds 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_blocking keeps it off the async workers, and --execute-timeout-secs abandons 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-size per profile); ClickHouse/Trino reuse HTTP keep-alive; DuckDB spawns a subprocess per call.