Code-First OpenAPI & Client Generation¶
SignalDB's HTTP admin, tenant-management, and trace-query API is code-first: the Rust handlers and their data types are the single source of truth, and the OpenAPI spec plus every client are generated from them. Nothing is hand-authored downstream of the code, so the spec cannot drift from what the router actually serves.
flowchart LR
A["#[utoipa::path] handlers<br/>#[derive(ToSchema)] DTOs"] --> B["ApiDoc<br/>(router::openapi)"]
B -->|golden test| C["api/signaldb-api.json<br/>(OpenAPI 3.1)"]
B -->|served at| S["/api/v1/openapi.json"]
C -->|progenitor<br/>+ 3.1→3.0 downconvert| D["signaldb-sdk<br/>(Rust client, CLI)"]
C -->|@hey-api/openapi-ts| E["src/ui/src/api/gen<br/>(TS client, UI)"]
Source of truth¶
- DTOs live in
signaldb-apias hand-written structs derivingutoipa::ToSchema(the instance-admin tenant/user DTOs under/api/v1) and, for the management surface (tenant-admin session,tenant:managekey, or the break-glass admin key with no tenant), insrc/router/src/endpoints/management.rs. Field names and serde attributes define the JSON wire format;ToSchemamakes each struct an OpenAPI component.TenantResponse,ListTenantsResponse, andCreateTenantRequestare handler-local structs defined directly inendpoints/tenants.rs, distinct from the DTOssignaldb-apiexports. - Operations are declared with
#[utoipa::path(...)]on the handlers inendpoints/tenants.rs(/api/v1/..., instance-admin tenant/user management — reachable by an instance-admin session or the break-glass admin key with no tenant),endpoints/management.rs(/api/v1/..., including the API-keyPOST/PATCHbodies with their requiredscopes),endpoints/tempo.rs(the Tempo-compatible trace query endpoints under/tempo/api/..., whose DTOs live intempo-api),endpoints/query.rs(the native Query IR endpointPOST /api/v1/query, whose request/response DTOs — including the response envelope'sQueryWarningentries — are defined in that module), the PromQL/LogQL instant and range query endpoints plus their label-discovery endpoints inendpoints/promql.rs(/prometheus/api/v1/query{,_range},/labels,/label/{name}/values) andendpoints/logql.rs(/loki/api/v1/query{,_range},/labels,/label/{name}/values), the operational-control endpoints inendpoints/ops.rs(/api/v1/ops/compact{,/status,/dry-run}, admin-authenticated, proxied to the compactor's Flightdo_actionsurface),endpoints/oauth.rs(the session-authed OAuth consent surface the explore-UI consumes —GET /oauth/consent/contextandPOST /oauth/authorize/decision),endpoints/session.rsandendpoints/oidc.rs(the login page's unauthenticated surface —GET /ui/session/configand the cookie-onlyGET /ui/session, both declared with an empty security requirement and with their nullable fields markedrequiredso the generated clients type them asT | nullrather than optional — plus the SSO redirect endpointsGET /ui/session/oidc/{start,callback}, so the UI reads the SSO offering and thegranted_bymembership source through generated types — change: oidc-login),endpoints/github.rs(the GitHub App installation surface —start_github_link,list_github_installations,remove_github_installation,attach_github_installation(attaches an installation that already exists on GitHub directly, for when a second tenant on the same GitHub account can't complete the OAuth install flow — change: github-installation-direct-attach) under/api/v1/tenants/{id}/github-installations, plus the unauthenticatedGET /ui/github/callbackinstall redirect declared with an empty security requirement — change: github-app-source-context),endpoints/source_context.rs(the stack-frame source lookup —source_contextonPOST /api/v1/tenants/{id}/source-context, always200with an available/unavailable status, and itsGETsiblingsource_context_availability; the tenant self-service prefix, not the management one, since any signal reader may call it), andendpoints/schema.rs(the schema registry:/api/v1/schema/registriesCRUD +:validate, and attribute/entity/metric resolution and prefix search under/api/v1/schema/{attributes,entities,metrics}; its resolved-definition DTOs deriveToSchemaincommon::schema_registryandschema-model, and the raw registry document is typed as an opaque object), andendpoints/tenant.rs(tenant tables and schemas:GET/POST /api/v1/tenants/{id}/tables{,/create},GET /api/v1/tenants/{id}/schemas,GET /api/v1/schemas/available, response DTOs incommon::tenant_api, includingDatasetTablesforListTablesResponse's per-dataset grouping). Paths are absolute, and operationIds are plain<verb>_<resource>names (list_tenants,create_dataset);endpoints/tenants.rsowns the merged tenant and user handlers. A few management DTOs that would collide withsignaldb_apitypes by bare name are aliasedManage*via#[schema(as = ...)]. The same technique disambiguates the Tempo v1/v2 tag types intempo-api(tempo_api::TagSearchResponsevs.tempo_api::v2::TagSearchResponse, …): utoipa registers schemas by bare type name, so the v2 module's types carry#[schema(as = tempo_api::v2::TagSearchResponse)]etc. to keep their own component entries instead of colliding with v1's. The PromQL/LogQL handlers set explicitoperation_ids (promql_query,logql_query, …) because their bare handler names collide. src/router/src/openapi.rsassembles everything into theApiDoc(#[derive(OpenApi)]) — info,servers, thebearerAuthsecurity scheme, tags, the path list, and the component schemas.
The router serves this document live at /api/v1/openapi.json
(openapi_document()), so the served spec is always exactly the code.
Route-vs-OpenAPI drift guard¶
axum 0.8 has no public route-introspection API, so router::openapi's tests
(known_routes_match_router_fn_source, every_known_route_has_an_openapi_operation)
extract the route literals straight out of the endpoint router() fn source
for the modules whose router() is a plain list of .route(...) calls under
one fixed mount prefix, and diff them against a hand-maintained
KNOWN_ROUTES/ALLOWLISTED_ROUTES pair — catching both directions of drift
(a route added to source without an OpenAPI operation, or a stale list
entry). Public/infra routes (/health, the spec endpoint itself, session,
OAuth) are trusted by inspection instead of extracted. Pre-existing Tempo v2/echo/metrics, Loki series/detected_fields,
and Prometheus label_stats/series routes are ALLOWLISTED_ROUTES (not yet
in the OpenAPI contract, tracked separately) rather than annotated.
Cross-cutting response headers that apply to every operation — the
Server-Timing/traceresponse trace-context headers the shared middleware
adds — are documented once in info.description, not repeated as per-response
header schemas on each path.
The spec artifact and its golden test¶
api/signaldb-api.json is the committed OpenAPI 3.1 document. A golden test in
router::openapi regenerates it from ApiDoc and fails if the checked-in file
drifts:
# Refresh the committed spec after changing annotations:
UPDATE_OPENAPI=1 cargo test -p router openapi_spec_is_up_to_date
# CI runs the same test without the env var, so a stale spec fails the build.
What xtask generates¶
cargo xtask generate produces every committed generated artifact;
cargo xtask check verifies they are current and is what CI gates on. Two
groups, sharing one write_or_check contract — generate to a scratch
location, then either write the file or fail with a diff:
| Artifact | From |
|---|---|
Rust SDK (signaldb-sdk) |
api/signaldb-api.json |
TypeScript client (src/ui/src/api/gen) |
api/signaldb-api.json |
tempo-api protobuf bindings (src/tempo-api/src/generated/*.rs) |
src/tempo-api/proto/tempo.proto + the opentelemetry-proto submodule |
The protobuf half lives here rather than in a build script because a build
script may only write into OUT_DIR, and these outputs are committed. The
previous src/tempo-api/build.rs wrote into the package directory and read a
submodule outside the crate root, which meant tempo-api could never package
at all; see xtask/src/tempopb.rs. Regenerating it needs
git submodule update --init opentelemetry-proto and protoc, neither of
which an ordinary cargo build requires any more.
Downstream clients¶
The two OpenAPI-derived clients:
- Rust SDK (
signaldb-sdk, consumed bysignaldb-cliandmcp-server) via progenitor. xtask setswith_inner_type(crate::retry::RetryPolicy), so the generatedClient::new/new_with_clienttake the retry policy and carry it as the client's inner value; the hand-writtenimpl ClientHooks<RetryPolicy> for Clientinsrc/signaldb-sdk/src/retry.rsoverrides progenitor'sexec— the single choke point every generated operation goes through — withsignaldb_sdk::retry::execute(the shared retry-on-throttle policy and W3C trace-context injection; seedocs/users/client-retry.md). This uses progenitor's documented auto-ref specialization (the generated code implements the hooks only for&Client, so an impl forClientwins method resolution); no post-processing of progenitor's output is involved, andsignaldb-sdk/tests/retry.rsguards both halves. Consumers construct clients throughsignaldb_sdk::ClientBuilder, neverreqwestdirectly (source-scan tests in both crates enforce it). progenitor parses through theopenapiv3crate, which only understands OpenAPI 3.0, so xtask downconverts its input only (downconvert_nullable_typesinxtask/src/main.rs), handling both 3.1 nullable encodings utoipa emits: nullabletypearrays (["string","null"]) becometype+nullable: true;"oneOf": [{"type": "null"}, X](emitted forOption<T>whereTis a$ref— a struct or enum DTO) has the null branch dropped andX's fields merged onto the parent object plusnullable: true. The IR version is pinned to3.0.3. The served spec andsignaldb-api.jsonstay 3.1. - TypeScript client (
src/ui/src/api/gen, consumed by the web UI) via@hey-api/openapi-ts(config insrc/ui/openapi-ts.config.ts), which consumes 3.1 directly. Incheckmode xtask regenerates into a temp directory and compares, so it never mutates the tree.
xtask also appends pub const OPERATIONS: &[&str] to the end of
generated.rs — every operation id in the document, alphabetized, extracted
from the same (pre-downconversion) spec value the Rust client is generated
from. signaldb-sdk's own test asserts it matches api/signaldb-api.json
directly, independent of the generation step. This is the manifest
tests-integration/tests/query_parity.rs iterates for the whole-SDK
surface-parity check (client-surface-parity spec): every operation must
have a CLI command and an MCP tool, or a reviewed entry in that test's
EXCLUDED list explaining why not.
xtask also owns two non-OpenAPI vendoring tasks. cargo xtask vendor-semconv
copies the OpenTelemetry semantic-conventions model/ tree at the version
pinned by common::self_monitoring::SEMCONV_SCHEMA_URL into
vendor/otel-semconv/ (the source of the bundled otel schema registry). It
is run by hand when the pin is bumped; a common test fails if the vendored
VERSION and the pin disagree. cargo xtask vendor-semconv-genai <commit>
does the same for the GenAI conventions (vendor/otel-semconv-genai/, the
bundled otel-genai registry) at a full commit SHA, since that repository has
no release tag yet, and repins otel/registry-genai/manifest.yaml to the
same commit; the build fails if the two ever differ.
Because the annotated paths are absolute, generated client URLs are absolute too — the CLI's admin client and the UI client are both configured with the router root as their base URL.
Adding or changing an endpoint¶
- Add/adjust the
ToSchemaDTOs and the#[utoipa::path]annotation on the handler; register new paths/schemas inrouter::openapi::ApiDoc. UPDATE_OPENAPI=1 cargo test -p router openapi_spec_is_up_to_dateto refreshapi/signaldb-api.json.cargo xtask generateto regenerate the Rust and TypeScript clients. The TypeScript half shells out to@hey-api/openapi-tsthrough pnpm, so a fresh worktree needspnpm install --frozen-lockfilerun once at the repository root before this step works (otherwiserun_openapi_tsfails fast with a message naming the missingnode_modulesdirectory and the fix, rather than pnpm's opaqueERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL).- Consume the endpoint through the generated clients — the UI must not issue
raw HTTP against the API (see the DoD in
openspec/config.yaml). - Commit the code, the spec, and the regenerated clients together.
CI enforces all of this: the golden test gates spec-vs-code in the Test Suite
job, and the codegen job runs cargo xtask check to gate the clients.
Known gaps¶
- A nullable
$ref(struct or enum) used to break the Rust SDK generator.Option<T>whereTderivesToSchemamakes utoipa emit"oneOf": [{"type": "null"}, {"$ref": "..."}], which progenitor's schema-to-Rust generator panicked on (not yet implemented: invalid type: null, into_schema.rs) — the panic was in client generation, socargo xtask check's spec-golden-test analog passed even though codegen itself couldn't complete.downconvert_nullable_typesinxtask/src/main.rsnow flattens thisoneOfshape before handing the spec to progenitor (see above), soOption<T>for a$refDTO works like any other optional field — no per-field workaround needed.ManageLogicalField::levelinendpoints/management.rsstill convertsAttributeLevelto a plainOption<String>at the handler boundary, but that's no longer required to avoid this panic; it predates the fix and can be revisited independently. - An operation with more than one distinct non-2xx body type breaks the
Rust SDK generator. progenitor's
extract_responsesassertsresponse_types.len() <= 1when building a method's signature — it can't express "one of several possible error shapes" (aTODOinprogenitor-implacknowledges this needs an enum type it doesn't generate). The router's shared429rate-limit response (components.responses.RateLimited, bodyApiErrorBody) collides with this whenever an operation's other declared errors use a different type (e.g.management.rs'sManageError,schema.rs'sSchemaError), or whenever a previously-error-free operation gets its first typed error at all — the latter would silently flip the generated method'sError<E>fromError<()>to something concrete, breaking hand-writtensignaldb-sdkcallers (mcp-server,signaldb-cli) that use bare?.homogenize_error_response_bodiesinxtask/src/main.rsfixes this for progenitor's input only (never the served spec or the committedapi/signaldb-api.json, and never the TypeScript client, which has no such limitation): it retargets an operation's429to reuse an already-homogeneous sibling error type where one exists, so existing callers see no signature change, and otherwise strips the429body (keeping its headers) so the operation's error type stays exactly what it was. - The Tempo (trace), Loki (LogQL), and Prometheus (PromQL) instant/range query
endpoints are all annotated. Tempo responses are typed (
SearchResult,Trace, …). The PromQL and LogQL responses, however, are declared with a looseserde_json::Valuebody rather than typed schemas: their[timestamp, "value"]tuple sample shapes need extra schema handling, so the generated clients see an opaque JSON value and pass the native Loki/Prometheus response through unchanged. Tightening those two response schemas is a follow-up (epic #620, Phase A). The Loki/Prometheus label-discovery endpoints (labels,label_values) share the same loose-body treatment — they return a flat{status, data}shape simple enough that a typed schema adds no value. - The polymorphic Tempo attribute
Value(a serde-tagged union of string/int/bool/double) serializes as an untyped object in the schema, so the generated clients see it as an arbitrary JSON value rather than a typed enum. - The Pyroscope-compatible query endpoints (
/pyroscope/...,/api/profiles/...) are annotated and in the code-first spec (change:pyroscope-openapi-parity). Their responses are typed (pyroscope_api::RenderResponse,LabelsResponse,ProfileType,tempo_api::ProfileSummary) —pyroscope-api's types deriveToSchemaalongside their existingSerialize/Deserialize, and router fixture tests pin the wire JSON so the schema addition can't silently change the serialized shape.