mod sync;
pub(crate) mod traces;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::any;
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::channel::guards;
use crate::errors::OrionError;
use crate::server::extract::{OrionBody, OrionQuery, PeerAddr};
use crate::server::routes::openapi::ErrorResponse;
use crate::server::state::AppState;
use sync::process_sync_for_channel;
const CREDENTIAL_HEADERS: [&str; 4] = [
"authorization",
"cookie",
"proxy-authorization",
"x-api-key",
];
pub fn data_routes() -> Router<AppState> {
Router::new().route("/{*path}", any(dynamic_handler))
}
#[utoipa::path(
post,
path = "/api/v1/data/{channel}",
tag = "Data",
operation_id = "process_channel_request",
summary = "Invoke a channel synchronously",
description = "\
Invoke a channel's workflow synchronously.
This is a **templated** path, not a static one. Orion serves the whole data \
plane from a single catch-all route (`/api/v1/data/{*path}`) and resolves the \
target channel at request time, so no per-channel path exists in this document:
* **Simple HTTP channels** — a single path segment matched against the channel \
`name`, e.g. `POST /api/v1/data/order-intake`.
* **REST channels** — each active channel registers its own method and path \
pattern (`config.rest.routes`) at engine-reload time; those patterns may span \
several segments and declare their own path parameters, which arrive in the \
workflow as `metadata.params`, percent-decoded exactly once (`a%2Fb` becomes \
`a/b`). Static segments match byte-exact — the path is case-sensitive per \
RFC 3986. Any HTTP method is accepted — `GET`, `PUT`, \
`PATCH` and `DELETE` behave identically to the `POST` documented here, with \
the verb exposed as `metadata.http_method`. Query the admin channel API for \
the routes a given deployment actually serves.
Append `/async` to submit to the queue instead — see \
`POST /api/v1/data/{channel}/async`.
Authentication is per channel and off by default: `admin_auth` covers the admin \
plane only. A channel carrying an `auth` block in its config (`api_key` or \
`hmac`) authenticates every caller on this path and on `/async` alike; a channel \
without one is open to anyone who can reach the port. `validation_logic` and \
`origin_allow_list` complement it but are not authentication — `Origin` is \
client-supplied.",
params(
("channel" = String, Path, description = "Channel name, or the first segment of a REST channel's registered route pattern."),
("profile" = Option<bool>, Query, description = "Set to `1`/`true` to append `_orion.profile` timings to the response. Requires `tracing.debug_profile_enabled = true`; the `X-Orion-Profile` header does the same."),
),
request_body(
content = ProcessRequest,
description = "Workflow input, in either of two shapes. An object carrying `data` or `metadata` is the \
**envelope**: `data` is the payload, and `metadata` is merged into the message metadata alongside the \
server-supplied `channel`, `http_method`, `params`, `query` and `headers` keys. Any other JSON body **is** \
the payload — `{\"amount\": 5}` is equivalent to `{\"data\": {\"amount\": 5}}`. An empty body is accepted \
(typical for `GET`/`DELETE` REST channels) and treated as `{\"data\": {}}`.",
content_type = "application/json",
),
responses(
(status = 200, description = "Workflow completed. `errors` is empty on success; when tasks failed it carries sanitized `{code, message, task_id}` entries and the envelope gains a `request_id` for correlation with the persisted trace.", body = ProcessResponse),
(status = 400, description = "Malformed JSON body, empty channel segment, an invalid percent-sequence in the request path, or a channel `validation_logic` rejection (`VALIDATION_ERROR`, with per-field `details`)", body = ErrorResponse),
(status = 401, description = "The channel declares `auth` and the request did not satisfy it — absent, wrong, or malformed credential. One message for every cause, so a caller cannot learn which half they had right.", body = ErrorResponse),
(status = 403, description = "`Origin` header not in the channel's `origin_allow_list`", body = ErrorResponse),
(status = 404, description = "No channel serves this request: either no REST route matches the requested method and path, or the single-segment name is not an active channel in the registry.", body = ErrorResponse),
(status = 409, description = "Deduplication key already seen inside the channel's dedup window", body = ErrorResponse),
(status = 413, description = "Request body exceeded `ingest.max_payload_size` (`PAYLOAD_TOO_LARGE`)", body = ErrorResponse),
(status = 415, description = "Non-empty body without a JSON `Content-Type`", body = ErrorResponse),
(status = 429, description = "Rate limit exceeded (global or per-channel)", body = ErrorResponse),
(status = 500, description = "Result exceeded `queue.max_result_size_bytes` (`RESPONSE_TOO_LARGE`), or an internal failure (`INTERNAL_ERROR`)", body = ErrorResponse),
(status = 503, description = "Channel backpressure limit reached, a connector circuit breaker is open (`CIRCUIT_OPEN`), or a rate-limit/dedup backend outage on a channel configured with `on_backend_error = \"deny\"`", body = ErrorResponse),
(status = 504, description = "Workflow exceeded the channel's `timeout_ms`", body = ErrorResponse),
)
)]
#[tracing::instrument(
skip(state, uri, headers, peer, query_params, body),
fields(path = %uri.path())
)]
pub(crate) async fn dynamic_handler(
State(state): State<AppState>,
method: axum::http::Method,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
PeerAddr(peer): PeerAddr,
OrionQuery(query_params): OrionQuery<std::collections::HashMap<String, String>>,
OrionBody(body): OrionBody,
) -> Result<impl IntoResponse, OrionError> {
let path = uri.path().trim_start_matches('/');
let (route_path, is_async) = if let Some(stripped) = path.strip_suffix("/async") {
(stripped, true)
} else {
(path, false)
};
let route_path = route_path.trim_matches('/').trim();
if route_path.is_empty() {
return Err(OrionError::validation("Channel name must not be empty"));
}
let (channel, route_params) = if let Some(rm) = state
.channel_registry
.match_route(method.as_str(), route_path)?
{
(rm.channel_name, rm.params)
} else if !route_path.contains('/') {
let name = crate::channel::routing::percent_decode_segment(route_path)
.map(std::borrow::Cow::into_owned)
.unwrap_or_else(|| route_path.to_string());
let name = name.trim().to_string();
if name.is_empty() {
return Err(OrionError::validation("Channel name must not be empty"));
}
(name, std::collections::HashMap::new())
} else {
return Err(OrionError::NotFound(format!(
"No channel matches {method} /{route_path}"
)));
};
if !body.is_empty() {
let content_type = headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let is_json =
content_type.starts_with("application/json") || content_type.contains("+json");
if !is_json {
return Err(OrionError::UnsupportedMediaType(
"Content-Type must be application/json for requests with a body".to_string(),
));
}
}
let req = ProcessRequest::from_body(&body)?;
let profile_requested = state.config.tracing.debug_profile_enabled
&& (header_or_query_truthy(&headers, &query_params, "x-orion-profile", "profile"));
let metadata = build_request_metadata(
&req.metadata,
&channel,
&method,
&route_params,
&query_params,
&headers,
);
let channel_runtime = state.channel_registry.require_serviceable(&channel)?;
let Some(runtime) = channel_runtime.clone() else {
return Err(OrionError::NotFound(format!(
"Channel '{channel}' not found or not active"
)));
};
let header_lookup = |name: &str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
let client_ip = crate::server::rate_limit::client_ip_from_parts(
peer.as_ref(),
&headers,
state.trusted_proxies(),
);
let transport = if is_async {
guards::Transport::HttpAsync
} else {
guards::Transport::HttpSync
};
let admission = match guards::apply_guards(guards::GuardRequest {
transport,
channel: &channel,
runtime: &channel_runtime,
data: &req.data,
metadata: &metadata,
datalogic: &state.datalogic,
origin: headers.get("origin").and_then(|v| v.to_str().ok()),
caller_identity: &client_ip,
header: &header_lookup,
raw_body: Some(&body),
dedup_key_fallback: None,
dedup_owner: None,
default_timeout_ms: None,
max_timeout_ms: None,
})
.await?
{
guards::GuardVerdict::CacheHit(body) => {
let shaped = runtime
.parsed_config
.response
.as_ref()
.is_some_and(|cfg| cfg.is_shaped());
return Ok(sync::cached_response(body, shaped));
}
guards::GuardVerdict::Admitted(admission) => admission,
};
if is_async {
return submit_async(
&state,
channel,
req.data,
metadata,
runtime,
profile_requested,
admission,
)
.await;
}
process_sync_for_channel(
&state,
&channel,
req.data,
metadata,
runtime,
profile_requested,
admission,
)
.await
}
fn build_request_metadata(
req_metadata: &Value,
channel: &str,
method: &axum::http::Method,
route_params: &std::collections::HashMap<String, String>,
query_params: &std::collections::HashMap<String, String>,
headers: &axum::http::HeaderMap,
) -> Value {
let mut metadata = if req_metadata.is_object() {
req_metadata.clone()
} else {
json!({})
};
metadata["channel"] = json!(channel);
metadata["http_method"] = json!(method.as_str());
if !route_params.is_empty() {
metadata["params"] = json!(route_params);
}
if !query_params.is_empty() {
metadata["query"] = json!(query_params);
}
let header_map: serde_json::Map<String, Value> = headers
.iter()
.filter_map(|(name, value)| {
let name = name.as_str();
if CREDENTIAL_HEADERS.contains(&name) {
return Some((name.to_string(), json!(crate::connector::MASK)));
}
value.to_str().ok().map(|v| (name.to_string(), json!(v)))
})
.collect();
metadata["headers"] = Value::Object(header_map);
metadata
}
async fn submit_async(
state: &AppState,
channel: String,
data: Value,
metadata: Value,
channel_runtime: std::sync::Arc<crate::channel::ChannelRuntimeConfig>,
profile_requested: bool,
admission: guards::Admission,
) -> Result<Response, OrionError> {
let backpressure_permit = admission.backpressure_permit;
let trace_headers = {
let mut h = std::collections::HashMap::new();
crate::server::trace_context::inject_trace_context(&mut h);
h
};
let input_json = serde_json::to_string(&data).ok();
let channel_id = Some(channel_runtime.channel.channel_id.as_str());
let token = uuid::Uuid::new_v4().simple().to_string();
let token_hash = crate::server::admin_auth::hash_trace_token(&token);
let trace = state
.repos
.traces
.create_pending(
&channel,
channel_id,
"async",
input_json.as_deref(),
Some(&token_hash),
)
.await?;
let trace_id = trace.id.clone();
let response: Response = (
StatusCode::ACCEPTED,
Json(json!({ "trace_id": trace.id, "trace_token": token })),
)
.into_response();
let submitted = state
.trace_queue
.submit(crate::queue::QueueMessage {
trace_id: trace_id.clone(),
channel,
payload: data,
metadata,
trace_headers,
profile_requested,
backpressure_permit,
})
.await;
if let Err(e) = submitted {
if let Err(update_err) = state
.repos
.traces
.update_status(
&trace_id,
crate::storage::models::TRACE_STATUS_FAILED,
Some("Submission shed: trace queue at capacity"),
)
.await
{
tracing::error!(
trace_id = %trace_id,
error = %update_err,
"Failed to settle pending trace after queue shed"
);
}
return Err(e);
}
Ok(response)
}
#[allow(dead_code)]
#[utoipa::path(
post,
path = "/api/v1/data/{channel}/async",
tag = "Data",
operation_id = "submit_channel_request_async",
summary = "Submit to a channel asynchronously",
description = "\
Queue a channel's workflow for background execution and return immediately.
Accepts the same body and resolves the channel exactly as \
`POST /api/v1/data/{channel}` (including REST route patterns — append `/async` \
to any of them). All ingress guards still apply before the queue hand-off: \
the origin allow-list, the rate limit, `validation_logic`, deduplication and \
backpressure. The response cache is sync-only, so an async submission never \
returns a cached body.
Poll `GET /api/v1/admin/traces/{id}` with the returned `trace_id` for the \
result, presenting the returned `trace_token` via the `x-trace-token` header \
or `?token=` query parameter. The token scopes the poll to this submission \
(R12); an admin credential also works.
`trace_id` is always present. Async submission is a request for a result to be \
fetched later, so the trace row is written before the 202 is sent even when \
`trace_storage.mode` is `off` — that setting still applies in full to the \
synchronous endpoint, where the caller already has the answer.",
params(
("channel" = String, Path, description = "Channel name, or the first segment of a REST channel's registered route pattern."),
),
request_body(
content = ProcessRequest,
description = "Same envelope as the synchronous endpoint.",
content_type = "application/json",
),
responses(
(
status = 202,
description = "Accepted and queued. `trace_id` and `trace_token` are always present — the trace row is written before this response is sent, so the id can always be polled.",
body = AsyncSubmitResponse,
),
(status = 400, description = "Malformed JSON body, empty channel segment, an invalid percent-sequence in the request path, or a `validation_logic` rejection", body = ErrorResponse),
(status = 401, description = "The channel declares `auth` and the request did not satisfy it — absent, wrong, or malformed credential. One message for every cause, so a caller cannot learn which half they had right.", body = ErrorResponse),
(status = 403, description = "`Origin` header not in the channel's `origin_allow_list`", body = ErrorResponse),
(status = 404, description = "No channel serves this request: either no REST route matches the requested method and path, or the single-segment name is not an active channel in the registry.", body = ErrorResponse),
(status = 409, description = "Deduplication key already seen inside the channel's dedup window", body = ErrorResponse),
(status = 413, description = "Request body exceeded `ingest.max_payload_size` (`PAYLOAD_TOO_LARGE`)", body = ErrorResponse),
(status = 415, description = "Non-empty body without a JSON `Content-Type`", body = ErrorResponse),
(status = 429, description = "Rate limit exceeded (global or per-channel)", body = ErrorResponse),
(status = 503, description = "Channel backpressure limit reached, the trace queue is full/closed, or a rate-limit/dedup backend outage on a channel configured with `on_backend_error = \"deny\"`", body = ErrorResponse),
)
)]
pub(crate) fn submit_channel_request_async_docs() {}
const TRUTHY_VALUES: &[&str] = &["1", "true", "yes", "on"];
fn is_truthy_str(s: &str) -> bool {
let trimmed = s.trim().to_ascii_lowercase();
TRUTHY_VALUES.contains(&trimmed.as_str())
}
fn header_or_query_truthy(
headers: &axum::http::HeaderMap,
query: &std::collections::HashMap<String, String>,
header_name: &str,
query_name: &str,
) -> bool {
if let Some(v) = headers.get(header_name).and_then(|v| v.to_str().ok())
&& is_truthy_str(v)
{
return true;
}
if let Some(v) = query.get(query_name)
&& is_truthy_str(v)
{
return true;
}
false
}
fn empty_object() -> Value {
Value::Object(serde_json::Map::new())
}
#[derive(Deserialize, utoipa::ToSchema)]
pub(crate) struct ProcessRequest {
#[serde(default = "empty_object")]
data: Value,
#[serde(default = "empty_object")]
metadata: Value,
}
impl ProcessRequest {
fn from_body(body: &[u8]) -> Result<Self, OrionError> {
if body.is_empty() {
return Ok(Self {
data: json!({}),
metadata: json!({}),
});
}
let parsed: Value = serde_json::from_slice(body)
.map_err(|e| OrionError::validation(format!("Invalid JSON body: {e}")))?;
match parsed {
Value::Object(mut obj) if obj.contains_key("data") || obj.contains_key("metadata") => {
Ok(Self {
data: obj.remove("data").unwrap_or_else(empty_object),
metadata: obj.remove("metadata").unwrap_or_else(empty_object),
})
}
other => Ok(Self {
data: other,
metadata: json!({}),
}),
}
}
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[cfg_attr(test, derive(serde::Deserialize))]
#[cfg_attr(test, serde(deny_unknown_fields))]
pub(crate) struct ProcessResponse {
id: String,
#[schema(example = "ok")]
status: String,
data: Value,
errors: Vec<ProcessTaskError>,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(rename = "_orion", default, skip_serializing_if = "Option::is_none")]
orion: Option<Value>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
#[cfg_attr(test, derive(serde::Deserialize))]
#[cfg_attr(test, serde(deny_unknown_fields))]
pub(crate) struct ProcessTaskError {
code: String,
#[schema(example = "Task processing failed; full detail is available in the trace")]
message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(crate) struct AsyncSubmitResponse {
trace_id: String,
trace_token: String,
}