From 989bec35c04d6a3c984cc60f374665348e1a2548 Mon Sep 17 00:00:00 2001 From: Greg Smith Date: Fri, 9 Oct 2026 19:40:21 +0000 Subject: [PATCH 1/4] feat(openapi): add profile-driven OpenAPI 3.1 service and type binding (generate-openapi) --- src/bin/ucp-schema.rs | 62 ++- src/codegen/mod.rs | 18 + src/codegen/profile.rs | 75 ++++ src/lib.rs | 2 + src/openapi/mod.rs | 732 ++++++++++++++++++++++++++++++ tests/generate_openapi_test.rs | 781 +++++++++++++++++++++++++++++++++ 6 files changed, 1666 insertions(+), 4 deletions(-) create mode 100644 src/openapi/mod.rs create mode 100644 tests/generate_openapi_test.rs diff --git a/src/bin/ucp-schema.rs b/src/bin/ucp-schema.rs index 0eb0586..67e5c21 100644 --- a/src/bin/ucp-schema.rs +++ b/src/bin/ucp-schema.rs @@ -9,10 +9,10 @@ use clap::{Parser, Subcommand}; use ucp_schema::{ bundle_refs, bundle_refs_with_url_mapping, compose_from_payload, compose_schema, detect_direction, extract_capabilities, extract_capabilities_from_profile, - extract_jsonrpc_payload, generate_types, is_url, lint, load_schema, load_schema_auto, resolve, - select_operation_schema, validate, CodegenError, ComposeError, DetectedDirection, Direction, - FileStatus, GenerateTypesOptions, ResolveError, ResolveOptions, SchemaBaseConfig, - ValidateError, + extract_jsonrpc_payload, generate_openapi, generate_types, is_url, lint, load_schema, + load_schema_auto, resolve, select_operation_schema, validate, CodegenError, ComposeError, + DetectedDirection, Direction, FileStatus, GenerateOpenApiOptions, GenerateTypesOptions, + ResolveError, ResolveOptions, SchemaBaseConfig, ValidateError, }; /// Errors with associated CLI exit codes. @@ -293,6 +293,36 @@ enum Commands { )] pretty: bool, }, + + /// Generate an OpenAPI 3.1.0 specification by binding a UCP profile's REST service schema to its compiled capability types + #[command(name = "generate-openapi")] + GenerateOpenapi { + /// Path or URL to a UCP discovery profile declaring a REST service binding (required) + #[arg(long, short = 'p')] + profile: String, + + /// Optional service FQDN filter when a profile declares multiple REST services (e.g. dev.ucp.shopping) + #[arg(long)] + service: Option, + + /// Base server URL injected into servers (overrides the profile's REST service endpoint) + #[arg(long)] + server_url: Option, + + /// Output file path (emits JSON to stdout if omitted) + #[arg(long, short = 'o')] + output: Option, + + /// Pretty-print JSON output (defaults to true; use --pretty=false for compact) + #[arg( + long, + default_value_t = true, + num_args = 0..=1, + default_missing_value = "true", + action = clap::ArgAction::Set + )] + pretty: bool, + }, } fn main() -> ExitCode { @@ -395,6 +425,14 @@ fn main() -> ExitCode { output, pretty, ), + + Commands::GenerateOpenapi { + profile, + service, + server_url, + output, + pretty, + } => run_generate_openapi(profile, service, server_url, output, pretty), }; match result { @@ -858,6 +896,22 @@ fn resolve_path_for_prefix_check(p: &Path) -> PathBuf { .unwrap_or(normalized) } +fn run_generate_openapi( + profile: String, + service: Option, + server_url: Option, + output: Option, + pretty: bool, +) -> Result<(), u8> { + let options = GenerateOpenApiOptions { + profile, + service, + server_url, + }; + let spec = generate_openapi(&options).map_err(cli_err(false))?; + write_json_output(&spec, output, pretty) +} + /// Shared helper: serialize JSON and write to output or stdout. fn write_json_output( value: &T, diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index fb8b13d..c02fd42 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -130,6 +130,12 @@ pub enum CodegenError { ResolveError(#[from] ResolveError), #[error("schema composition error: {0}")] ComposeError(#[from] ComposeError), + #[error("profile '{profile}' does not declare any service binding with transport 'rest'")] + NoRestServiceBinding { profile: String }, + #[error( + "REST service binding '{service}' in profile '{profile}' is missing required 'schema' URL" + )] + MissingServiceSchema { profile: String, service: String }, } impl CodegenError { @@ -138,6 +144,7 @@ impl CodegenError { match self { Self::ResolveError(e) => e.exit_code(), Self::ComposeError(e) => e.exit_code(), + Self::NoRestServiceBinding { .. } | Self::MissingServiceSchema { .. } => 2, } } } @@ -511,5 +518,16 @@ mod tests { let conflict_err = compile_types(&opts).expect_err("combining profile and schema_dir must error"); assert_eq!(conflict_err.exit_code(), 2); + + let no_rest = CodegenError::NoRestServiceBinding { + profile: "p".to_string(), + }; + assert_eq!(no_rest.exit_code(), 2); + + let missing_svc = CodegenError::MissingServiceSchema { + profile: "p".to_string(), + service: "s".to_string(), + }; + assert_eq!(missing_svc.exit_code(), 2); } } diff --git a/src/codegen/profile.rs b/src/codegen/profile.rs index e885b2c..e9df435 100644 --- a/src/codegen/profile.rs +++ b/src/codegen/profile.rs @@ -507,6 +507,81 @@ fn relocate_url_to_local_schemas( None } +/// Resolve a REST service binding's `schema` URL or relative path against `profile_source`, +/// relocating `.../services/` URLs to a sibling `services/` directory of any local +/// `schemas/` root discovered from the profile's capabilities when available on disk. +pub(crate) fn resolve_service_schema_target( + profile_source: &str, + service_schema_url: &str, + capabilities: &[Capability], +) -> Result { + let local_schema_roots = collect_local_schema_roots(profile_source, capabilities); + let file_part = service_schema_url.split('#').next().unwrap_or(""); + if is_url(file_part) { + if let Some(local_hit) = + relocate_url_to_local_services(profile_source, file_part, &local_schema_roots) + { + return Ok(local_hit); + } + } + resolve_schema_target(profile_source, service_schema_url, &local_schema_roots) +} + +fn collect_local_schema_roots( + profile_source: &str, + capabilities: &[Capability], +) -> Vec { + let mut crawler = ProfileCrawler::default(); + for cap in capabilities { + let Ok(resolved) = resolve_schema_target(profile_source, &cap.schema_url, &[]) else { + continue; + }; + if is_url(&resolved) { + continue; + } + let Ok(schema) = load_schema_auto(&resolved) else { + continue; + }; + crawler.record_local_schema_root(&resolved, &schema, Some(&cap.name), Some(&cap.version)); + } + crawler.local_schema_roots +} + +fn relocate_url_to_local_services( + profile_source: &str, + url_str: &str, + local_schema_roots: &[LocalSchemaRoot], +) -> Option { + let (target_origin, target_version, rel_after_services) = + split_url_marker(url_str, "/services/")?; + for root in local_schema_roots { + if root.origin != target_origin { + continue; + } + if !versions_compatible(root.version.as_deref(), target_version.as_deref()) { + continue; + } + let Some(source_root) = root.path.parent() else { + continue; + }; + let candidate = + normalize_lexical_path(&source_root.join("services").join(rel_after_services)); + if candidate.exists() { + return Some(candidate.to_string_lossy().into_owned()); + } + } + if !is_url(profile_source) { + for ancestor in Path::new(profile_source).ancestors().skip(1) { + let candidate = + normalize_lexical_path(&ancestor.join("services").join(rel_after_services)); + if candidate.exists() { + return Some(candidate.to_string_lossy().into_owned()); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index 0e8d314..c3fcec2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,6 +60,7 @@ mod error; mod linter; mod loader; mod namespace; +pub mod openapi; mod resolver; mod types; mod validator; @@ -81,6 +82,7 @@ pub use loader::{ load_schema_str, navigate_fragment, }; pub use namespace::{reverse_labels, validate_binding, BindingError}; +pub use openapi::{generate_openapi, rewrite_defs_to_component_schemas, GenerateOpenApiOptions}; pub use resolver::{resolve, strip_annotations}; pub use types::{Direction, Requires, ResolveOptions, VersionConstraint, Visibility}; pub use validator::{select_operation_schema, validate, validate_against_schema}; diff --git a/src/openapi/mod.rs b/src/openapi/mod.rs new file mode 100644 index 0000000..a66f08b --- /dev/null +++ b/src/openapi/mod.rs @@ -0,0 +1,732 @@ +//! Layer 2 Profile-Driven OpenAPI 3.1.0 Generation (`generate-openapi`). +//! +//! Binds a UCP discovery profile's declared REST service OpenAPI 3.1 templates +//! (`ucp.services[][transport == "rest"].schema`) to Layer 1's compiled +//! capability and extension schemas (`compile_types`), pruning inactive routes, +//! replacing snake_case template wrapper aliases with canonical PascalCase +//! `#/components/schemas/` references, and pruning `components` to the +//! transitive reachability closure of the retained routes and webhooks. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::path::Path; + +use serde_json::{json, Map, Value}; + +use crate::codegen::normalizer::ref_to_def_name; +use crate::codegen::profile::{ + parse_profile_source, resolve_service_schema_target, RestServiceBinding, +}; +use crate::codegen::{compile_types, CodegenError, GenerateTypesOptions}; +use crate::error::ResolveError; +use crate::loader::{load_schema_auto, INSTANCE_DATA_KEYWORDS}; + +const OAS_3_1_DIALECT: &str = "https://spec.openapis.org/oas/3.1/dialect/base"; + +const HTTP_METHODS: &[&str] = &[ + "get", "put", "post", "delete", "options", "head", "patch", "trace", +]; + +/// Configuration options for [`generate_openapi`]. +#[derive(Debug, Clone)] +pub struct GenerateOpenApiOptions { + pub profile: String, + pub service: Option, + pub server_url: Option, +} + +impl GenerateOpenApiOptions { + /// Create [`GenerateOpenApiOptions`] for a profile file path or URL. + pub fn new(profile: impl Into) -> Self { + Self { + profile: profile.into(), + service: None, + server_url: None, + } + } + + /// Create [`GenerateOpenApiOptions`] scoped to a UCP discovery profile path or URL. + pub fn from_profile(profile: impl Into) -> Self { + Self::new(profile) + } + + /// Filter to a specific REST service FQDN (e.g. `"dev.ucp.shopping"`). + pub fn service(mut self, service: impl Into) -> Self { + let svc = service.into(); + self.service = (!svc.is_empty()).then_some(svc); + self + } + + /// Override the OpenAPI `servers` array with an explicit base URL. + pub fn server_url(mut self, server_url: impl Into) -> Self { + let url = server_url.into(); + self.server_url = (!url.is_empty()).then_some(url); + self + } +} + +#[derive(Debug, Clone)] +struct TemplateSchemaBinding { + capability_stem: Option, + target_def: String, + is_error_oneof: bool, +} + +#[derive(Default)] +struct MergedTemplates { + openapi_version: String, + info: Value, + fallback_servers: Option, + paths: Map, + webhooks: Map, + parameters: Map, + headers: Map, + template_schemas: Map, +} + +/// Rewrite all local `#/$defs/` references in a JSON Schema or OpenAPI AST +/// to `#/components/schemas/`. +pub fn rewrite_defs_to_component_schemas(value: &mut Value) { + match value { + Value::Object(obj) => { + if let Some(target) = obj + .get("$ref") + .and_then(Value::as_str) + .and_then(|r| r.strip_prefix("#/$defs/")) + .map(str::to_string) + { + obj.insert( + "$ref".to_string(), + Value::String(format!("#/components/schemas/{target}")), + ); + } + for (k, v) in obj.iter_mut() { + if INSTANCE_DATA_KEYWORDS.contains(&k.as_str()) { + continue; + } + rewrite_defs_to_component_schemas(v); + } + } + Value::Array(arr) => { + for item in arr { + rewrite_defs_to_component_schemas(item); + } + } + _ => {} + } +} + +/// Generate a self-contained OpenAPI 3.1.0 specification for the REST service bindings +/// and active capabilities declared in a UCP discovery profile. +pub fn generate_openapi(options: &GenerateOpenApiOptions) -> Result { + let parsed = parse_profile_source(&options.profile)?; + let selected_services = select_rest_services(&options.profile, &parsed.rest_services, options)?; + + let mut merged = load_and_merge_templates(&options.profile, &selected_services, &parsed)?; + let compiled = compile_types(&GenerateTypesOptions::new().profile(&options.profile))?; + let bindings = parse_template_bindings(&merged.template_schemas); + + prune_and_bind_route_map( + &mut merged.paths, + &bindings, + &compiled.active_capabilities, + &compiled.defs, + ); + prune_and_bind_route_map( + &mut merged.webhooks, + &bindings, + &compiled.active_capabilities, + &compiled.defs, + ); + + let mut components_schemas = BTreeMap::new(); + for (name, mut schema_val) in compiled.defs { + rewrite_defs_to_component_schemas(&mut schema_val); + components_schemas.insert(name, schema_val); + } + for param_val in merged.parameters.values_mut() { + rewrite_defs_to_component_schemas(param_val); + } + for header_val in merged.headers.values_mut() { + rewrite_defs_to_component_schemas(header_val); + } + + prune_unreachable_components( + &merged.paths, + &merged.webhooks, + &mut merged.parameters, + &mut merged.headers, + &mut components_schemas, + ); + + let servers = build_servers_array(&selected_services, options, merged.fallback_servers); + Ok(assemble_openapi_document( + merged.openapi_version, + merged.info, + servers, + merged.paths, + merged.webhooks, + merged.parameters, + merged.headers, + components_schemas, + )) +} + +fn select_rest_services<'a>( + profile: &str, + rest_services: &'a [RestServiceBinding], + options: &GenerateOpenApiOptions, +) -> Result, CodegenError> { + let selected: Vec<&RestServiceBinding> = rest_services + .iter() + .filter(|b| { + options + .service + .as_deref() + .is_none_or(|svc| b.service_name == svc) + }) + .collect(); + + if selected.is_empty() { + return Err(CodegenError::NoRestServiceBinding { + profile: profile.to_string(), + }); + } + + for binding in &selected { + if binding.schema_url.trim().is_empty() { + return Err(CodegenError::MissingServiceSchema { + profile: profile.to_string(), + service: binding.service_name.clone(), + }); + } + } + + Ok(selected) +} + +fn load_and_merge_templates( + profile_source: &str, + selected_services: &[&RestServiceBinding], + parsed: &crate::codegen::ParsedProfile, +) -> Result { + let mut merged = MergedTemplates { + openapi_version: "3.1.0".to_string(), + info: json!({ "title": "UCP REST Service", "version": "1.0.0" }), + ..MergedTemplates::default() + }; + + for (idx, binding) in selected_services.iter().enumerate() { + let target = resolve_service_schema_target( + profile_source, + &binding.schema_url, + &parsed.capabilities, + )?; + let doc = load_schema_auto(&target)?; + let Some(root_obj) = doc.as_object() else { + return Err(CodegenError::ResolveError(ResolveError::InvalidSchema { + message: format!("OpenAPI service template '{target}' must be a JSON object"), + })); + }; + + if idx == 0 { + if let Some(ver) = root_obj.get("openapi").and_then(Value::as_str) { + merged.openapi_version = ver.to_string(); + } + if let Some(info) = root_obj.get("info") { + merged.info = info.clone(); + } + merged.fallback_servers = root_obj.get("servers").cloned(); + } + + extend_object_map(&mut merged.paths, root_obj.get("paths")); + extend_object_map(&mut merged.webhooks, root_obj.get("webhooks")); + + let Some(components) = root_obj.get("components").and_then(Value::as_object) else { + continue; + }; + extend_object_map(&mut merged.parameters, components.get("parameters")); + extend_object_map(&mut merged.headers, components.get("headers")); + extend_object_map(&mut merged.template_schemas, components.get("schemas")); + } + + Ok(merged) +} + +fn extend_object_map(dst: &mut Map, src: Option<&Value>) { + let Some(obj) = src.and_then(Value::as_object) else { + return; + }; + for (k, v) in obj { + dst.entry(k.clone()).or_insert_with(|| v.clone()); + } +} + +fn parse_template_bindings( + template_schemas: &Map, +) -> BTreeMap { + let mut bindings = BTreeMap::new(); + for (key, val) in template_schemas { + let Some(binding) = parse_single_template_binding(val) else { + continue; + }; + bindings.insert(key.clone(), binding); + } + bindings +} + +fn parse_single_template_binding(val: &Value) -> Option { + let obj = val.as_object()?; + if let Some(ref_str) = obj.get("$ref").and_then(Value::as_str) { + return parse_ref_binding(ref_str, false); + } + + let one_of = obj.get("oneOf").and_then(Value::as_array)?; + let mut primary_ref = None; + let mut has_error_ref = false; + for branch in one_of { + let branch_ref = branch.get("$ref").and_then(Value::as_str)?; + if extract_file_stem(branch_ref).as_deref() == Some("error_response") { + has_error_ref = true; + } else { + primary_ref = Some(branch_ref); + } + } + + if !has_error_ref { + return None; + } + parse_ref_binding(primary_ref?, true) +} + +fn parse_ref_binding(ref_str: &str, is_error_oneof: bool) -> Option { + let stem = extract_file_stem(ref_str)?; + let has_def_fragment = ref_str.contains("#/$defs/") || ref_str.contains("#/definitions/"); + if stem == "ucp" && !has_def_fragment { + return Some(TemplateSchemaBinding { + capability_stem: None, + target_def: "UcpBase".to_string(), + is_error_oneof, + }); + } + if stem == "error_response" && !has_def_fragment { + return Some(TemplateSchemaBinding { + capability_stem: None, + target_def: "ErrorResponse".to_string(), + is_error_oneof, + }); + } + Some(TemplateSchemaBinding { + capability_stem: Some(stem), + target_def: ref_to_def_name(ref_str, None), + is_error_oneof, + }) +} + +fn extract_file_stem(ref_str: &str) -> Option { + let file_part = ref_str.split('#').next().unwrap_or(""); + if file_part.is_empty() { + return None; + } + Path::new(file_part) + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string) +} + +fn prune_and_bind_route_map( + route_map: &mut Map, + bindings: &BTreeMap, + active_capabilities: &BTreeSet, + compiled_defs: &BTreeMap, +) { + route_map.retain(|_, path_val| { + let Some(path_item) = path_val.as_object_mut() else { + return false; + }; + + path_item.retain(|key, op_val| { + if !HTTP_METHODS.contains(&key.as_str()) { + return true; + } + if !is_operation_active(op_val, bindings, active_capabilities, compiled_defs) { + return false; + } + bind_operation_schemas(op_val, bindings, compiled_defs); + true + }); + + HTTP_METHODS.iter().any(|&m| path_item.contains_key(m)) + }); +} + +fn is_operation_active( + op_val: &Value, + bindings: &BTreeMap, + active_capabilities: &BTreeSet, + compiled_defs: &BTreeMap, +) -> bool { + let mut referenced_keys = BTreeSet::new(); + collect_component_refs(op_val, "#/components/schemas/", &mut referenced_keys); + for key in referenced_keys { + let Some(binding) = bindings.get(&key) else { + if !compiled_defs.contains_key(&key) { + return false; + } + continue; + }; + if let Some(cap_stem) = &binding.capability_stem { + if !active_capabilities.contains(cap_stem) { + return false; + } + } + if !compiled_defs.contains_key(&binding.target_def) { + return false; + } + } + true +} + +fn bind_operation_schemas( + op_val: &mut Value, + bindings: &BTreeMap, + compiled_defs: &BTreeMap, +) { + let Some(op_obj) = op_val.as_object_mut() else { + return; + }; + let op_id = op_obj + .get("operationId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let req_suffix = directional_request_suffix(&op_id); + + for (key, child) in op_obj.iter_mut() { + let suffix = (key == "requestBody").then_some(req_suffix).flatten(); + let in_responses = key == "responses"; + rewrite_operation_subtree(child, bindings, compiled_defs, suffix, in_responses); + } +} + +fn directional_request_suffix(operation_id: &str) -> Option<&'static str> { + if operation_id.starts_with("create_") { + Some("CreateRequest") + } else if operation_id.starts_with("update_") { + Some("UpdateRequest") + } else if operation_id.starts_with("complete_") { + Some("CompleteRequest") + } else { + None + } +} + +fn rewrite_operation_subtree( + val: &mut Value, + bindings: &BTreeMap, + compiled_defs: &BTreeMap, + req_suffix: Option<&str>, + in_responses: bool, +) { + match val { + Value::Object(obj) => { + if let Some(schema_key) = obj + .get("$ref") + .and_then(Value::as_str) + .and_then(|r| r.strip_prefix("#/components/schemas/")) + .map(str::to_string) + { + apply_schema_binding_to_ref_obj( + obj, + &schema_key, + bindings, + compiled_defs, + req_suffix, + in_responses, + ); + return; + } + for child in obj.values_mut() { + rewrite_operation_subtree(child, bindings, compiled_defs, req_suffix, in_responses); + } + } + Value::Array(arr) => { + for item in arr { + rewrite_operation_subtree(item, bindings, compiled_defs, req_suffix, in_responses); + } + } + _ => {} + } +} + +fn apply_schema_binding_to_ref_obj( + obj: &mut Map, + schema_key: &str, + bindings: &BTreeMap, + compiled_defs: &BTreeMap, + req_suffix: Option<&str>, + in_responses: bool, +) { + let Some(binding) = bindings.get(schema_key) else { + return; + }; + + if in_responses && binding.is_error_oneof { + obj.remove("$ref"); + obj.insert( + "oneOf".to_string(), + json!([ + { "$ref": format!("#/components/schemas/{}", binding.target_def) }, + { "$ref": "#/components/schemas/ErrorResponse" } + ]), + ); + return; + } + + let target = resolve_directional_target(&binding.target_def, req_suffix, compiled_defs); + obj.insert( + "$ref".to_string(), + Value::String(format!("#/components/schemas/{target}")), + ); +} + +fn resolve_directional_target<'a>( + base_target: &'a str, + req_suffix: Option<&str>, + compiled_defs: &'a BTreeMap, +) -> &'a str { + let Some(suffix) = req_suffix else { + return base_target; + }; + let candidate = format!("{base_target}{suffix}"); + let Some((matched_key, _)) = compiled_defs.get_key_value(&candidate) else { + return base_target; + }; + matched_key.as_str() +} + +fn prune_unreachable_components( + paths: &Map, + webhooks: &Map, + parameters: &mut Map, + headers: &mut Map, + schemas: &mut BTreeMap, +) { + let mut reachable_params = BTreeSet::new(); + let mut reachable_headers = BTreeSet::new(); + let mut reachable_schemas = BTreeSet::new(); + + for val in paths.values().chain(webhooks.values()) { + collect_component_refs(val, "#/components/parameters/", &mut reachable_params); + collect_component_refs(val, "#/components/headers/", &mut reachable_headers); + collect_component_refs(val, "#/components/schemas/", &mut reachable_schemas); + } + + parameters.retain(|k, _| reachable_params.contains(k)); + for param_val in parameters.values() { + collect_component_refs(param_val, "#/components/headers/", &mut reachable_headers); + collect_component_refs(param_val, "#/components/schemas/", &mut reachable_schemas); + } + + headers.retain(|k, _| reachable_headers.contains(k)); + for header_val in headers.values() { + collect_component_refs(header_val, "#/components/schemas/", &mut reachable_schemas); + } + + let mut queue: VecDeque = reachable_schemas.iter().cloned().collect(); + while let Some(schema_name) = queue.pop_front() { + let Some(schema_val) = schemas.get(&schema_name) else { + continue; + }; + let mut child_refs = BTreeSet::new(); + collect_component_refs(schema_val, "#/components/schemas/", &mut child_refs); + for child in child_refs { + if reachable_schemas.insert(child.clone()) { + queue.push_back(child); + } + } + } + + schemas.retain(|k, _| reachable_schemas.contains(k)); +} + +fn collect_component_refs(val: &Value, prefix: &str, out: &mut BTreeSet) { + match val { + Value::Object(obj) => { + if let Some(target) = obj + .get("$ref") + .and_then(Value::as_str) + .and_then(|r| r.strip_prefix(prefix)) + { + out.insert(target.to_string()); + } + for (k, v) in obj { + if INSTANCE_DATA_KEYWORDS.contains(&k.as_str()) { + continue; + } + collect_component_refs(v, prefix, out); + } + } + Value::Array(arr) => { + for item in arr { + collect_component_refs(item, prefix, out); + } + } + _ => {} + } +} + +fn build_servers_array( + selected_services: &[&RestServiceBinding], + options: &GenerateOpenApiOptions, + fallback_servers: Option, +) -> Value { + if let Some(url) = options.server_url.as_deref().filter(|s| !s.is_empty()) { + return json!([{ "url": url }]); + } + + let mut seen = BTreeSet::new(); + let mut endpoints = Vec::new(); + for binding in selected_services { + let Some(ep) = binding.endpoint.as_deref().map(str::trim) else { + continue; + }; + if ep.is_empty() || !seen.insert(ep.to_string()) { + continue; + } + endpoints.push(json!({ "url": ep })); + } + + if !endpoints.is_empty() { + return Value::Array(endpoints); + } + fallback_servers.unwrap_or_else(|| json!([])) +} + +#[allow(clippy::too_many_arguments)] +fn assemble_openapi_document( + openapi_version: String, + info: Value, + servers: Value, + paths: Map, + webhooks: Map, + parameters: Map, + headers: Map, + schemas: BTreeMap, +) -> Value { + let mut root = Map::new(); + root.insert("openapi".to_string(), Value::String(openapi_version)); + root.insert( + "jsonSchemaDialect".to_string(), + Value::String(OAS_3_1_DIALECT.to_string()), + ); + root.insert("info".to_string(), info); + root.insert("servers".to_string(), servers); + root.insert("paths".to_string(), Value::Object(paths)); + if !webhooks.is_empty() { + root.insert("webhooks".to_string(), Value::Object(webhooks)); + } + + let mut components = Map::new(); + if !parameters.is_empty() { + components.insert("parameters".to_string(), Value::Object(parameters)); + } + if !headers.is_empty() { + components.insert("headers".to_string(), Value::Object(headers)); + } + let schemas_map: Map = schemas.into_iter().collect(); + components.insert("schemas".to_string(), Value::Object(schemas_map)); + root.insert("components".to_string(), Value::Object(components)); + + Value::Object(root) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_defs_to_component_schemas_rewrites_schema_refs_and_skips_instance_keywords() { + let mut doc = json!({ + "type": "object", + "properties": { + "item": { "$ref": "#/$defs/LineItem" }, + "literal_const": { + "type": "object", + "const": { "$ref": "#/$defs/DoNotTouch" }, + "default": { "$ref": "#/$defs/DoNotTouchEither" } + } + }, + "anyOf": [ + { "$ref": "#/$defs/ShippingMethod" }, + { "$ref": "#/$defs/FulfillmentMethodBase" } + ] + }); + + rewrite_defs_to_component_schemas(&mut doc); + + assert_eq!( + doc["properties"]["item"]["$ref"], + "#/components/schemas/LineItem" + ); + assert_eq!( + doc["anyOf"][0]["$ref"], + "#/components/schemas/ShippingMethod" + ); + assert_eq!( + doc["anyOf"][1]["$ref"], + "#/components/schemas/FulfillmentMethodBase" + ); + assert_eq!( + doc["properties"]["literal_const"]["const"]["$ref"], + "#/$defs/DoNotTouch" + ); + assert_eq!( + doc["properties"]["literal_const"]["default"]["$ref"], + "#/$defs/DoNotTouchEither" + ); + } + + #[test] + fn parse_single_template_binding_maps_direct_and_oneof_template_entries() { + let checkout = parse_single_template_binding(&json!({ + "$ref": "../../schemas/shopping/checkout.json" + })) + .unwrap(); + assert_eq!(checkout.capability_stem.as_deref(), Some("checkout")); + assert_eq!(checkout.target_def, "Checkout"); + assert!(!checkout.is_error_oneof); + + let checkout_resp = parse_single_template_binding(&json!({ + "oneOf": [ + { "$ref": "../../schemas/shopping/checkout.json" }, + { "$ref": "../../schemas/common/types/error_response.json" } + ] + })) + .unwrap(); + assert_eq!(checkout_resp.capability_stem.as_deref(), Some("checkout")); + assert_eq!(checkout_resp.target_def, "Checkout"); + assert!(checkout_resp.is_error_oneof); + + let get_product_resp = parse_single_template_binding(&json!({ + "oneOf": [ + { "$ref": "../../schemas/shopping/catalog_lookup.json#/$defs/get_product_response" }, + { "$ref": "../../schemas/common/types/error_response.json" } + ] + })) + .unwrap(); + assert_eq!( + get_product_resp.capability_stem.as_deref(), + Some("catalog_lookup") + ); + assert_eq!(get_product_resp.target_def, "CatalogGetProductResponse"); + assert!(get_product_resp.is_error_oneof); + + let ucp = parse_single_template_binding(&json!({ + "$ref": "../../schemas/ucp.json" + })) + .unwrap(); + assert!(ucp.capability_stem.is_none()); + assert_eq!(ucp.target_def, "UcpBase"); + assert!(!ucp.is_error_oneof); + } +} diff --git a/tests/generate_openapi_test.rs b/tests/generate_openapi_test.rs new file mode 100644 index 0000000..f89a9ea --- /dev/null +++ b/tests/generate_openapi_test.rs @@ -0,0 +1,781 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; +use ucp_schema::{generate_openapi, CodegenError, GenerateOpenApiOptions}; + +fn ucp_source_dir() -> Option { + let candidate = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ucp/source"); + candidate.exists().then_some(candidate) +} + +fn visit_json_objects(val: &Value, f: &mut impl FnMut(&serde_json::Map)) { + match val { + Value::Object(obj) => { + f(obj); + for (k, v) in obj { + if !["const", "enum", "default", "examples"].contains(&k.as_str()) { + visit_json_objects(v, f); + } + } + } + Value::Array(arr) => arr.iter().for_each(|v| visit_json_objects(v, f)), + _ => {} + } +} + +fn assert_openapi_invariants(spec: &Value) { + assert_eq!(spec["openapi"], "3.1.0"); + assert_eq!( + spec["jsonSchemaDialect"], + "https://spec.openapis.org/oas/3.1/dialect/base" + ); + assert!(spec["paths"].is_object(), "paths must be an object"); + + let components = spec["components"] + .as_object() + .expect("components must be an object"); + let schemas = components["schemas"] + .as_object() + .expect("components.schemas must be an object"); + let known_schemas: BTreeSet<&str> = schemas.keys().map(String::as_str).collect(); + let known_params: BTreeSet<&str> = components + .get("parameters") + .and_then(Value::as_object) + .map(|m| m.keys().map(String::as_str).collect()) + .unwrap_or_default(); + let known_headers: BTreeSet<&str> = components + .get("headers") + .and_then(Value::as_object) + .map(|m| m.keys().map(String::as_str).collect()) + .unwrap_or_default(); + + for schema_name in &known_schemas { + assert!( + schema_name.starts_with(|c: char| c.is_ascii_uppercase()) && !schema_name.contains('_'), + "components.schemas key '{schema_name}' must be canonical PascalCase (no snake_case wrappers)" + ); + } + + visit_json_objects(spec, &mut |obj| { + assert!( + !obj.contains_key("discriminator"), + "OpenAPI spec must not contain 'discriminator' keyword: {obj:?}" + ); + assert!( + !obj.keys().any(|k| k == "ucp_request" + || k == "ucp_response" + || k == "x-ucp-schema-transition"), + "OpenAPI spec still contains UCP authoring annotation: {obj:?}" + ); + + let Some(r) = obj.get("$ref").and_then(Value::as_str) else { + return; + }; + assert!( + !r.starts_with("#/$defs/"), + "OpenAPI spec contains unrewritten #/$defs/ reference: '{r}'" + ); + if let Some(target) = r.strip_prefix("#/components/schemas/") { + assert!( + known_schemas.contains(target), + "dangling schema $ref '#/components/schemas/{target}'" + ); + return; + } + if let Some(target) = r.strip_prefix("#/components/parameters/") { + assert!( + known_params.contains(target), + "dangling parameter $ref '#/components/parameters/{target}'" + ); + return; + } + if let Some(target) = r.strip_prefix("#/components/headers/") { + assert!( + known_headers.contains(target), + "dangling header $ref '#/components/headers/{target}'" + ); + return; + } + panic!("unexpected non-component $ref in OpenAPI spec: '{r}'"); + }); +} + +#[test] +fn shopping_checkout_discount_fulfillment_prunes_routes_and_binds_directional_schemas() { + let Some(source_dir) = ucp_source_dir() else { + return; + }; + let schema_dir = source_dir.join("schemas"); + let s = |rel: &str| schema_dir.join(rel).to_string_lossy().into_owned(); + + let tmp = tempfile::tempdir().unwrap(); + let profile_path = tmp.path().join("shopping_checkout_profile.json"); + let profile = json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [ + { + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://merchant.example.com/ucp", + "schema": "https://ucp.dev/2026-08-25/services/shopping/rest.openapi.json" + } + ] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [ + { "version": "2026-08-25", "schema": s("shopping/checkout.json") } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-08-25", + "schema": s("shopping/discount.json"), + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-08-25", + "schema": s("shopping/fulfillment.json"), + "extends": "dev.ucp.shopping.checkout" + } + ] + } + } + }); + std::fs::write(&profile_path, profile.to_string()).unwrap(); + + let opts = GenerateOpenApiOptions::new(profile_path.to_str().unwrap()); + let spec = generate_openapi(&opts).unwrap(); + assert_openapi_invariants(&spec); + + assert_eq!( + spec["servers"], + json!([{ "url": "https://merchant.example.com/ucp" }]) + ); + + let paths = spec["paths"].as_object().unwrap(); + let retained_paths: Vec<&str> = paths.keys().map(String::as_str).collect(); + assert_eq!( + retained_paths, + vec![ + "/checkout-sessions", + "/checkout-sessions/{id}", + "/checkout-sessions/{id}/complete", + "/checkout-sessions/{id}/cancel", + ] + ); + assert!( + spec.get("webhooks").is_none(), + "webhooks must be pruned when order capability is inactive" + ); + + // Verify directional request bindings + assert_eq!( + paths["/checkout-sessions"]["post"]["requestBody"]["content"]["application/json"]["schema"] + ["$ref"], + "#/components/schemas/CheckoutCreateRequest" + ); + assert_eq!( + paths["/checkout-sessions/{id}"]["put"]["requestBody"]["content"]["application/json"] + ["schema"]["$ref"], + "#/components/schemas/CheckoutUpdateRequest" + ); + assert_eq!( + paths["/checkout-sessions/{id}/complete"]["post"]["requestBody"]["content"] + ["application/json"]["schema"]["$ref"], + "#/components/schemas/CheckoutCompleteRequest" + ); + + // Verify inlined oneOf response bindings + let expected_checkout_oneof = json!({ + "oneOf": [ + { "$ref": "#/components/schemas/Checkout" }, + { "$ref": "#/components/schemas/ErrorResponse" } + ] + }); + assert_eq!( + paths["/checkout-sessions"]["post"]["responses"]["201"]["content"]["application/json"] + ["schema"], + expected_checkout_oneof + ); + assert_eq!( + paths["/checkout-sessions/{id}"]["get"]["responses"]["200"]["content"]["application/json"] + ["schema"], + expected_checkout_oneof + ); + assert_eq!( + paths["/checkout-sessions/{id}/cancel"]["post"]["responses"]["200"]["content"] + ["application/json"]["schema"], + expected_checkout_oneof + ); + + let schemas = spec["components"]["schemas"].as_object().unwrap(); + for expected in [ + "Checkout", + "CheckoutCreateRequest", + "CheckoutUpdateRequest", + "CheckoutCompleteRequest", + "DiscountsObject", + "DiscountsObjectCreateRequest", + "DiscountsObjectUpdateRequest", + "Fulfillment", + "FulfillmentCreateRequest", + "FulfillmentUpdateRequest", + "FulfillmentMethod", + "FulfillmentMethodBase", + "ShippingMethod", + "PickupMethod", + "FulfillmentDestination", + "FulfillmentDestinationBase", + "ShippingDestination", + "LocationDestination", + "ErrorResponse", + "ResponseCheckoutSchema", + ] { + assert!( + schemas.contains_key(expected), + "expected '{expected}' in components.schemas" + ); + } + + for pruned in [ + "checkout", + "checkout_response", + "cart", + "cart_response", + "order", + "order_response", + "Cart", + "Order", + "CatalogSearchRequest", + "DiscoveryProfile", + "CheckoutPlatformSchema", + "CheckoutBusinessSchema", + "UcpPlatformSchema", + "UcpBusinessSchema", + ] { + assert!( + !schemas.contains_key(pruned), + "expected '{pruned}' to be pruned from components.schemas" + ); + } + + let params = spec["components"]["parameters"].as_object().unwrap(); + assert!(params.contains_key("checkout_session_id_path")); + for pruned_param in [ + "cart_id_path", + "order_id_path", + "webhook_timestamp", + "webhook_id", + "QueryParam", + "LimitParam", + ] { + assert!( + !params.contains_key(pruned_param), + "expected unused parameter '{pruned_param}' to be pruned" + ); + } +} + +#[test] +fn shopping_order_retains_orders_route_and_order_event_webhook() { + let Some(source_dir) = ucp_source_dir() else { + return; + }; + let schema_dir = source_dir.join("schemas"); + let s = |rel: &str| schema_dir.join(rel).to_string_lossy().into_owned(); + + let tmp = tempfile::tempdir().unwrap(); + let profile_path = tmp.path().join("shopping_order_profile.json"); + let profile = json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://orders.example.com/ucp", + "schema": source_dir.join("services/shopping/rest.openapi.json").to_str().unwrap() + }] + }, + "capabilities": { + "dev.ucp.shopping.order": [ + { "version": "2026-08-25", "schema": s("shopping/order.json") } + ] + } + } + }); + std::fs::write(&profile_path, profile.to_string()).unwrap(); + + let spec = + generate_openapi(&GenerateOpenApiOptions::new(profile_path.to_str().unwrap())).unwrap(); + assert_openapi_invariants(&spec); + + let paths = spec["paths"].as_object().unwrap(); + let retained_paths: Vec<&str> = paths.keys().map(String::as_str).collect(); + assert_eq!(retained_paths, vec!["/orders/{id}"]); + assert_eq!( + paths["/orders/{id}"]["get"]["responses"]["200"]["content"]["application/json"]["schema"], + json!({ + "oneOf": [ + { "$ref": "#/components/schemas/Order" }, + { "$ref": "#/components/schemas/ErrorResponse" } + ] + }) + ); + + let webhooks = spec["webhooks"] + .as_object() + .expect("webhooks must be retained when order capability is active"); + assert!(webhooks.contains_key("orderEvent")); + let webhook_op = &webhooks["orderEvent"]["post"]; + assert_eq!( + webhook_op["requestBody"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/Order" + ); + assert_eq!( + webhook_op["responses"]["200"]["content"]["application/json"]["schema"]["properties"] + ["ucp"]["$ref"], + "#/components/schemas/UcpBase" + ); + + let schemas = spec["components"]["schemas"].as_object().unwrap(); + assert!(schemas.contains_key("Order")); + assert!(schemas.contains_key("UcpBase")); + assert!(schemas.contains_key("ErrorResponse")); + assert!(!schemas.contains_key("Checkout")); + assert!(!schemas.contains_key("Cart")); + + let params = spec["components"]["parameters"].as_object().unwrap(); + assert!(params.contains_key("order_id_path")); + assert!(params.contains_key("webhook_timestamp")); + assert!(params.contains_key("webhook_id")); + assert!(!params.contains_key("checkout_session_id_path")); + assert!(!params.contains_key("cart_id_path")); +} + +#[test] +fn container_capabilities_and_lodging_booking_bind_canonical_schemas() { + let Some(source_dir) = ucp_source_dir() else { + return; + }; + let schema_dir = source_dir.join("schemas"); + let s = |rel: &str| schema_dir.join(rel).to_string_lossy().into_owned(); + + let tmp = tempfile::tempdir().unwrap(); + let profile_path = tmp.path().join("containers_and_lodging_profile.json"); + let profile = json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://commerce.example.com/shopping", + "schema": "https://ucp.dev/2026-08-25/services/shopping/rest.openapi.json" + }], + "dev.ucp.common": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://commerce.example.com/common", + "schema": "https://ucp.dev/2026-08-25/services/common/rest.openapi.json" + }], + "dev.ucp.lodging": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://commerce.example.com/lodging", + "schema": "https://ucp.dev/2026-08-25/services/lodging/rest.openapi.json" + }] + }, + "capabilities": { + "dev.ucp.shopping.catalog.search": [ + { "version": "2026-08-25", "schema": s("shopping/catalog_search.json") } + ], + "dev.ucp.shopping.catalog.lookup": [ + { "version": "2026-08-25", "schema": s("shopping/catalog_lookup.json") } + ], + "dev.ucp.common.location.search": [ + { "version": "2026-08-25", "schema": s("common/location_search.json") } + ], + "dev.ucp.common.location.lookup": [ + { "version": "2026-08-25", "schema": s("common/location_lookup.json") } + ], + "dev.ucp.common.ask": [ + { "version": "2026-08-25", "schema": s("common/ask.json") } + ], + "dev.ucp.lodging.booking": [ + { "version": "2026-08-25", "schema": s("lodging/booking.json") } + ] + } + } + }); + std::fs::write(&profile_path, profile.to_string()).unwrap(); + + // 1. Default multi-service merge includes shopping catalog, common location/ask, and lodging booking + let merged_spec = + generate_openapi(&GenerateOpenApiOptions::new(profile_path.to_str().unwrap())).unwrap(); + assert_openapi_invariants(&merged_spec); + + assert_eq!( + merged_spec["servers"], + json!([ + { "url": "https://commerce.example.com/shopping" }, + { "url": "https://commerce.example.com/common" }, + { "url": "https://commerce.example.com/lodging" } + ]) + ); + + let paths = merged_spec["paths"].as_object().unwrap(); + assert_eq!( + paths["/catalog/search"]["post"]["requestBody"]["content"]["application/json"]["schema"] + ["$ref"], + "#/components/schemas/CatalogSearchRequest" + ); + assert_eq!( + paths["/catalog/search"]["post"]["responses"]["200"]["content"]["application/json"] + ["schema"]["$ref"], + "#/components/schemas/CatalogSearchResponse" + ); + assert_eq!( + paths["/catalog/lookup"]["post"]["requestBody"]["content"]["application/json"]["schema"] + ["$ref"], + "#/components/schemas/CatalogLookupRequest" + ); + assert_eq!( + paths["/catalog/lookup"]["post"]["responses"]["200"]["content"]["application/json"] + ["schema"]["$ref"], + "#/components/schemas/CatalogLookupResponse" + ); + assert_eq!( + paths["/catalog/product"]["post"]["requestBody"]["content"]["application/json"]["schema"] + ["$ref"], + "#/components/schemas/CatalogGetProductRequest" + ); + assert_eq!( + paths["/catalog/product"]["post"]["responses"]["200"]["content"]["application/json"] + ["schema"], + json!({ + "oneOf": [ + { "$ref": "#/components/schemas/CatalogGetProductResponse" }, + { "$ref": "#/components/schemas/ErrorResponse" } + ] + }) + ); + + assert_eq!( + paths["/locations/search"]["post"]["requestBody"]["content"]["application/json"]["schema"] + ["$ref"], + "#/components/schemas/LocationSearchRequest" + ); + assert_eq!( + paths["/locations/search"]["post"]["responses"]["200"]["content"]["application/json"] + ["schema"]["$ref"], + "#/components/schemas/LocationSearchResponse" + ); + assert_eq!( + paths["/locations/lookup"]["post"]["requestBody"]["content"]["application/json"]["schema"] + ["$ref"], + "#/components/schemas/LocationLookupRequest" + ); + assert_eq!( + paths["/locations/lookup"]["post"]["responses"]["200"]["content"]["application/json"] + ["schema"]["$ref"], + "#/components/schemas/LocationLookupResponse" + ); + assert_eq!( + paths["/ask"]["post"]["requestBody"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/AskRequest" + ); + assert_eq!( + paths["/ask"]["post"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/AskResponse" + ); + + // Lodging booking directional request and response bindings + assert_eq!( + paths["/booking-sessions"]["post"]["requestBody"]["content"]["application/json"]["schema"] + ["$ref"], + "#/components/schemas/BookingCreateRequest" + ); + assert_eq!( + paths["/booking-sessions/{id}"]["put"]["requestBody"]["content"]["application/json"] + ["schema"]["$ref"], + "#/components/schemas/BookingUpdateRequest" + ); + assert_eq!( + paths["/booking-sessions/{id}/complete"]["post"]["requestBody"]["content"] + ["application/json"]["schema"]["$ref"], + "#/components/schemas/BookingCompleteRequest" + ); + assert_eq!( + paths["/booking-sessions"]["post"]["responses"]["201"]["content"]["application/json"] + ["schema"], + json!({ + "oneOf": [ + { "$ref": "#/components/schemas/Booking" }, + { "$ref": "#/components/schemas/ErrorResponse" } + ] + }) + ); + + // 2. Filtering via --service dev.ucp.common scopes strictly to common service routes & schemas + let common_spec = generate_openapi( + &GenerateOpenApiOptions::new(profile_path.to_str().unwrap()) + .service("dev.ucp.common") + .server_url("https://override.example.com/common"), + ) + .unwrap(); + assert_openapi_invariants(&common_spec); + assert_eq!( + common_spec["servers"], + json!([{ "url": "https://override.example.com/common" }]) + ); + let common_paths: Vec<&str> = common_spec["paths"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + common_paths, + vec!["/locations/search", "/locations/lookup", "/ask"] + ); + let common_schemas = common_spec["components"]["schemas"].as_object().unwrap(); + assert!(common_schemas.contains_key("AskRequest")); + assert!(common_schemas.contains_key("LocationSearchRequest")); + assert!(!common_schemas.contains_key("CatalogSearchRequest")); + assert!(!common_schemas.contains_key("Booking")); +} + +#[test] +fn error_handling_and_cli_exit_codes_for_missing_rest_binding_and_schema() { + let Some(source_dir) = ucp_source_dir() else { + return; + }; + let schema_dir = source_dir.join("schemas"); + let s = |rel: &str| schema_dir.join(rel).to_string_lossy().into_owned(); + + let tmp = tempfile::tempdir().unwrap(); + + // 1. MCP-only profile (like Allbirds) -> NoRestServiceBinding (exit code 2) + let mcp_profile_path = tmp.path().join("mcp_only_profile.json"); + let mcp_profile = json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [{ + "version": "2026-08-25", + "transport": "mcp", + "endpoint": "https://weareallbirds.myshopify.com/api/ucp/mcp", + "schema": "https://ucp.dev/2026-08-25/services/shopping/mcp.openrpc.json" + }] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [ + { "version": "2026-08-25", "schema": s("shopping/checkout.json") } + ] + } + } + }); + std::fs::write(&mcp_profile_path, mcp_profile.to_string()).unwrap(); + + let err = generate_openapi(&GenerateOpenApiOptions::new( + mcp_profile_path.to_str().unwrap(), + )) + .expect_err("MCP-only profile must return NoRestServiceBinding"); + assert!(matches!(err, CodegenError::NoRestServiceBinding { .. })); + assert_eq!(err.exit_code(), 2); + + // 2. REST binding missing "schema" URL -> MissingServiceSchema (exit code 2) + let missing_schema_path = tmp.path().join("missing_service_schema_profile.json"); + let missing_schema_profile = json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://merchant.example.com/ucp" + }] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [ + { "version": "2026-08-25", "schema": s("shopping/checkout.json") } + ] + } + } + }); + std::fs::write(&missing_schema_path, missing_schema_profile.to_string()).unwrap(); + + let err = generate_openapi(&GenerateOpenApiOptions::new( + missing_schema_path.to_str().unwrap(), + )) + .expect_err("REST service missing schema must return MissingServiceSchema"); + assert!(matches!(err, CodegenError::MissingServiceSchema { .. })); + assert_eq!(err.exit_code(), 2); + + // 3. --service filter matching no REST service -> NoRestServiceBinding + let valid_profile_path = tmp.path().join("valid_rest_profile.json"); + let valid_profile = json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://merchant.example.com/ucp", + "schema": "https://ucp.dev/2026-08-25/services/shopping/rest.openapi.json" + }] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [ + { "version": "2026-08-25", "schema": s("shopping/checkout.json") } + ] + } + } + }); + std::fs::write(&valid_profile_path, valid_profile.to_string()).unwrap(); + + let err = generate_openapi( + &GenerateOpenApiOptions::new(valid_profile_path.to_str().unwrap()) + .service("dev.ucp.lodging"), + ) + .expect_err("non-matching --service must return NoRestServiceBinding"); + assert!(matches!(err, CodegenError::NoRestServiceBinding { .. })); + assert_eq!(err.exit_code(), 2); + + // 4. Verify CLI execution (both success with --server-url / --output and failure exit code 2) + let bin = env!("CARGO_BIN_EXE_ucp-schema"); + let out_file = tmp.path().join("emitted.openapi.json"); + let ok_out = std::process::Command::new(bin) + .args([ + "generate-openapi", + "--profile", + valid_profile_path.to_str().unwrap(), + "--server-url", + "https://cli-override.example.com/ucp", + "--output", + out_file.to_str().unwrap(), + ]) + .output() + .expect("run ucp-schema generate-openapi"); + assert!( + ok_out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&ok_out.stderr) + ); + let emitted: Value = + serde_json::from_str(&std::fs::read_to_string(&out_file).unwrap()).unwrap(); + assert_openapi_invariants(&emitted); + assert_eq!( + emitted["servers"], + json!([{ "url": "https://cli-override.example.com/ucp" }]) + ); + + let fail_out = std::process::Command::new(bin) + .args([ + "generate-openapi", + "--profile", + mcp_profile_path.to_str().unwrap(), + ]) + .output() + .expect("run ucp-schema generate-openapi on MCP-only profile"); + assert_eq!(fail_out.status.code(), Some(2)); + + // 5. Verify --pretty=false compact output on stdout + let compact_out = std::process::Command::new(bin) + .args([ + "generate-openapi", + "-p", + valid_profile_path.to_str().unwrap(), + "--pretty=false", + ]) + .output() + .expect("run ucp-schema generate-openapi --pretty=false"); + assert!(compact_out.status.success()); + let compact_str = String::from_utf8(compact_out.stdout).unwrap(); + assert_eq!(compact_str.lines().count(), 1); + let compact_val: Value = serde_json::from_str(&compact_str).unwrap(); + assert_openapi_invariants(&compact_val); +} + +#[test] +fn shopping_cart_retains_cart_routes_and_binds_directional_cart_schemas() { + let Some(source_dir) = ucp_source_dir() else { + return; + }; + let schema_dir = source_dir.join("schemas"); + let s = |rel: &str| schema_dir.join(rel).to_string_lossy().into_owned(); + + let tmp = tempfile::tempdir().unwrap(); + let profile_path = tmp.path().join("shopping_cart_profile.json"); + let profile = json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://carts.example.com/ucp", + "schema": "https://ucp.dev/2026-08-25/services/shopping/rest.openapi.json" + }] + }, + "capabilities": { + "dev.ucp.shopping.cart": [ + { "version": "2026-08-25", "schema": s("shopping/cart.json") } + ] + } + } + }); + std::fs::write(&profile_path, profile.to_string()).unwrap(); + + let spec = generate_openapi(&GenerateOpenApiOptions::from_profile( + profile_path.to_str().unwrap(), + )) + .unwrap(); + assert_openapi_invariants(&spec); + + let paths = spec["paths"].as_object().unwrap(); + let retained_paths: Vec<&str> = paths.keys().map(String::as_str).collect(); + assert_eq!( + retained_paths, + vec!["/carts", "/carts/{id}", "/carts/{id}/cancel"] + ); + assert!(spec.get("webhooks").is_none()); + + assert_eq!( + paths["/carts"]["post"]["requestBody"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/CartCreateRequest" + ); + assert_eq!( + paths["/carts/{id}"]["put"]["requestBody"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/CartUpdateRequest" + ); + let expected_cart_oneof = json!({ + "oneOf": [ + { "$ref": "#/components/schemas/Cart" }, + { "$ref": "#/components/schemas/ErrorResponse" } + ] + }); + assert_eq!( + paths["/carts"]["post"]["responses"]["201"]["content"]["application/json"]["schema"], + expected_cart_oneof + ); + assert_eq!( + paths["/carts/{id}"]["get"]["responses"]["200"]["content"]["application/json"]["schema"], + expected_cart_oneof + ); + assert_eq!( + paths["/carts/{id}/cancel"]["post"]["responses"]["200"]["content"]["application/json"] + ["schema"], + expected_cart_oneof + ); + + let params = spec["components"]["parameters"].as_object().unwrap(); + assert!(params.contains_key("cart_id_path")); + assert!(!params.contains_key("checkout_session_id_path")); + assert!(!params.contains_key("order_id_path")); +} From e22784403610dbf17c1fff0e344acc055153a7bf Mon Sep 17 00:00:00 2001 From: Greg Smith Date: Wed, 21 Oct 2026 03:48:21 +0000 Subject: [PATCH 2/4] fix(openapi): preserve info.version when merging REST service templates --- src/openapi/mod.rs | 15 ++++++++++++--- tests/generate_openapi_test.rs | 13 +++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/openapi/mod.rs b/src/openapi/mod.rs index a66f08b..551b624 100644 --- a/src/openapi/mod.rs +++ b/src/openapi/mod.rs @@ -209,9 +209,14 @@ fn load_and_merge_templates( selected_services: &[&RestServiceBinding], parsed: &crate::codegen::ParsedProfile, ) -> Result { + let default_version = selected_services + .first() + .map(|b| b.version.trim()) + .filter(|v| !v.is_empty()) + .unwrap_or("1.0.0"); let mut merged = MergedTemplates { openapi_version: "3.1.0".to_string(), - info: json!({ "title": "UCP REST Service", "version": "1.0.0" }), + info: json!({ "title": "UCP REST Service", "version": default_version }), ..MergedTemplates::default() }; @@ -232,8 +237,12 @@ fn load_and_merge_templates( if let Some(ver) = root_obj.get("openapi").and_then(Value::as_str) { merged.openapi_version = ver.to_string(); } - if let Some(info) = root_obj.get("info") { - merged.info = info.clone(); + if let Some(info_obj) = root_obj.get("info").and_then(Value::as_object) { + if let Some(merged_info) = merged.info.as_object_mut() { + for (k, v) in info_obj { + merged_info.insert(k.clone(), v.clone()); + } + } } merged.fallback_servers = root_obj.get("servers").cloned(); } diff --git a/tests/generate_openapi_test.rs b/tests/generate_openapi_test.rs index f89a9ea..114035a 100644 --- a/tests/generate_openapi_test.rs +++ b/tests/generate_openapi_test.rs @@ -26,6 +26,18 @@ fn visit_json_objects(val: &Value, f: &mut impl FnMut(&serde_json::Map Date: Fri, 23 Oct 2026 03:29:14 +0000 Subject: [PATCH 3/4] test(openapi): add CI fixtures, OAS 3.1 structural invariants, and trigger PR CI --- .github/workflows/ci.yml | 1 - .../compose/shopping_rest.openapi.json | 137 +++++++++++ tests/generate_openapi_test.rs | 224 +++++++++++++++++- 3 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/compose/shopping_rest.openapi.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dda9c67..e1796b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main, master] pull_request: - branches: [main, master] # Every job here only reads the repository. Declaring this once at the workflow # level covers all of them, because a job without its own block inherits the diff --git a/tests/fixtures/compose/shopping_rest.openapi.json b/tests/fixtures/compose/shopping_rest.openapi.json new file mode 100644 index 0000000..51ea1a4 --- /dev/null +++ b/tests/fixtures/compose/shopping_rest.openapi.json @@ -0,0 +1,137 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "UCP Shopping Service", + "description": "Canonical REST interface for UCP Shopping service." + }, + "servers": [ + { + "url": "https://merchant.example.com/ucp" + } + ], + "paths": { + "/checkout-sessions": { + "post": { + "operationId": "create_checkout", + "summary": "Create Checkout", + "description": "Create a new checkout session.", + "parameters": [ + { + "$ref": "#/components/parameters/idempotency_key" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/checkout" + } + } + } + }, + "responses": { + "201": { + "description": "Checkout session created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/checkout" + } + } + } + } + } + } + }, + "/checkout-sessions/{id}": { + "put": { + "operationId": "update_checkout", + "summary": "Update Checkout", + "description": "Update an existing checkout session.", + "parameters": [ + { + "$ref": "#/components/parameters/session_id" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/checkout" + } + } + } + }, + "responses": { + "200": { + "description": "Checkout session updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/checkout" + } + } + } + } + } + } + }, + "/carts": { + "post": { + "operationId": "create_cart", + "summary": "Create Cart", + "description": "Create a cart.", + "responses": { + "201": { + "description": "Cart created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cart" + } + } + } + } + } + } + } + }, + "components": { + "parameters": { + "idempotency_key": { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + }, + "session_id": { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + "unused_query": { + "name": "filter", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + }, + "schemas": { + "checkout": { + "$ref": "schemas/shopping/checkout.json" + }, + "cart": { + "$ref": "schemas/shopping/cart.json" + } + } + } +} diff --git a/tests/generate_openapi_test.rs b/tests/generate_openapi_test.rs index 114035a..99b1205 100644 --- a/tests/generate_openapi_test.rs +++ b/tests/generate_openapi_test.rs @@ -4,11 +4,23 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; use ucp_schema::{generate_openapi, CodegenError, GenerateOpenApiOptions}; +fn fixture_compose_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compose") +} + fn ucp_source_dir() -> Option { let candidate = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ucp/source"); candidate.exists().then_some(candidate) } +fn require_ucp_source_dir(test_name: &str) -> Option { + let dir = ucp_source_dir(); + if dir.is_none() { + eprintln!("skipping {test_name}: sibling ../ucp/source checkout not found"); + } + dir +} + fn visit_json_objects(val: &Value, f: &mut impl FnMut(&serde_json::Map)) { match val { Value::Object(obj) => { @@ -24,8 +36,85 @@ fn visit_json_objects(val: &Value, f: &mut impl FnMut(&serde_json::Map = components .get("headers") .and_then(Value::as_object) @@ -113,9 +243,83 @@ fn assert_openapi_invariants(spec: &Value) { }); } +#[test] +fn generate_openapi_fixture_closure_and_template_merge() { + let compose_dir = fixture_compose_dir(); + let schema_dir = compose_dir.join("schemas"); + let template_path = compose_dir.join("shopping_rest.openapi.json"); + let s = |rel: &str| schema_dir.join(rel).to_string_lossy().into_owned(); + + let tmp = tempfile::tempdir().unwrap(); + let profile_path = tmp.path().join("profile.json"); + let profile = json!({ + "ucp": { + "version": "2026-01-11", + "services": { + "dev.ucp.shopping": [{ + "version": "2026-01-11", + "transport": "rest", + "endpoint": "https://merchant.example.com/ucp", + "schema": template_path.to_str().unwrap() + }] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [{ + "version": "2026-01-11", + "schema": s("shopping/checkout.json") + }], + "dev.ucp.shopping.discount": [{ + "version": "2026-01-11", + "schema": s("shopping/discount.json"), + "extends": "dev.ucp.shopping.checkout" + }] + } + } + }); + std::fs::write(&profile_path, profile.to_string()).unwrap(); + + let opts = GenerateOpenApiOptions::new(profile_path.to_str().unwrap()); + let spec = generate_openapi(&opts).unwrap(); + + // 1. Enforce OAS 3.1 structural invariants unconditionally in CI + assert_openapi_invariants(&spec); + + // 2. Info version preservation from profile binding when template omits info.version + assert_eq!(spec["info"]["title"], "UCP Shopping Service"); + assert_eq!(spec["info"]["version"], "2026-01-11"); + + // 3. Route pruning: /checkout-sessions retained, /carts pruned (inactive capability) + let paths = spec["paths"].as_object().unwrap(); + let retained_paths: Vec<&str> = paths.keys().map(String::as_str).collect(); + assert_eq!( + retained_paths, + vec!["/checkout-sessions", "/checkout-sessions/{id}"] + ); + + // 4. Directional schema bindings: CreateRequest and UpdateRequest + assert_eq!( + paths["/checkout-sessions"]["post"]["requestBody"]["content"]["application/json"]["schema"] + ["$ref"], + "#/components/schemas/CheckoutCreateRequest" + ); + assert_eq!( + paths["/checkout-sessions/{id}"]["put"]["requestBody"]["content"]["application/json"] + ["schema"]["$ref"], + "#/components/schemas/CheckoutUpdateRequest" + ); + + // 5. Component parameter pruning: idempotency_key & session_id retained, unused_query pruned + let params = spec["components"]["parameters"].as_object().unwrap(); + assert!(params.contains_key("idempotency_key")); + assert!(params.contains_key("session_id")); + assert!(!params.contains_key("unused_query")); +} + #[test] fn shopping_checkout_discount_fulfillment_prunes_routes_and_binds_directional_schemas() { - let Some(source_dir) = ucp_source_dir() else { + let Some(source_dir) = require_ucp_source_dir( + "shopping_checkout_discount_fulfillment_prunes_routes_and_binds_directional_schemas", + ) else { return; }; let schema_dir = source_dir.join("schemas"); @@ -295,7 +499,9 @@ fn shopping_checkout_discount_fulfillment_prunes_routes_and_binds_directional_sc #[test] fn shopping_order_retains_orders_route_and_order_event_webhook() { - let Some(source_dir) = ucp_source_dir() else { + let Some(source_dir) = + require_ucp_source_dir("shopping_order_retains_orders_route_and_order_event_webhook") + else { return; }; let schema_dir = source_dir.join("schemas"); @@ -372,7 +578,9 @@ fn shopping_order_retains_orders_route_and_order_event_webhook() { #[test] fn container_capabilities_and_lodging_booking_bind_canonical_schemas() { - let Some(source_dir) = ucp_source_dir() else { + let Some(source_dir) = + require_ucp_source_dir("container_capabilities_and_lodging_booking_bind_canonical_schemas") + else { return; }; let schema_dir = source_dir.join("schemas"); @@ -565,7 +773,9 @@ fn container_capabilities_and_lodging_booking_bind_canonical_schemas() { #[test] fn error_handling_and_cli_exit_codes_for_missing_rest_binding_and_schema() { - let Some(source_dir) = ucp_source_dir() else { + let Some(source_dir) = require_ucp_source_dir( + "error_handling_and_cli_exit_codes_for_missing_rest_binding_and_schema", + ) else { return; }; let schema_dir = source_dir.join("schemas"); @@ -717,7 +927,9 @@ fn error_handling_and_cli_exit_codes_for_missing_rest_binding_and_schema() { #[test] fn shopping_cart_retains_cart_routes_and_binds_directional_cart_schemas() { - let Some(source_dir) = ucp_source_dir() else { + let Some(source_dir) = require_ucp_source_dir( + "shopping_cart_retains_cart_routes_and_binds_directional_cart_schemas", + ) else { return; }; let schema_dir = source_dir.join("schemas"); From 011f26c29b1bc3df200f1abe17beaa3a584929a7 Mon Sep 17 00:00:00 2001 From: Greg Smith Date: Mon, 26 Oct 2026 16:35:55 +0000 Subject: [PATCH 4/4] fix(openapi): bind templates per service and emit OAS 3.1 Path Item servers - Bind and prune each selected REST service template independently before merging so inactive services do not leak endpoints, info, or parameters and cross-service path collisions fail with ConflictingServicePath (exit code 2) - Emit OAS 3.1 Path Item Object.servers (omitting root servers) when retained routes span distinct service endpoints while keeping uniform endpoints at root servers - Guard against whitespace-only version strings in template info objects and add CI-unconditional multi-service fixture tests --- src/codegen/mod.rs | 19 +- src/openapi/mod.rs | 336 ++++++++++++++++++++++++++------- tests/generate_openapi_test.rs | 316 ++++++++++++++++++++++++++++++- 3 files changed, 599 insertions(+), 72 deletions(-) diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index c02fd42..15f65c3 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -136,6 +136,14 @@ pub enum CodegenError { "REST service binding '{service}' in profile '{profile}' is missing required 'schema' URL" )] MissingServiceSchema { profile: String, service: String }, + #[error( + "conflicting active OpenAPI route '{path}' declared by both '{first_service}' and '{second_service}' (use --service to scope to a single service)" + )] + ConflictingServicePath { + path: String, + first_service: String, + second_service: String, + }, } impl CodegenError { @@ -144,7 +152,9 @@ impl CodegenError { match self { Self::ResolveError(e) => e.exit_code(), Self::ComposeError(e) => e.exit_code(), - Self::NoRestServiceBinding { .. } | Self::MissingServiceSchema { .. } => 2, + Self::NoRestServiceBinding { .. } + | Self::MissingServiceSchema { .. } + | Self::ConflictingServicePath { .. } => 2, } } } @@ -529,5 +539,12 @@ mod tests { service: "s".to_string(), }; assert_eq!(missing_svc.exit_code(), 2); + + let conflicting_path = CodegenError::ConflictingServicePath { + path: "/orders".to_string(), + first_service: "dev.ucp.shopping".to_string(), + second_service: "dev.vendor.orders".to_string(), + }; + assert_eq!(conflicting_path.exit_code(), 2); } } diff --git a/src/openapi/mod.rs b/src/openapi/mod.rs index 551b624..fcbc039 100644 --- a/src/openapi/mod.rs +++ b/src/openapi/mod.rs @@ -75,12 +75,12 @@ struct TemplateSchemaBinding { struct MergedTemplates { openapi_version: String, info: Value, - fallback_servers: Option, + default_servers: Vec, paths: Map, + path_servers: BTreeMap>, webhooks: Map, parameters: Map, headers: Map, - template_schemas: Map, } /// Rewrite all local `#/$defs/` references in a JSON Schema or OpenAPI AST @@ -120,23 +120,15 @@ pub fn rewrite_defs_to_component_schemas(value: &mut Value) { pub fn generate_openapi(options: &GenerateOpenApiOptions) -> Result { let parsed = parse_profile_source(&options.profile)?; let selected_services = select_rest_services(&options.profile, &parsed.rest_services, options)?; - - let mut merged = load_and_merge_templates(&options.profile, &selected_services, &parsed)?; let compiled = compile_types(&GenerateTypesOptions::new().profile(&options.profile))?; - let bindings = parse_template_bindings(&merged.template_schemas); - prune_and_bind_route_map( - &mut merged.paths, - &bindings, - &compiled.active_capabilities, - &compiled.defs, - ); - prune_and_bind_route_map( - &mut merged.webhooks, - &bindings, - &compiled.active_capabilities, - &compiled.defs, - ); + let mut merged = load_bind_and_merge_templates( + &options.profile, + &selected_services, + &parsed, + &compiled, + options, + )?; let mut components_schemas = BTreeMap::new(); for (name, mut schema_val) in compiled.defs { @@ -158,7 +150,12 @@ pub fn generate_openapi(options: &GenerateOpenApiOptions) -> Result( Ok(selected) } -fn load_and_merge_templates( +fn load_bind_and_merge_templates( profile_source: &str, selected_services: &[&RestServiceBinding], parsed: &crate::codegen::ParsedProfile, + compiled: &crate::codegen::CompiledTypes, + options: &GenerateOpenApiOptions, ) -> Result { - let default_version = selected_services - .first() - .map(|b| b.version.trim()) - .filter(|v| !v.is_empty()) - .unwrap_or("1.0.0"); let mut merged = MergedTemplates { openapi_version: "3.1.0".to_string(), - info: json!({ "title": "UCP REST Service", "version": default_version }), + info: json!({ "title": "UCP REST Service", "version": "1.0.0" }), ..MergedTemplates::default() }; + let mut first_active_service: Option = None; + let mut fallback_metadata: Option<(String, Value, Vec)> = None; + let mut path_owners: BTreeMap = BTreeMap::new(); for (idx, binding) in selected_services.iter().enumerate() { let target = resolve_service_schema_target( @@ -233,34 +230,166 @@ fn load_and_merge_templates( })); }; + let template_ver = root_obj + .get("openapi") + .and_then(Value::as_str) + .unwrap_or("3.1.0") + .to_string(); + let service_info = build_service_info(binding, root_obj); + let binding_servers = resolve_binding_servers(binding, root_obj, options); if idx == 0 { - if let Some(ver) = root_obj.get("openapi").and_then(Value::as_str) { - merged.openapi_version = ver.to_string(); - } - if let Some(info_obj) = root_obj.get("info").and_then(Value::as_object) { - if let Some(merged_info) = merged.info.as_object_mut() { - for (k, v) in info_obj { - merged_info.insert(k.clone(), v.clone()); - } - } - } - merged.fallback_servers = root_obj.get("servers").cloned(); + fallback_metadata = Some(( + template_ver.clone(), + service_info.clone(), + binding_servers.clone(), + )); } - extend_object_map(&mut merged.paths, root_obj.get("paths")); - extend_object_map(&mut merged.webhooks, root_obj.get("webhooks")); + let empty_map = Map::new(); + let components = root_obj.get("components").and_then(Value::as_object); + let template_schemas = components + .and_then(|c| c.get("schemas")) + .and_then(Value::as_object) + .unwrap_or(&empty_map); + let schema_bindings = parse_template_bindings(template_schemas); + + let mut svc_paths = root_obj + .get("paths") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let mut svc_webhooks = root_obj + .get("webhooks") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + prune_and_bind_route_map( + &mut svc_paths, + &schema_bindings, + &compiled.active_capabilities, + &compiled.defs, + ); + prune_and_bind_route_map( + &mut svc_webhooks, + &schema_bindings, + &compiled.active_capabilities, + &compiled.defs, + ); - let Some(components) = root_obj.get("components").and_then(Value::as_object) else { + if svc_paths.is_empty() && svc_webhooks.is_empty() { continue; - }; - extend_object_map(&mut merged.parameters, components.get("parameters")); - extend_object_map(&mut merged.headers, components.get("headers")); - extend_object_map(&mut merged.template_schemas, components.get("schemas")); + } + + if first_active_service.is_none() { + merged.openapi_version = template_ver; + merged.info = service_info; + merged.default_servers = binding_servers.clone(); + first_active_service = Some(binding.service_name.clone()); + } else if first_active_service.as_deref() == Some(binding.service_name.as_str()) { + append_unique_servers(&mut merged.default_servers, &binding_servers); + } + + for (path_key, path_item) in svc_paths { + let Some(existing_owner) = path_owners.get(&path_key) else { + path_owners.insert(path_key.clone(), binding.service_name.clone()); + merged + .path_servers + .insert(path_key.clone(), binding_servers.clone()); + merged.paths.insert(path_key, path_item); + continue; + }; + if existing_owner != &binding.service_name { + return Err(CodegenError::ConflictingServicePath { + path: path_key, + first_service: existing_owner.clone(), + second_service: binding.service_name.clone(), + }); + } + let entry = merged.path_servers.entry(path_key).or_default(); + append_unique_servers(entry, &binding_servers); + } + + for (hook_key, hook_item) in svc_webhooks { + merged.webhooks.entry(hook_key).or_insert(hook_item); + } + + if let Some(comp) = components { + extend_object_map(&mut merged.parameters, comp.get("parameters")); + extend_object_map(&mut merged.headers, comp.get("headers")); + } + } + + if let (None, Some((ver, info, servers))) = (&first_active_service, fallback_metadata) { + merged.openapi_version = ver; + merged.info = info; + merged.default_servers = servers; } Ok(merged) } +fn build_service_info(binding: &RestServiceBinding, root_obj: &Map) -> Value { + let default_version = Some(binding.version.trim()) + .filter(|v| !v.is_empty()) + .unwrap_or("1.0.0"); + let mut info = Map::new(); + info.insert( + "title".to_string(), + Value::String("UCP REST Service".to_string()), + ); + info.insert( + "version".to_string(), + Value::String(default_version.to_string()), + ); + let Some(src_info) = root_obj.get("info").and_then(Value::as_object) else { + return Value::Object(info); + }; + for (k, v) in src_info { + if k == "version" && v.as_str().is_some_and(|s| s.trim().is_empty()) { + continue; + } + info.insert(k.clone(), v.clone()); + } + Value::Object(info) +} + +fn resolve_binding_servers( + binding: &RestServiceBinding, + root_obj: &Map, + options: &GenerateOpenApiOptions, +) -> Vec { + if let Some(url) = options + .server_url + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return vec![json!({ "url": url })]; + } + if let Some(ep) = binding + .endpoint + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return vec![json!({ "url": ep })]; + } + root_obj + .get("servers") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +fn append_unique_servers(dst: &mut Vec, src: &[Value]) { + for item in src { + if !dst.contains(item) { + dst.push(item.clone()); + } + } +} + fn extend_object_map(dst: &mut Map, src: Option<&Value>) { let Some(obj) = src.and_then(Value::as_object) else { return; @@ -584,38 +713,59 @@ fn collect_component_refs(val: &Value, prefix: &str, out: &mut BTreeSet) } } -fn build_servers_array( - selected_services: &[&RestServiceBinding], +fn resolve_root_and_path_servers( + paths: &mut Map, + path_servers: &BTreeMap>, + default_servers: Vec, options: &GenerateOpenApiOptions, - fallback_servers: Option, -) -> Value { - if let Some(url) = options.server_url.as_deref().filter(|s| !s.is_empty()) { - return json!([{ "url": url }]); +) -> Option { + if let Some(url) = options + .server_url + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + for path_val in paths.values_mut() { + if let Some(item_obj) = path_val.as_object_mut() { + item_obj.remove("servers"); + } + } + return Some(json!([{ "url": url }])); } - let mut seen = BTreeSet::new(); - let mut endpoints = Vec::new(); - for binding in selected_services { - let Some(ep) = binding.endpoint.as_deref().map(str::trim) else { + let Some(first_key) = paths.keys().next() else { + return Some(Value::Array(default_servers)); + }; + let first_servers = path_servers.get(first_key).cloned().unwrap_or_default(); + let uniform = paths + .keys() + .all(|k| path_servers.get(k).is_some_and(|s| s == &first_servers)); + if uniform { + return Some(Value::Array(first_servers)); + } + + for (path_key, path_val) in paths.iter_mut() { + let Some(servers) = path_servers.get(path_key).filter(|s| !s.is_empty()) else { continue; }; - if ep.is_empty() || !seen.insert(ep.to_string()) { + let Some(item_obj) = path_val.as_object_mut() else { continue; - } - endpoints.push(json!({ "url": ep })); - } - - if !endpoints.is_empty() { - return Value::Array(endpoints); - } - fallback_servers.unwrap_or_else(|| json!([])) + }; + let existing_entries: Vec<(String, Value)> = std::mem::take(item_obj) + .into_iter() + .filter(|(k, _)| k != "servers") + .collect(); + item_obj.insert("servers".to_string(), Value::Array(servers.clone())); + item_obj.extend(existing_entries); + } + None } #[allow(clippy::too_many_arguments)] fn assemble_openapi_document( openapi_version: String, info: Value, - servers: Value, + servers: Option, paths: Map, webhooks: Map, parameters: Map, @@ -629,7 +779,9 @@ fn assemble_openapi_document( Value::String(OAS_3_1_DIALECT.to_string()), ); root.insert("info".to_string(), info); - root.insert("servers".to_string(), servers); + if let Some(servers_val) = servers { + root.insert("servers".to_string(), servers_val); + } root.insert("paths".to_string(), Value::Object(paths)); if !webhooks.is_empty() { root.insert("webhooks".to_string(), Value::Object(webhooks)); @@ -738,4 +890,60 @@ mod tests { assert_eq!(ucp.target_def, "UcpBase"); assert!(!ucp.is_error_oneof); } + + #[test] + fn build_service_info_ignores_empty_template_version_and_resolve_servers_adapts() { + let binding = RestServiceBinding { + service_name: "dev.ucp.shopping".to_string(), + version: "2026-08-25".to_string(), + endpoint: Some("https://shop.example.com".to_string()), + schema_url: "rest.openapi.json".to_string(), + }; + let template_obj = json!({ + "info": { + "title": "Custom Shopping", + "version": " " + } + }); + let info = build_service_info(&binding, template_obj.as_object().unwrap()); + assert_eq!(info["title"], "Custom Shopping"); + assert_eq!(info["version"], "2026-08-25"); + + let mut paths = Map::from_iter([ + ("/checkout".to_string(), json!({ "post": {} })), + ("/ask".to_string(), json!({ "post": {} })), + ]); + let path_servers = BTreeMap::from([ + ( + "/checkout".to_string(), + vec![json!({ "url": "https://shop.example.com" })], + ), + ( + "/ask".to_string(), + vec![json!({ "url": "https://common.example.com" })], + ), + ]); + let opts = GenerateOpenApiOptions::new("profile.json"); + let root_servers = resolve_root_and_path_servers(&mut paths, &path_servers, vec![], &opts); + assert!(root_servers.is_none()); + assert_eq!( + paths["/checkout"]["servers"], + json!([{ "url": "https://shop.example.com" }]) + ); + assert_eq!( + paths["/ask"]["servers"], + json!([{ "url": "https://common.example.com" }]) + ); + + let override_opts = + GenerateOpenApiOptions::new("profile.json").server_url("https://gateway.example.com"); + let overridden = + resolve_root_and_path_servers(&mut paths, &path_servers, vec![], &override_opts); + assert_eq!( + overridden, + Some(json!([{ "url": "https://gateway.example.com" }])) + ); + assert!(paths["/checkout"].get("servers").is_none()); + assert!(paths["/ask"].get("servers").is_none()); + } } diff --git a/tests/generate_openapi_test.rs b/tests/generate_openapi_test.rs index 99b1205..f2a65ce 100644 --- a/tests/generate_openapi_test.rs +++ b/tests/generate_openapi_test.rs @@ -112,6 +112,24 @@ fn validate_operation_invariants(method: &str, path: &str, op_val: &Value) { } } +fn validate_servers_invariants(servers_val: &Value, ctx: &str) { + let servers = servers_val + .as_array() + .unwrap_or_else(|| panic!("servers at {ctx} must be an array")); + for srv in servers { + let srv_obj = srv + .as_object() + .unwrap_or_else(|| panic!("server entry at {ctx} must be an object")); + assert!( + srv_obj + .get("url") + .and_then(Value::as_str) + .is_some_and(|u| !u.trim().is_empty()), + "server entry at {ctx} must have non-empty string 'url': {srv:?}" + ); + } +} + fn assert_openapi_invariants(spec: &Value) { assert_eq!(spec["openapi"], "3.1.0"); assert!(spec["info"].is_object(), "info must be an object"); @@ -131,6 +149,9 @@ fn assert_openapi_invariants(spec: &Value) { spec["jsonSchemaDialect"], "https://spec.openapis.org/oas/3.1/dialect/base" ); + if let Some(root_servers) = spec.get("servers") { + validate_servers_invariants(root_servers, "root"); + } let paths = spec["paths"].as_object().expect("paths must be an object"); for (path, item) in paths { assert!( @@ -140,11 +161,17 @@ fn assert_openapi_invariants(spec: &Value) { let item_obj = item .as_object() .unwrap_or_else(|| panic!("path item for '{path}' must be an object")); + if let Some(item_servers) = item_obj.get("servers") { + validate_servers_invariants(item_servers, &format!("paths['{path}']")); + } for method in [ "get", "put", "post", "delete", "options", "head", "patch", "trace", ] { if let Some(op) = item_obj.get(method) { validate_operation_invariants(method, path, op); + if let Some(op_servers) = op.get("servers") { + validate_servers_invariants(op_servers, &format!("{method} {path}")); + } } } if let Some(params) = item_obj.get("parameters").and_then(Value::as_array) { @@ -640,16 +667,35 @@ fn container_capabilities_and_lodging_booking_bind_canonical_schemas() { generate_openapi(&GenerateOpenApiOptions::new(profile_path.to_str().unwrap())).unwrap(); assert_openapi_invariants(&merged_spec); - assert_eq!( - merged_spec["servers"], - json!([ - { "url": "https://commerce.example.com/shopping" }, - { "url": "https://commerce.example.com/common" }, - { "url": "https://commerce.example.com/lodging" } - ]) + // Heterogeneous service endpoints omit root servers and stamp OAS 3.1 Path Item Object.servers + assert!( + merged_spec.get("servers").is_none(), + "root servers must be omitted when retained routes span distinct service endpoints" ); let paths = merged_spec["paths"].as_object().unwrap(); + for shopping_path in ["/catalog/search", "/catalog/lookup", "/catalog/product"] { + assert_eq!( + paths[shopping_path]["servers"], + json!([{ "url": "https://commerce.example.com/shopping" }]) + ); + } + for common_path in ["/locations/search", "/locations/lookup", "/ask"] { + assert_eq!( + paths[common_path]["servers"], + json!([{ "url": "https://commerce.example.com/common" }]) + ); + } + for lodging_path in [ + "/booking-sessions", + "/booking-sessions/{id}", + "/booking-sessions/{id}/complete", + ] { + assert_eq!( + paths[lodging_path]["servers"], + json!([{ "url": "https://commerce.example.com/lodging" }]) + ); + } assert_eq!( paths["/catalog/search"]["post"]["requestBody"]["content"]["application/json"]["schema"] ["$ref"], @@ -1004,3 +1050,259 @@ fn shopping_cart_retains_cart_routes_and_binds_directional_cart_schemas() { assert!(!params.contains_key("checkout_session_id_path")); assert!(!params.contains_key("order_id_path")); } + +#[test] +fn multi_service_fixture_per_path_servers_shared_gateway_inactive_pruning_and_collision() { + let compose_dir = fixture_compose_dir(); + let schema_dir = compose_dir.join("schemas"); + let checkout_schema = schema_dir + .join("shopping/checkout.json") + .to_string_lossy() + .into_owned(); + let cart_schema = schema_dir + .join("shopping/cart.json") + .to_string_lossy() + .into_owned(); + + let tmp = tempfile::tempdir().unwrap(); + let checkout_tpl_path = tmp.path().join("checkout_service.openapi.json"); + let cart_tpl_path = tmp.path().join("cart_service.openapi.json"); + + std::fs::write( + &checkout_tpl_path, + json!({ + "openapi": "3.1.0", + "info": { "title": "UCP Shopping Service" }, + "paths": { + "/checkout-sessions": { + "post": { + "operationId": "create_checkout", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/checkout" } } } + }, + "responses": { + "201": { + "description": "Created", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/checkout" } } } + } + } + } + } + }, + "components": { + "schemas": { + "checkout": { "$ref": checkout_schema } + } + } + }) + .to_string(), + ) + .unwrap(); + + std::fs::write( + &cart_tpl_path, + json!({ + "openapi": "3.1.0", + "info": { "title": "UCP Cart Service" }, + "paths": { + "/carts": { + "post": { + "operationId": "create_cart", + "responses": { + "201": { + "description": "Created", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/cart" } } } + } + } + } + } + }, + "components": { + "parameters": { + "unused_cart_param": { + "name": "X-Cart", + "in": "header", + "required": false, + "schema": { "type": "string" } + } + }, + "schemas": { + "cart": { "$ref": cart_schema } + } + } + }) + .to_string(), + ) + .unwrap(); + + // 1. Heterogeneous endpoints -> per-path servers + omitted root servers + let hetero_profile_path = tmp.path().join("hetero_profile.json"); + std::fs::write( + &hetero_profile_path, + json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://api.example.com/shopping", + "schema": checkout_tpl_path.to_str().unwrap() + }], + "dev.ucp.cart_svc": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://api.example.com/carts", + "schema": cart_tpl_path.to_str().unwrap() + }] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [{ "version": "2026-08-25", "schema": checkout_schema }], + "dev.ucp.shopping.cart": [{ "version": "2026-08-25", "schema": cart_schema }] + } + } + }) + .to_string(), + ) + .unwrap(); + + let hetero_spec = generate_openapi(&GenerateOpenApiOptions::new( + hetero_profile_path.to_str().unwrap(), + )) + .unwrap(); + assert_openapi_invariants(&hetero_spec); + assert!(hetero_spec.get("servers").is_none()); + assert_eq!( + hetero_spec["paths"]["/checkout-sessions"]["servers"], + json!([{ "url": "https://api.example.com/shopping" }]) + ); + assert_eq!( + hetero_spec["paths"]["/carts"]["servers"], + json!([{ "url": "https://api.example.com/carts" }]) + ); + + // 2. Shared gateway endpoint across services -> single root servers, no per-path servers + let shared_profile_path = tmp.path().join("shared_gateway_profile.json"); + std::fs::write( + &shared_profile_path, + json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://api.example.com/ucp", + "schema": checkout_tpl_path.to_str().unwrap() + }], + "dev.ucp.cart_svc": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://api.example.com/ucp", + "schema": cart_tpl_path.to_str().unwrap() + }] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [{ "version": "2026-08-25", "schema": checkout_schema }], + "dev.ucp.shopping.cart": [{ "version": "2026-08-25", "schema": cart_schema }] + } + } + }) + .to_string(), + ) + .unwrap(); + + let shared_spec = generate_openapi(&GenerateOpenApiOptions::new( + shared_profile_path.to_str().unwrap(), + )) + .unwrap(); + assert_openapi_invariants(&shared_spec); + assert_eq!( + shared_spec["servers"], + json!([{ "url": "https://api.example.com/ucp" }]) + ); + assert!(shared_spec["paths"]["/checkout-sessions"] + .get("servers") + .is_none()); + assert!(shared_spec["paths"]["/carts"].get("servers").is_none()); + + // 3. Inactive leading service does not leak its endpoint, info, or parameters + let inactive_first_path = tmp.path().join("inactive_first_profile.json"); + std::fs::write( + &inactive_first_path, + json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.cart_svc": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://unused.example.com/carts", + "schema": cart_tpl_path.to_str().unwrap() + }], + "dev.ucp.shopping": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://api.example.com/shopping", + "schema": checkout_tpl_path.to_str().unwrap() + }] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [{ "version": "2026-08-25", "schema": checkout_schema }] + } + } + }) + .to_string(), + ) + .unwrap(); + + let pruned_spec = generate_openapi(&GenerateOpenApiOptions::new( + inactive_first_path.to_str().unwrap(), + )) + .unwrap(); + assert_openapi_invariants(&pruned_spec); + assert_eq!(pruned_spec["info"]["title"], "UCP Shopping Service"); + assert_eq!( + pruned_spec["servers"], + json!([{ "url": "https://api.example.com/shopping" }]) + ); + assert!(pruned_spec["paths"].get("/carts").is_none()); + + // 4. Conflicting active path across distinct services -> ConflictingServicePath (exit code 2) + let collision_profile_path = tmp.path().join("collision_profile.json"); + std::fs::write( + &collision_profile_path, + json!({ + "ucp": { + "version": "2026-08-25", + "services": { + "dev.ucp.shopping": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://api.example.com/shopping", + "schema": checkout_tpl_path.to_str().unwrap() + }], + "dev.vendor.alt_shopping": [{ + "version": "2026-08-25", + "transport": "rest", + "endpoint": "https://api.example.com/alt-shopping", + "schema": checkout_tpl_path.to_str().unwrap() + }] + }, + "capabilities": { + "dev.ucp.shopping.checkout": [{ "version": "2026-08-25", "schema": checkout_schema }] + } + } + }) + .to_string(), + ) + .unwrap(); + + let err = generate_openapi(&GenerateOpenApiOptions::new( + collision_profile_path.to_str().unwrap(), + )) + .expect_err("colliding active path across distinct services must error"); + assert!(matches!(err, CodegenError::ConflictingServicePath { .. })); + assert_eq!(err.exit_code(), 2); +}