use crate::models::{
ErrorResponse, ExecutionDetailResponse, ExecutionResponse, ListQuery, PauseRequest,
PauseRuleResponse, PotentiallyStaleDepResponse, RerunRequest, RerunResponse, RestartRequest,
RestartResponse, ResumeResponse, RetryStepRequest, RetryStepResponse, RunRecordResponse,
StartExecutionRequest, StartExecutionResponse, StatsResponse, StepAttemptResponse,
StepDetailResponse, WaitQuery,
};
use crate::{admin_routes, routes};
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
routes::list_executions,
routes::start_execution,
routes::get_execution,
routes::cancel_execution,
routes::wait_execution,
routes::offer_event,
routes::get_stats,
routes::healthz,
routes::readyz,
admin_routes::retry_step,
admin_routes::restart,
admin_routes::rerun,
admin_routes::execution_detail,
admin_routes::list_runs,
admin_routes::create_pause,
admin_routes::list_pauses,
admin_routes::resume_rule,
admin_routes::delete_pause_rule,
),
components(schemas(
StartExecutionRequest,
ListQuery,
WaitQuery,
ExecutionResponse,
StartExecutionResponse,
ErrorResponse,
RetryStepRequest,
RestartRequest,
RerunRequest,
RetryStepResponse,
RestartResponse,
RerunResponse,
PotentiallyStaleDepResponse,
RunRecordResponse,
PauseRequest,
PauseRuleResponse,
ResumeResponse,
StatsResponse,
ExecutionDetailResponse,
StepDetailResponse,
StepAttemptResponse,
)),
tags(
(name = "executions", description = "Durable execution lifecycle"),
(name = "events", description = "External event delivery"),
(name = "stats", description = "Aggregate execution statistics"),
(name = "health", description = "Liveness and readiness probes"),
(name = "admin-executions", description = "Administrative execution operations"),
(name = "admin-pause", description = "Pause rule management"),
),
info(title = "Zart API", version = env!("CARGO_PKG_VERSION"))
)]
pub struct ZartApiDoc;
#[derive(utoipa::OpenApi)]
#[openapi(paths(
routes::list_executions,
routes::start_execution,
routes::get_execution,
routes::cancel_execution,
routes::wait_execution,
routes::offer_event,
routes::get_stats,
routes::healthz,
routes::readyz,
))]
struct ApiOnlyDoc;
#[derive(utoipa::OpenApi)]
#[openapi(paths(
admin_routes::retry_step,
admin_routes::restart,
admin_routes::rerun,
admin_routes::execution_detail,
admin_routes::list_runs,
admin_routes::create_pause,
admin_routes::list_pauses,
admin_routes::resume_rule,
admin_routes::delete_pause_rule,
))]
struct AdminOnlyDoc;
const DEFAULT_API_SKIP: &[&str] = &["/healthz", "/readyz"];
const DEFAULT_ADMIN_SKIP: &[&str] = &[];
struct PrefixRewriter {
prefix: String,
skip: Vec<String>,
}
impl utoipa::Modify for PrefixRewriter {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let old = std::mem::take(&mut openapi.paths.paths);
for (path, item) in old {
let new_path = if self.skip.iter().any(|s| s == &path) {
path
} else {
format!("{}{}", self.prefix, path)
};
openapi.paths.paths.insert(new_path, item);
}
}
}
fn resolve_skip(provided: Option<&[&str]>, default: &[&str]) -> Vec<String> {
provided
.unwrap_or(default)
.iter()
.map(|s| s.to_string())
.collect()
}
pub fn build_openapi(
api_prefix: &str,
admin_prefix: &str,
api_skip: Option<&[&str]>,
admin_skip: Option<&[&str]>,
) -> utoipa::openapi::OpenApi {
use utoipa::{Modify as _, OpenApi as _};
let mut api_doc = ApiOnlyDoc::openapi();
PrefixRewriter {
prefix: api_prefix.to_string(),
skip: resolve_skip(api_skip, DEFAULT_API_SKIP),
}
.modify(&mut api_doc);
let mut admin_doc = AdminOnlyDoc::openapi();
PrefixRewriter {
prefix: admin_prefix.to_string(),
skip: resolve_skip(admin_skip, DEFAULT_ADMIN_SKIP),
}
.modify(&mut admin_doc);
for (path, item) in admin_doc.paths.paths {
api_doc.paths.paths.insert(path, item);
}
let full = ZartApiDoc::openapi();
api_doc.components = full.components;
api_doc.tags = full.tags;
api_doc.info = full.info;
api_doc
}
pub fn swagger_ui_router(api_prefix: &str, admin_prefix: &str) -> axum::Router {
utoipa_swagger_ui::SwaggerUi::new("/swagger-ui")
.url(
"/openapi.json",
build_openapi(api_prefix, admin_prefix, None, None),
)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
use utoipa::OpenApi as _;
#[test]
fn zart_api_doc_has_paths() {
let doc = ZartApiDoc::openapi();
assert!(
!doc.paths.paths.is_empty(),
"ZartApiDoc must expose at least one path"
);
}
#[test]
fn zart_api_doc_paths_are_relative() {
let doc = ZartApiDoc::openapi();
for path in doc.paths.paths.keys() {
assert!(
!path.starts_with("/api/v1") && !path.starts_with("/zart/admin"),
"path '{path}' must be relative (no hardcoded prefix)"
);
}
}
#[test]
fn build_openapi_applies_custom_api_prefix() {
let doc = build_openapi("/v2", "/zart/admin/v1", None, None);
assert!(
doc.paths.paths.contains_key("/v2/executions"),
"expected /v2/executions in paths"
);
}
#[test]
fn build_openapi_applies_custom_admin_prefix() {
let doc = build_openapi("/api/v1", "/ops/admin", None, None);
assert!(
doc.paths.paths.contains_key("/ops/admin/pause"),
"expected /ops/admin/pause in paths"
);
}
#[test]
fn build_openapi_keeps_health_at_root() {
let doc = build_openapi("/v2", "/ops", None, None);
assert!(
doc.paths.paths.contains_key("/healthz"),
"/healthz must remain at root regardless of prefix"
);
assert!(
doc.paths.paths.contains_key("/readyz"),
"/readyz must remain at root regardless of prefix"
);
}
#[test]
fn build_openapi_no_unprefixed_api_paths() {
let doc = build_openapi("/v2", "/ops", None, None);
for path in doc.paths.paths.keys() {
let is_root = path == "/healthz" || path == "/readyz";
let has_prefix = path.starts_with("/v2") || path.starts_with("/ops");
assert!(
is_root || has_prefix,
"path '{path}' has no prefix — expected /v2 or /ops"
);
}
}
#[test]
fn build_openapi_custom_api_skip_overrides_default() {
let doc = build_openapi("/v2", "/ops", Some(&[]), None);
assert!(
doc.paths.paths.contains_key("/v2/healthz"),
"/v2/healthz must appear when skip list is empty"
);
assert!(
!doc.paths.paths.contains_key("/healthz"),
"/healthz must not appear at root when skip list is empty"
);
}
#[test]
fn build_openapi_no_unprefixed_admin_paths() {
let doc = build_openapi("/api/v1", "/ops/admin", None, None);
assert!(
!doc.paths.paths.contains_key("/pause"),
"/pause must not appear unprefixed"
);
assert!(
doc.paths.paths.contains_key("/ops/admin/pause"),
"/ops/admin/pause must be present"
);
}
#[test]
fn zart_api_doc_serialises_to_json() {
let doc = ZartApiDoc::openapi();
let json = serde_json::to_string(&doc).expect("serialisation must succeed");
assert!(json.contains("Zart API"), "title must appear in JSON");
}
}