Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 58 additions & 4 deletions src/bin/ucp-schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>,

/// Base server URL injected into servers (overrides the profile's REST service endpoint)
#[arg(long)]
server_url: Option<String>,

/// Output file path (emits JSON to stdout if omitted)
#[arg(long, short = 'o')]
output: Option<PathBuf>,

/// 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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -858,6 +896,22 @@ fn resolve_path_for_prefix_check(p: &Path) -> PathBuf {
.unwrap_or(normalized)
}

fn run_generate_openapi(
profile: String,
service: Option<String>,
server_url: Option<String>,
output: Option<PathBuf>,
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<T: serde::Serialize + ?Sized>(
value: &T,
Expand Down
35 changes: 35 additions & 0 deletions src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ 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 },
#[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 {
Expand All @@ -138,6 +152,9 @@ impl CodegenError {
match self {
Self::ResolveError(e) => e.exit_code(),
Self::ComposeError(e) => e.exit_code(),
Self::NoRestServiceBinding { .. }
| Self::MissingServiceSchema { .. }
| Self::ConflictingServicePath { .. } => 2,
}
}
}
Expand Down Expand Up @@ -511,5 +528,23 @@ 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);

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);
}
}
75 changes: 75 additions & 0 deletions src/codegen/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<rel>` 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<String, CodegenError> {
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<LocalSchemaRoot> {
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<String> {
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::*;
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ mod error;
mod linter;
mod loader;
mod namespace;
pub mod openapi;
mod resolver;
mod types;
mod validator;
Expand All @@ -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};
Expand Down
Loading
Loading