use std::{
fs,
path::{Component, Path, PathBuf},
};
use serde_json::Value;
use xbp_openapi_gen::{
aggregate::{aggregate, ServiceDocument},
cache::{cache_key, read as read_cache, source_metadata, write as write_cache},
config::{GeneratorConfig, UnknownSchemaPolicy, WebSocketRouteConfig},
discovery::generate,
model::{OpenApiDocument, Server},
overlay::{apply_merge_patch, parse_overlay},
render::{render_json, Dialect},
servers::derive_servers,
validate::{validate_document, validate_value},
};
use crate::{
commands::openapi_archive::{
archive_and_upload_openapi_generations, OpenApiArchiveArtifact, OpenApiArtifactKind,
},
commands::service::load_xbp_config_with_root,
strategies::deployment_config::{
get_all_services, OpenApiOutputsConfig, OpenApiServiceDefaultsConfig, ServiceConfig,
ServiceOpenApiConfig, XbpConfig,
},
};
#[derive(Debug, Clone)]
pub struct GenerateOpenApiArgs {
pub services: Vec<String>,
pub all: bool,
pub aggregate: bool,
pub format: Option<String>,
pub output: Option<PathBuf>,
pub check: bool,
pub dry_run: bool,
pub strict: bool,
pub no_cache: bool,
pub version: Option<String>,
}
const XBP_CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
struct Artifact {
path: PathBuf,
contents: String,
service_name: String,
service_version: String,
kind: OpenApiArtifactKind,
path_count: u32,
operation_count: u32,
schema_count: u32,
server_count: u32,
websocket_operation_count: u32,
openapi_dialect: String,
}
struct GeneratedService {
name: String,
prefix: Option<String>,
document: OpenApiDocument,
outputs: OpenApiOutputsConfig,
overlay: Option<String>,
root: PathBuf,
emit_outputs: bool,
}
pub async fn run_generate_openapi(args: GenerateOpenApiArgs, debug: bool) -> Result<(), String> {
generate_openapi(args, debug).await.map(|_| ())
}
pub async fn generate_openapi(
args: GenerateOpenApiArgs,
debug: bool,
) -> Result<Vec<PathBuf>, String> {
let (project_root, config) = load_xbp_config_with_root().await?;
let project_openapi = config
.openapi
.as_ref()
.ok_or_else(|| "openapi generation is not configured in .xbp/xbp.yaml".to_string())?;
if project_openapi.enabled != Some(true) {
return Err("openapi.enabled must be true before generation can run".into());
}
if args.output.is_some() && (args.services.len() != 1 || args.format.as_deref() == Some("both"))
{
return Err("--output requires exactly one --service and one --format".into());
}
let defaults = project_openapi.service_defaults.clone().unwrap_or_default();
let auto_detect = project_openapi.auto_detect_services.unwrap_or(true);
let requested: std::collections::HashSet<_> =
args.services.iter().map(String::as_str).collect();
let mut generated = Vec::new();
for service in get_all_services(&config) {
let service_openapi = service.openapi.clone().unwrap_or_default();
let eligible = match service_openapi.enabled {
Some(value) => value,
None => auto_detect,
};
let selected = if args.all {
eligible
} else if requested.is_empty() {
eligible
&& service_root(&project_root, &service)
.starts_with(std::env::current_dir().map_err(|e| e.to_string())?)
} else {
requested.contains(service.name.as_str()) && eligible
};
if !eligible || (!selected && !args.aggregate) {
continue;
}
let mut service = generate_service(
&project_root,
&config,
&service,
&defaults,
&service_openapi,
&args,
)?;
service.emit_outputs = selected;
generated.push(service);
}
if generated.is_empty() || !generated.iter().any(|service| service.emit_outputs) {
let target = if requested.is_empty() {
"current directory".into()
} else {
format!("service(s) {}", args.services.join(", "))
};
return Err(format!("no OpenAPI-enabled service matched {target}"));
}
let mut artifacts = Vec::new();
let mut artifact_owners: Vec<String> = Vec::new();
for service in &generated {
if !service.emit_outputs {
continue;
}
let before = artifacts.len();
append_artifacts(
&project_root,
&service.root,
&service.document,
&service.outputs,
service.overlay.as_deref(),
&args,
&service.name,
&service.document.version,
OpenApiArtifactKind::Service,
&mut artifacts,
)?;
for _ in before..artifacts.len() {
artifact_owners.push(format!("service `{}`", service.name));
}
}
let mut aggregate_error: Option<String> = None;
if (args.all || args.aggregate)
&& project_openapi
.aggregate
.as_ref()
.and_then(|value| value.enabled)
.unwrap_or(false)
{
let aggregate_config = project_openapi.aggregate.as_ref().unwrap();
let multi_service = generated.len() > 1;
match aggregate(
format!("{} API", config.project_name),
args.version.as_deref().unwrap_or(&config.version),
generated
.iter()
.map(|service| ServiceDocument {
name: service.name.clone(),
path_prefix: service.prefix.clone().or_else(|| {
multi_service.then(|| format!("/{}", service.name))
}),
document: service.document.clone(),
})
.collect(),
) {
Ok(mut document) => {
document.stamp_xbp_cli_generator(XBP_CLI_VERSION);
let default_aggregate_outputs = OpenApiOutputsConfig {
yaml: Some("openapi.aggregate.yaml".into()),
json: Some("openapi.aggregate.json".into()),
};
let before = artifacts.len();
if let Err(error) = append_artifacts(
&project_root,
&project_root,
&document,
aggregate_config
.outputs
.as_ref()
.unwrap_or(&default_aggregate_outputs),
aggregate_config.overlay.as_deref(),
&args,
"_aggregate",
&document.version,
OpenApiArtifactKind::Aggregate,
&mut artifacts,
) {
aggregate_error = Some(error);
} else {
for _ in before..artifacts.len() {
artifact_owners.push("aggregate".into());
}
}
}
Err(error) => {
aggregate_error = Some(format!(
"{error}. Hint: set services[].openapi.aggregate_path_prefix (for example `/athena` and `/athena-auth`) via `xbp config openapi`, or disable aggregate in project openapi config."
));
}
}
}
ensure_unique_artifact_paths(&artifacts, &artifact_owners)?;
if args.check {
let changed: Vec<_> = artifacts
.iter()
.filter(|artifact| {
fs::read_to_string(&artifact.path).ok().as_deref() != Some(&artifact.contents)
})
.map(|artifact| artifact.path.display().to_string())
.collect();
if !changed.is_empty() {
return Err(format!("OpenAPI output is stale: {}", changed.join(", ")));
}
} else if !args.dry_run {
write_transactionally(&artifacts)?;
let archive_items: Vec<OpenApiArchiveArtifact> = artifacts
.iter()
.map(|artifact| OpenApiArchiveArtifact {
path: artifact.path.clone(),
contents: artifact.contents.clone(),
service_name: artifact.service_name.clone(),
service_version: artifact.service_version.clone(),
kind: artifact.kind,
xbp_cli_version: XBP_CLI_VERSION.to_string(),
path_count: artifact.path_count,
operation_count: artifact.operation_count,
schema_count: artifact.schema_count,
server_count: artifact.server_count,
websocket_operation_count: artifact.websocket_operation_count,
openapi_dialect: artifact.openapi_dialect.clone(),
})
.collect();
match archive_and_upload_openapi_generations(&project_root, &archive_items).await {
Ok(result) => {
for dir in result
.project_dirs
.iter()
.chain(result.global_dirs.iter())
{
println!("archived {}", dir.display());
}
if result.uploaded > 0 {
println!("uploaded {} OpenAPI artifact(s) to xbp.app", result.uploaded);
} else if let Some(reason) = result.upload_skipped_reason {
eprintln!("warning: OpenAPI upload skipped: {reason}");
}
}
Err(error) => {
eprintln!("warning: OpenAPI archive failed: {error}");
}
}
}
for artifact in &artifacts {
println!(
"{} {}",
if args.dry_run {
"validated"
} else if args.check {
"current"
} else {
"generated"
},
artifact.path.display()
);
}
if let Some(error) = aggregate_error {
eprintln!("warning: aggregate OpenAPI skipped: {error}");
if artifacts.is_empty() {
return Err(error);
}
}
if debug {
eprintln!(
"OpenAPI generation used static source analysis only (cache bypass: {})",
args.no_cache
);
}
Ok(artifacts
.into_iter()
.map(|artifact| artifact.path)
.collect())
}
fn generate_service(
project_root: &Path,
project: &XbpConfig,
service: &ServiceConfig,
defaults: &OpenApiServiceDefaultsConfig,
openapi: &ServiceOpenApiConfig,
args: &GenerateOpenApiArgs,
) -> Result<GeneratedService, String> {
let root = service_root(project_root, service);
let mut generator = GeneratorConfig::new(&service.name, &root);
generator.version = args
.version
.clone()
.unwrap_or_else(|| project.version.clone());
generator.framework = openapi.framework.clone();
generator.dialect = match openapi
.spec_version
.as_deref()
.or(defaults.spec_version.as_deref())
.unwrap_or("3.1.0")
{
"3.0" | "3.0.0" | "3.0.1" | "3.0.2" | "3.0.3" => Dialect::V3_0,
"3.1" | "3.1.0" | "3.1.1" => Dialect::V3_1,
version => {
return Err(format!(
"service {} has unsupported spec_version {version}",
service.name
))
}
};
generator.strict = args.strict || openapi.strict.or(defaults.strict).unwrap_or(false);
generator.unknown_schema = match openapi
.unknown_schema
.as_deref()
.or(defaults.unknown_schema.as_deref())
.unwrap_or("permissive")
{
"permissive" => UnknownSchemaPolicy::Permissive,
"strict" => UnknownSchemaPolicy::Strict,
value => {
return Err(format!(
"service {} has invalid unknown_schema {value}",
service.name
))
}
};
generator.source_roots = openapi
.source_roots
.as_ref()
.or(defaults.source_roots.as_ref())
.cloned()
.unwrap_or_else(|| vec!["src".into()])
.into_iter()
.map(PathBuf::from)
.collect();
generator.servers = service_servers(service, openapi);
generator.websocket_routes = openapi
.websocket_routes
.clone()
.unwrap_or_default()
.into_iter()
.map(|route| WebSocketRouteConfig {
path: route.path,
client_message_schema: route.client_message_schema,
server_message_schema: route.server_message_schema,
message_format: route.message_format.unwrap_or_else(|| "json".into()),
server_sends_first: route.server_sends_first.unwrap_or(false),
})
.collect();
let cache_root = project_root.join(".xbp/cache/openapi-gen");
let fingerprint = serde_json::to_string(&(service, defaults, openapi, &generator.version))
.map_err(|error| error.to_string())?;
let key = (!args.no_cache).then(|| {
cache_key(
&fingerprint,
&source_metadata(&generator.service_root, &generator.source_roots),
)
});
let document = if let Some(key) = &key {
read_cache(&cache_root, key)
.and_then(|value| serde_json::from_str::<OpenApiDocument>(&value).ok())
.filter(|document| validate_document(document).is_ok())
} else {
None
};
let mut document = match document {
Some(document) => document,
None => {
let generation = generate(&generator).map_err(|error| error.to_string())?;
for diagnostic in generation.diagnostics {
eprintln!("warning: {}: {}", service.name, diagnostic.message);
}
if !args.no_cache && !args.dry_run && !args.check {
let serialized = serde_json::to_string(&generation.document)
.map_err(|error| error.to_string())?;
write_cache(
&cache_root,
key.as_deref().expect("cache key exists"),
&serialized,
)
.map_err(|error| {
format!(
"failed to write OpenAPI cache {}: {error}",
cache_root.display()
)
})?;
}
generation.document
}
};
document.stamp_xbp_cli_generator(XBP_CLI_VERSION);
let outputs = openapi
.outputs
.clone()
.or_else(|| defaults.outputs.clone())
.unwrap_or(OpenApiOutputsConfig {
yaml: Some("openapi.yaml".into()),
json: Some("openapi.json".into()),
});
let outputs = disambiguate_project_root_outputs(
project_root,
&root,
&service.name,
outputs,
openapi.outputs.is_some(),
);
Ok(GeneratedService {
name: service.name.clone(),
prefix: openapi.aggregate_path_prefix.clone(),
document,
outputs,
overlay: openapi.overlay.clone(),
root,
emit_outputs: true,
})
}
fn disambiguate_project_root_outputs(
project_root: &Path,
service_root: &Path,
service_name: &str,
outputs: OpenApiOutputsConfig,
explicit_outputs: bool,
) -> OpenApiOutputsConfig {
if explicit_outputs {
return outputs;
}
let same_root = paths_equalish(project_root, service_root);
if !same_root {
return outputs;
}
let safe = sanitize_service_filename(service_name);
OpenApiOutputsConfig {
yaml: outputs
.yaml
.map(|path| rewrite_root_output_name(&path, &safe, "yaml")),
json: outputs
.json
.map(|path| rewrite_root_output_name(&path, &safe, "json")),
}
}
fn paths_equalish(left: &Path, right: &Path) -> bool {
if left == right {
return true;
}
match (left.canonicalize(), right.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
fn sanitize_service_filename(name: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
if cleaned.is_empty() {
"service".into()
} else {
cleaned
}
}
fn rewrite_root_output_name(path: &str, service: &str, ext: &str) -> String {
let path = Path::new(path);
if path.components().count() > 1 {
return path.to_string_lossy().into_owned();
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("openapi");
format!("{stem}.{service}.{ext}")
}
fn ensure_unique_artifact_paths(
artifacts: &[Artifact],
owners: &[String],
) -> Result<(), String> {
let mut seen: std::collections::HashMap<PathBuf, usize> = std::collections::HashMap::new();
for (index, artifact) in artifacts.iter().enumerate() {
if let Some(previous) = seen.insert(artifact.path.clone(), index) {
let left = owners.get(previous).map(String::as_str).unwrap_or("?");
let right = owners.get(index).map(String::as_str).unwrap_or("?");
return Err(format!(
"multiple OpenAPI documents target {} (from {left} and {right}). \
Configure distinct services[].openapi.outputs paths or aggregate.outputs via `xbp config openapi`.",
artifact.path.display()
));
}
}
Ok(())
}
fn service_root(project_root: &Path, service: &ServiceConfig) -> PathBuf {
service.root_directory.as_deref().map_or_else(
|| project_root.to_path_buf(),
|path| project_root.join(path),
)
}
fn service_servers(service: &ServiceConfig, openapi: &ServiceOpenApiConfig) -> Vec<Server> {
let Some(derivation) = &openapi.server_derivation else {
return service
.url
.as_ref()
.map(|url| Server {
url: url.clone(),
description: None,
})
.into_iter()
.collect();
};
let local = derivation.local.as_ref().and_then(|local| {
local.enabled.unwrap_or(false).then_some((
local.scheme.as_deref().unwrap_or("http"),
local.host.as_deref().unwrap_or("localhost"),
service.port,
local.description.as_deref(),
))
});
derive_servers(
derivation
.from_service_url
.unwrap_or(false)
.then_some(service.url.as_deref())
.flatten(),
derivation.service_url_description.as_deref(),
local,
)
}
#[allow(clippy::too_many_arguments)]
fn append_artifacts(
project_root: &Path,
output_root: &Path,
document: &OpenApiDocument,
configured: &OpenApiOutputsConfig,
overlay: Option<&str>,
args: &GenerateOpenApiArgs,
service_name: &str,
service_version: &str,
kind: OpenApiArtifactKind,
artifacts: &mut Vec<Artifact>,
) -> Result<(), String> {
let mut document = document.clone();
document.stamp_xbp_cli_generator(XBP_CLI_VERSION);
let stats = document.stats();
let dialect = document.dialect.version().to_string();
let mut value: Value = serde_json::from_str(&render_json(&document).map_err(|e| e.to_string())?)
.map_err(|error| error.to_string())?;
if let Some(overlay) = overlay {
let path = safe_output_path(project_root, output_root, overlay)?;
let contents = fs::read_to_string(&path)
.map_err(|error| format!("failed to read overlay {}: {error}", path.display()))?;
apply_merge_patch(
&mut value,
&parse_overlay(&contents).map_err(|e| e.to_string())?,
);
ensure_generator_metadata_on_value(&mut value, XBP_CLI_VERSION, &stats, &dialect);
}
validate_value(&value).map_err(|error| error.to_string())?;
let formats: Vec<_> = match args.format.as_deref() {
Some("yaml") => vec![("yaml", configured.yaml.as_deref().unwrap_or("openapi.yaml"))],
Some("json") => vec![("json", configured.json.as_deref().unwrap_or("openapi.json"))],
Some("both") => vec![
("yaml", configured.yaml.as_deref().unwrap_or("openapi.yaml")),
("json", configured.json.as_deref().unwrap_or("openapi.json")),
],
None => {
let mut values = Vec::new();
if let Some(path) = configured.yaml.as_deref() {
values.push(("yaml", path));
}
if let Some(path) = configured.json.as_deref() {
values.push(("json", path));
}
values
}
Some(value) => return Err(format!("unsupported format {value}")),
};
if formats.is_empty() {
return Err("OpenAPI outputs must configure yaml, json, or a --format override".into());
}
for (format, configured_path) in formats {
let path = if let Some(output) = &args.output {
safe_output_path(project_root, project_root, output)?
} else {
safe_output_path(project_root, output_root, configured_path)?
};
let contents = if format == "json" {
format!(
"{}\n",
serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?
)
} else {
serde_yaml::to_string(&value).map_err(|e| e.to_string())?
};
artifacts.push(Artifact {
path,
contents,
service_name: service_name.to_string(),
service_version: service_version.to_string(),
kind,
path_count: stats.path_count,
operation_count: stats.operation_count,
schema_count: stats.schema_count,
server_count: stats.server_count,
websocket_operation_count: stats.websocket_operation_count,
openapi_dialect: dialect.clone(),
});
}
Ok(())
}
fn ensure_generator_metadata_on_value(
value: &mut Value,
cli_version: &str,
stats: &xbp_openapi_gen::model::DocumentStats,
dialect: &str,
) {
let Some(root) = value.as_object_mut() else {
return;
};
let generator = serde_json::json!({
"name": "xbp",
"command": "xbp generate openapi",
"cliVersion": cli_version,
"openApiDialect": dialect,
"pathCount": stats.path_count,
"operationCount": stats.operation_count,
"schemaCount": stats.schema_count,
"serverCount": stats.server_count,
"websocketOperationCount": stats.websocket_operation_count,
});
root.insert(
"x-generator".into(),
Value::String(format!("xbp-cli/{cli_version}")),
);
root.insert(
"x-xbp-cli-version".into(),
Value::String(cli_version.to_string()),
);
root.insert("x-xbp-generator".into(), generator);
let info = root
.entry("info".to_string())
.or_insert_with(|| serde_json::json!({}));
if let Some(info) = info.as_object_mut() {
info.insert(
"x-xbp-cli-version".into(),
Value::String(cli_version.to_string()),
);
info.insert(
"x-generated-by".into(),
Value::String("xbp generate openapi".into()),
);
info.insert(
"x-generator".into(),
Value::String(format!("xbp-cli/{cli_version}")),
);
let stamp = format!(
"Generated by xbp CLI v{cli_version} (`xbp generate openapi`). \
Static source analysis only (no runtime introspection). \
Surface: {} path(s), {} operation(s), {} schema(s).",
stats.path_count, stats.operation_count, stats.schema_count
);
match info.get("description").and_then(Value::as_str) {
Some(existing) if existing.contains("Generated by xbp CLI") => {
let body = existing
.split_once("\n\n")
.map(|(_, rest)| rest.trim())
.filter(|rest| !rest.is_empty())
.unwrap_or("");
let description = if body.is_empty() {
stamp
} else {
format!("{stamp}\n\n{body}")
};
info.insert("description".into(), Value::String(description));
}
Some(existing) if !existing.trim().is_empty() => {
info.insert(
"description".into(),
Value::String(format!("{stamp}\n\n{}", existing.trim())),
);
}
_ => {
info.insert("description".into(), Value::String(stamp));
}
}
}
}
fn safe_output_path(
project_root: &Path,
base: &Path,
path: impl AsRef<Path>,
) -> Result<PathBuf, String> {
let path = path.as_ref();
if path.is_absolute()
|| path
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err(format!(
"OpenAPI path escapes the project: {}",
path.display()
));
}
let resolved = base.join(path);
if !resolved.starts_with(project_root) {
return Err(format!(
"OpenAPI path escapes the project: {}",
path.display()
));
}
let canonical_project = project_root
.canonicalize()
.map_err(|error| format!("failed to resolve project root: {error}"))?;
let mut existing = resolved.as_path();
while !existing.exists() {
existing = existing.parent().ok_or_else(|| {
format!(
"OpenAPI path has no existing ancestor: {}",
resolved.display()
)
})?;
}
let canonical_existing = existing
.canonicalize()
.map_err(|error| format!("failed to resolve {}: {error}", existing.display()))?;
if !canonical_existing.starts_with(&canonical_project) {
return Err(format!(
"OpenAPI path resolves outside the project: {}",
path.display()
));
}
Ok(resolved)
}
fn write_transactionally(artifacts: &[Artifact]) -> Result<(), String> {
let mut destinations = std::collections::HashSet::new();
for artifact in artifacts {
if !destinations.insert(artifact.path.clone()) {
return Err(format!(
"multiple OpenAPI documents target {}. \
Configure distinct services[].openapi.outputs or aggregate.outputs via `xbp config openapi`.",
artifact.path.display()
));
}
}
let mut staged = Vec::new();
for (index, artifact) in artifacts.iter().enumerate() {
if let Some(parent) = artifact.path.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
let temporary = artifact
.path
.with_extension(format!("xbp-openapi-{index}.tmp"));
fs::write(&temporary, &artifact.contents).map_err(|error| error.to_string())?;
staged.push((temporary, &artifact.path));
}
let mut backups = Vec::new();
for (index, (_, destination)) in staged.iter().enumerate() {
if destination.exists() {
let backup = destination.with_extension(format!("xbp-openapi-{index}.bak"));
if backup.exists() {
fs::remove_file(&backup).map_err(|error| error.to_string())?;
}
if let Err(error) = fs::rename(destination, &backup) {
for (previous_backup, original) in backups {
let _ = fs::rename(previous_backup, original);
}
for (temporary, _) in &staged {
let _ = fs::remove_file(temporary);
}
return Err(error.to_string());
}
backups.push((backup, (*destination).clone()));
}
}
let mut installed = Vec::new();
for (temporary, destination) in staged {
if let Err(error) = fs::rename(&temporary, destination) {
for path in installed {
let _ = fs::remove_file(path);
}
for (backup, original) in backups {
let _ = fs::rename(backup, original);
}
return Err(error.to_string());
}
installed.push(destination.clone());
}
for (backup, _) in backups {
let _ = fs::remove_file(backup);
}
Ok(())
}