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::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>,
}
struct Artifact {
path: PathBuf,
contents: 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();
for service in &generated {
if !service.emit_outputs {
continue;
}
append_artifacts(
&project_root,
&service.root,
&service.document,
&service.outputs,
service.overlay.as_deref(),
&args,
&mut artifacts,
)?;
}
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 document = 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(),
document: service.document.clone(),
})
.collect(),
)
.map_err(|error| error.to_string())?;
append_artifacts(
&project_root,
&project_root,
&document,
aggregate_config
.outputs
.as_ref()
.unwrap_or(&OpenApiOutputsConfig {
yaml: Some("openapi.yaml".into()),
json: None,
}),
aggregate_config.overlay.as_deref(),
&args,
&mut artifacts,
)?;
}
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)?;
}
for artifact in &artifacts {
println!(
"{} {}",
if args.dry_run {
"validated"
} else if args.check {
"current"
} else {
"generated"
},
artifact.path.display()
);
}
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 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
}
};
let outputs = openapi
.outputs
.clone()
.or_else(|| defaults.outputs.clone())
.unwrap_or(OpenApiOutputsConfig {
yaml: Some("openapi.yaml".into()),
json: None,
});
Ok(GeneratedService {
name: service.name.clone(),
prefix: openapi.aggregate_path_prefix.clone(),
document,
outputs,
overlay: openapi.overlay.clone(),
root,
emit_outputs: true,
})
}
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,
artifacts: &mut Vec<Artifact>,
) -> Result<(), 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())?,
);
}
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 });
}
Ok(())
}
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 {}",
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(())
}