use serde_json::Value;
use utoipa::openapi::path::Operation;
use utoipa::openapi::security::{
ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityRequirement, SecurityScheme,
};
use utoipa::openapi::{Content, ContentBuilder, Ref, RefOr, Response, ResponseBuilder};
use utoipa::{Modify, OpenApi};
use crate::server::admin_auth::is_guarded_path;
use crate::storage::models::TraceListItemResponse;
const METRICS_ON_DOCUMENTED_LISTENER: bool = true;
pub(crate) use orion_api::{
ErrorBody as ErrorDetail, ErrorEnvelope as ErrorResponse, FieldError as ErrorFieldDetail,
};
const SCHEME_BEARER: &str = "admin_bearer";
const SCHEME_API_KEY: &str = "admin_api_key";
pub(crate) struct SecurityAddon;
fn admin_security() -> Vec<SecurityRequirement> {
let no_scopes: [&str; 0] = [];
vec![
SecurityRequirement::new(SCHEME_BEARER, no_scopes),
SecurityRequirement::new(SCHEME_API_KEY, no_scopes),
]
}
fn error_content() -> Content {
ContentBuilder::new()
.schema(Some(Ref::from_schema_name("ErrorResponse")))
.build()
}
fn error_response(description: &str) -> RefOr<Response> {
RefOr::T(
ResponseBuilder::new()
.description(description)
.content("application/json", error_content())
.build(),
)
}
fn fill_error_content(operation: &mut Operation) {
for (status, response) in operation.responses.responses.iter_mut() {
if !status.starts_with('4') && !status.starts_with('5') {
continue;
}
if let RefOr::T(response) = response
&& response.content.is_empty()
{
response
.content
.insert("application/json".to_string(), error_content());
}
}
}
fn ensure_response(operation: &mut Operation, status: &str, response: RefOr<Response>) {
operation
.responses
.responses
.entry(status.to_string())
.or_insert(response);
}
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
SCHEME_BEARER,
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.description(Some(
"Admin API key presented as `Authorization: Bearer <key>` — the \
default. Enforced on `/api/v1/admin/*`, `/metrics`, and \
`/api/v1/admin/traces*` whenever `admin_auth.enabled` is true, which \
the shipped Helm chart and HA compose files set. Keys come from \
`admin_auth.api_keys`, either in plaintext or as `sha256:<64-hex>` \
digests. The data plane (`POST /api/v1/data/{channel}`) is not \
covered by this scheme.",
))
.build(),
),
);
components.add_security_scheme(
SCHEME_API_KEY,
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::with_description(
"X-API-Key",
"Alternative to `admin_bearer`, active when `admin_auth.header` names a \
header other than `Authorization`. The raw key is sent as that header's \
value with no `Bearer ` prefix. `X-API-Key` is the conventional choice and \
is what this document shows, but the header name is deployment-specific — \
substitute whatever `admin_auth.header` is set to. Exactly one of the two \
schemes is live at a time; they are listed as alternatives because this \
document is generated without knowledge of a deployment's config.",
))),
);
}
for (path, item) in openapi.paths.paths.iter_mut() {
let guarded = is_guarded_path(path, METRICS_ON_DOCUMENTED_LISTENER);
let operations = [
("GET", item.get.as_mut()),
("PUT", item.put.as_mut()),
("POST", item.post.as_mut()),
("DELETE", item.delete.as_mut()),
("OPTIONS", item.options.as_mut()),
("HEAD", item.head.as_mut()),
("PATCH", item.patch.as_mut()),
("TRACE", item.trace.as_mut()),
];
for (method, operation) in operations
.into_iter()
.filter_map(|(m, op)| op.map(|op| (m, op)))
{
if guarded {
operation.security = Some(admin_security());
ensure_response(
operation,
"401",
error_response(
"Missing or invalid admin API key. Only returned when \
`admin_auth.enabled` is true.",
),
);
if !matches!(method, "GET" | "HEAD") {
ensure_response(
operation,
"403",
error_response(
"The presented admin key is read-only, and this method \
mutates. Only returned when `admin_auth.enabled` is true.",
),
);
}
}
ensure_response(
operation,
"500",
error_response("Unexpected internal error (`INTERNAL_ERROR`)"),
);
fill_error_content(operation);
}
}
}
}
#[derive(OpenApi)]
#[openapi(
info(
title = "Orion — Declarative Services Runtime API",
version = env!("CARGO_PKG_VERSION"),
description = "\
Declarative services runtime platform.
**Authentication.** The admin API (`/api/v1/admin/*`), the Prometheus endpoint \
(`/metrics`), and the trace read endpoints (`/api/v1/admin/traces*`) require an \
admin API key when `admin_auth.enabled` is true — the default in the shipped \
Helm chart and HA compose files. Operations that need it carry a `security` \
block; see the `admin_bearer` / `admin_api_key` schemes for how the key is \
presented and for the `admin_auth.header` config key that selects between them.
The data plane (`/api/v1/data/{channel}`) and the health probes are \
deliberately unauthenticated: channel-level access control is expressed \
through each channel's `validation_logic` and `origin_allow_list`.
**Errors.** Every non-2xx response uses the `ErrorResponse` envelope: \
`{\"error\": {\"code\", \"message\", \"request_id\"}}`, plus `details` on \
validation failures.",
license(name = "Apache-2.0"),
),
modifiers(&SecurityAddon),
tags(
(name = "Channels", description = "Channel management"),
(name = "Workflows", description = "Workflow management"),
(name = "Connectors", description = "Connector management"),
(name = "Engine", description = "Engine control"),
(name = "Functions", description = "Engine function schemas"),
(name = "Audit", description = "Admin audit-log history"),
(name = "Traces", description = "Execution trace listing and polling"),
(name = "Trace DLQ", description = "Dead-letter queue inspection, replay, and purge"),
(name = "Backups", description = "Database backup management (SQLite only)"),
(name = "Packages", description = "Package receipts — what package versions are \
staged or applied here (K14)"),
(name = "Data", description = "Data processing"),
(name = "Operational", description = "Health and metrics"),
),
paths(
// Channels
super::admin::channels::list_channels,
super::admin::channels::create_channel,
super::admin::channels::get_channel,
super::admin::channels::update_channel,
super::admin::channels::delete_channel,
super::admin::channels::change_channel_status,
super::admin::channels::list_channel_versions,
super::admin::channels::create_new_channel_version,
super::admin::channels::import_channels,
super::admin::channels::export_channels,
super::admin::channels::validate_channel,
// Workflows
super::admin::workflows::list_workflows,
super::admin::workflows::create_workflow,
super::admin::workflows::get_workflow,
super::admin::workflows::update_workflow,
super::admin::workflows::delete_workflow,
super::admin::workflows::change_workflow_status,
super::admin::workflows::update_rollout,
super::admin::workflows::list_workflow_versions,
super::admin::workflows::create_new_workflow_version,
super::admin::workflows::test_workflow,
super::admin::workflows::import_workflows,
super::admin::workflows::export_workflows,
super::admin::workflows::validate_workflow,
super::admin::workflows::workflow_dependencies,
// Connectors
super::admin::connectors::list_connectors,
super::admin::connectors::create_connector,
super::admin::connectors::get_connector,
super::admin::connectors::update_connector,
super::admin::connectors::delete_connector,
super::admin::connectors::list_circuit_breakers,
super::admin::connectors::reset_circuit_breaker,
super::admin::connectors::import_connectors,
super::admin::connectors::export_connectors,
super::admin::connectors::validate_connector,
super::admin::connectors::test_connector,
// Engine
super::admin::engine::engine_status,
super::admin::engine::engine_reload,
// Functions (A1: input-schema registry surfaced for tooling)
super::admin::functions::list_functions,
// Audit logs
super::admin::audit::list_audit_logs,
// Trace DLQ
super::admin::trace_dlq::list_trace_dlq,
super::admin::trace_dlq::get_trace_dlq_entry,
super::admin::trace_dlq::requeue_trace_dlq_entry,
super::admin::trace_dlq::purge_trace_dlq,
// Backups
super::admin::backups::create_backup,
super::admin::backups::list_backups,
// Packages (K14)
super::admin::packages::list_packages,
super::admin::packages::get_package,
super::admin::packages::put_package,
// Data plane (C8) — one catch-all handler, two documented operations
super::data::dynamic_handler,
super::data::submit_channel_request_async_docs,
super::data::traces::list_traces,
super::data::traces::get_trace,
// Operational
super::health_check,
super::liveness_check,
super::readiness_check,
super::metrics_endpoint,
),
components(
schemas(
// R22/D28: only shapes an endpoint actually returns, and never a
// row struct. Row structs were registered here and `$ref`d by
// nothing — they described `condition_json` / `tasks_json` as
// strings, which no response carries. `ConnectorResponse` is the
// masked wire shape for a single connector; the `Connector` row it
// is built from carries the unmasked config and cannot be
// serialized at all.
crate::storage::models::ConnectorResponse,
crate::storage::repositories::workflows::CreateWorkflowRequest,
crate::storage::repositories::workflows::UpdateWorkflowRequest,
crate::storage::repositories::workflows::StatusChangeRequest,
crate::storage::repositories::workflows::RolloutUpdateRequest,
crate::storage::repositories::channels::CreateChannelRequest,
crate::storage::repositories::channels::UpdateChannelRequest,
crate::storage::repositories::channels::ChannelStatusChangeRequest,
crate::storage::repositories::connectors::CreateConnectorRequest,
crate::storage::repositories::connectors::UpdateConnectorRequest,
super::admin::trace_dlq::PurgeTraceDlqRequest,
super::admin::ValidationEnvelope,
crate::storage::models::PackageReceiptResponse,
crate::storage::models::PackageState,
super::admin::workflows::WorkflowDependencies,
super::admin::workflows::ConnectorDependency,
crate::storage::repositories::packages::PutPackageReceiptRequest,
super::admin::packages::PackageDetail,
super::data::ProcessRequest,
super::data::ProcessResponse,
super::data::ProcessTaskError,
super::data::AsyncSubmitResponse,
ErrorResponse,
ErrorDetail,
ErrorFieldDetail,
)
)
)]
pub(crate) struct ApiDoc;
pub fn pretty_json() -> String {
serde_json::to_string_pretty(&ApiDoc::openapi())
.expect("OpenAPI spec is always serializable to JSON")
}
#[cfg(test)]
mod tests {
use super::*;
fn spec() -> utoipa::openapi::OpenApi {
ApiDoc::openapi()
}
#[test]
fn security_schemes_are_registered() {
let components = spec().components.expect("components");
assert!(components.security_schemes.contains_key(SCHEME_BEARER));
assert!(components.security_schemes.contains_key(SCHEME_API_KEY));
}
#[test]
fn security_matches_the_middleware_guard() {
const HANDLER_ENFORCED_401: &[&str] = &[
"/api/v1/admin/traces/{id}",
"/api/v1/data/{channel}",
"/api/v1/data/{channel}/async",
];
let spec = spec();
for (path, item) in &spec.paths.paths {
let expected = is_guarded_path(path, METRICS_ON_DOCUMENTED_LISTENER);
let handler_enforced = HANDLER_ENFORCED_401.contains(&path.as_str());
for (method, operation) in [
("get", &item.get),
("put", &item.put),
("post", &item.post),
("delete", &item.delete),
("patch", &item.patch),
] {
let Some(operation) = operation else { continue };
assert_eq!(
operation.security.is_some(),
expected,
"{method} {path}: security block does not match is_guarded_path()"
);
assert_eq!(
operation.responses.responses.contains_key("401"),
expected || handler_enforced,
"{method} {path}: 401 response does not match is_guarded_path()"
);
}
}
}
#[test]
fn every_operation_documents_a_500() {
let spec = spec();
for (path, item) in &spec.paths.paths {
for operation in [&item.get, &item.put, &item.post, &item.delete, &item.patch]
.into_iter()
.flatten()
{
assert!(
operation.responses.responses.contains_key("500"),
"{path}: missing shared 500 response"
);
}
}
}
#[test]
fn every_response_that_can_carry_a_body_describes_one() {
let spec = spec();
let mut missing = Vec::new();
for (path, item) in &spec.paths.paths {
for (verb, operation) in [
("GET", &item.get),
("PUT", &item.put),
("POST", &item.post),
("DELETE", &item.delete),
("PATCH", &item.patch),
] {
let Some(operation) = operation else { continue };
for (status, response) in &operation.responses.responses {
if status == "204" {
continue;
}
let RefOr::T(response) = response else {
continue;
};
if response.content.is_empty() {
missing.push(format!("{verb} {path} -> {status}"));
}
}
}
}
missing.sort();
assert!(
missing.is_empty(),
"{} response(s) declare a status with no body schema:\n {}",
missing.len(),
missing.join("\n ")
);
}
#[test]
fn no_storage_row_struct_is_published_unless_it_is_the_wire_shape() {
const ROW_STRUCTS: &[&str] = &[
"Workflow",
"Channel",
"Connector",
"Trace",
"TraceListRow",
"TraceDlqEntry",
"TraceDlqSummary",
"AuditLogEntry",
"PackageReceipt",
];
let spec = spec();
let schemas = spec
.components
.as_ref()
.expect("components")
.schemas
.keys()
.cloned()
.collect::<Vec<_>>();
for row in ROW_STRUCTS {
for name in &schemas {
let leaks = name.split('_').any(|segment| segment == *row);
assert!(
!leaks,
"`{name}` publishes the `{row}` storage row — no endpoint \
returns it. Publish the DTO from storage::models::dto instead."
);
}
}
}
#[test]
fn data_plane_and_probes_are_documented() {
let spec = spec();
for path in [
"/api/v1/data/{channel}",
"/api/v1/data/{channel}/async",
"/healthz",
"/readyz",
] {
assert!(spec.paths.paths.contains_key(path), "missing path: {path}");
}
let data = spec
.paths
.paths
.get("/api/v1/data/{channel}")
.and_then(|item| item.post.as_ref())
.expect("POST /api/v1/data/{channel}");
assert!(data.security.is_none());
for status in ["200", "400", "409", "429", "503", "504"] {
assert!(
data.responses.responses.contains_key(status),
"data plane missing {status}"
);
}
let async_submit = spec
.paths
.paths
.get("/api/v1/data/{channel}/async")
.and_then(|item| item.post.as_ref())
.expect("POST /api/v1/data/{channel}/async");
let accepted = async_submit
.responses
.responses
.get("202")
.expect("202 accepted");
let no_warning_header = match accepted {
RefOr::T(response) => !response.headers.contains_key("warning"),
RefOr::Ref(_) => false,
};
assert!(
no_warning_header,
"the 202 must not document a `Warning` header — `trace_id` is unconditional"
);
let schemas = spec
.components
.as_ref()
.map(|c| &c.schemas)
.expect("components.schemas");
let async_response = serde_json::to_value(
schemas
.get("AsyncSubmitResponse")
.expect("AsyncSubmitResponse is registered"),
)
.expect("schema serializes");
let required = async_response["required"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
assert!(
required.contains(&"trace_id") && required.contains(&"trace_token"),
"both must be required — a nullable trace_id was R11's permanent half: \
{async_response}"
);
}
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct DataEnvelope<T> {
#[allow(dead_code)] data: T,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct PaginatedEnvelope<T> {
#[allow(dead_code)]
data: Vec<T>,
#[allow(dead_code)]
total: i64,
#[allow(dead_code)]
limit: i64,
#[allow(dead_code)]
offset: i64,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct ConnectorListItem {
id: String,
name: String,
connector_type: String,
config_json: String,
config: serde_json::Value,
enabled: bool,
tags: Vec<String>,
content_hash: String,
created_at: String,
updated_at: String,
#[schema(example = "loaded")]
load_status: String,
#[serde(skip_serializing_if = "Option::is_none")]
load_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
load_error_stage: Option<String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct ConnectorExportItem {
id: String,
name: String,
connector_type: String,
config: serde_json::Value,
enabled: bool,
tags: serde_json::Value,
content_hash: String,
}
pub(crate) use orion_api::ImportResult;
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct EngineStatus {
version: String,
uptime_seconds: i64,
workflows_count: u64,
active_workflows: u64,
channels: Vec<String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct EngineReloaded {
reloaded: bool,
workflows_count: u64,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct CircuitBreakerStates {
enabled: bool,
scope: String,
instance_id: String,
breakers: std::collections::HashMap<String, String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct CircuitBreakerReset {
reset: bool,
key: String,
found_on_this_node: bool,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct DlqPurgeResult {
purged: u64,
older_than_hours: i64,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct WorkflowTestResult {
matched: bool,
trace: Value,
output: Value,
errors: Vec<Value>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct BackupFile {
filename: String,
path: String,
size_bytes: u64,
created_at: String,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct BackupListItem {
filename: String,
size_bytes: u64,
modified_at: String,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct TracePageEnvelope {
data: Vec<TraceListItemResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
total: Option<i64>,
limit: i64,
offset: i64,
#[serde(skip_serializing_if = "Option::is_none")]
next_cursor: Option<String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct TraceDetail {
id: String,
status: String,
mode: String,
channel: String,
channel_id: Option<String>,
created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
started_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
completed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
duration_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
task_trace_json: Option<Value>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct FunctionSchemaItem {
name: String,
description: String,
category: String,
input_fields: Vec<Value>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[allow(dead_code)]
pub(crate) struct HealthStatus {
status: String,
version: String,
uptime_seconds: i64,
components: Value,
#[serde(skip_serializing_if = "Option::is_none")]
git_hash: Option<String>,
}