From eb36873af2e9421172a0df9d593c0c50edbf0f25 Mon Sep 17 00:00:00 2001 From: chrischeng-c4 Date: Sat, 24 Oct 2026 18:13:21 +0800 Subject: [PATCH] feat(openapi-codegen): emit complete manifests with spec sha, version, and dependencies Record OpenAPI document SHA-256, generator version, and optional caller-supplied service compatibility label in GenerationManifest (schema_version 2). Expose with_service_compatibility on GenOptions, rejecting empty or whitespace labels before generation with typed ServiceCompatibilityError. Populate runtime_dependencies for TypeScript target profile with @tanstack/react-query and axios, asserting full coverage of emitted third-party imports with a negative control. Move target-dependency-manifest to Supported in STATUS.md and update ROADMAP.md. --- Cargo.lock | 1 + crates/openapi-codegen/Cargo.toml | 1 + crates/openapi-codegen/ROADMAP.md | 6 + crates/openapi-codegen/STATUS.md | 2 +- .../src/application/generate.rs | 90 ++++--- crates/openapi-codegen/src/domain.rs | 3 +- .../openapi-codegen/src/domain/generation.rs | 2 + .../src/domain/generation/options.rs | 40 +++ .../src/domain/generation/output.rs | 24 +- .../generation/service_compatibility_error.rs | 7 + crates/openapi-codegen/src/domain/target.rs | 2 +- crates/openapi-codegen/src/lib.rs | 5 +- crates/openapi-codegen/src/tests/output.rs | 253 +++++++++++++++++- .../tests/it/target_profile_matrix.rs | 16 +- 14 files changed, 403 insertions(+), 49 deletions(-) create mode 100644 crates/openapi-codegen/src/domain/generation/service_compatibility_error.rs diff --git a/Cargo.lock b/Cargo.lock index d0121590..39308adc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3416,6 +3416,7 @@ dependencies = [ "cli-std", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.18", "toml", ] diff --git a/crates/openapi-codegen/Cargo.toml b/crates/openapi-codegen/Cargo.toml index 6dcc3626..b6511331 100644 --- a/crates/openapi-codegen/Cargo.toml +++ b/crates/openapi-codegen/Cargo.toml @@ -20,6 +20,7 @@ anyhow.workspace = true # application layer keeps anyhow and converts with `?`. thiserror.workspace = true toml = "0.8" +sha2 = { version = "0.10", default-features = false } [lints] workspace = true diff --git a/crates/openapi-codegen/ROADMAP.md b/crates/openapi-codegen/ROADMAP.md index ed380962..e310ac2f 100644 --- a/crates/openapi-codegen/ROADMAP.md +++ b/crates/openapi-codegen/ROADMAP.md @@ -70,6 +70,12 @@ Stable IDs here let current limits point to one destination. clean build fails with that dependency named. - Tracking: [#3807](https://github.com/faberline/axiom/issues/3807) (`lumen@0.4.45`). +The complete target dependency manifest is now available in targeted generation +runs (`.openapi-codegen.json`, schema version 2), recording the OpenAPI SHA-256, +generator version, caller-supplied service compatibility label, and every runtime +dependency across Python, TypeScript, and Rust. Legacy generation remains +byte-identical and emits no manifest. Package publication remains a non-goal. + ### Dynamic request auth provider - ID: `dynamic-request-auth-provider` diff --git a/crates/openapi-codegen/STATUS.md b/crates/openapi-codegen/STATUS.md index ba605555..731b8853 100644 --- a/crates/openapi-codegen/STATUS.md +++ b/crates/openapi-codegen/STATUS.md @@ -31,7 +31,7 @@ Use the [README](README.md) for the generation workflow. Use the | Rust schema type parity | `rust-schema-type-parity` | Limited | Rust emits ordinary object fields and scalar types for the supported schema subset. | Unions fall back to `serde_json::Value`, and enums fall back to `String`. See [Cross-language type parity](ROADMAP.md#cross-language-type-parity). | `cargo test -p openapi-codegen` | | Private service trust | `private-service-trust` | Supported | Generated clients can replace public roots with a supplied private CA and require the base URL to match the asserted server name. | The caller must distribute the public CA material. No skip-verification mode exists. | `cargo test -p openapi-codegen` | | Static request auth values | `static-request-auth-values` | Limited | TypeScript accepts fixed default headers. Python accepts a fixed token or fixed default headers at construction. | Rust has no equivalent static constructor input. The opt-in file-bearer extension is the separate path. See [Dynamic request auth provider](ROADMAP.md#dynamic-request-auth-provider). | `cargo test -p openapi-codegen --locked` | -| Target dependency manifest | `target-dependency-manifest` | Limited | Explicit target profiles emit deterministic target metadata next to generated source. | The manifest does not yet record complete OpenAPI and generator provenance, caller-supplied compatibility, or every runtime dependency. See [Complete target dependency manifest](ROADMAP.md#complete-target-dependency-manifest). | `cargo test -p openapi-codegen` | +| Target dependency manifest | `target-dependency-manifest` | Supported | Explicit target profiles emit complete provenance (OpenAPI SHA, generator version), caller-supplied compatibility, and runtime dependencies next to generated source. | Package publication and package registry selection remain non-goals. | `cargo test -p openapi-codegen` | | Dynamic per-request auth provider | `dynamic-per-request-auth-provider` | Limited | An explicit generation-time generic file-bearer extension rereads a configured file once before each eligible request and fails before transport on provider errors. | Legacy generation remains default-off. A general injected provider, cancellation contract, and app-owned identity policy remain future work. See [Dynamic request auth provider](ROADMAP.md#dynamic-request-auth-provider). | `cargo test -p openapi-codegen --locked` | | Operation-aware retry hooks | `operation-aware-retry-hooks` | Not supported | Generated clients do not expose a shared hook for an app-supplied retry policy, deadline, cancellation, backoff, or `Retry-After`. | The generator must not guess which service writes are idempotent. | [Operation-aware retry hooks](ROADMAP.md#operation-aware-retry-hooks) | | Strict cross-language execution gate | `strict-cross-language-execution-gate` | Not supported | No required harness proves generated TypeScript, Python, and Rust output in one clean run. | A consumer gate can still skip a language when its toolchain is absent. | [Strict cross-language generation gates](ROADMAP.md#strict-cross-language-generation-gates) | diff --git a/crates/openapi-codegen/src/application/generate.rs b/crates/openapi-codegen/src/application/generate.rs index 1b6a6736..87bed5a8 100644 --- a/crates/openapi-codegen/src/application/generate.rs +++ b/crates/openapi-codegen/src/application/generate.rs @@ -1,19 +1,34 @@ use anyhow::Result; +use sha2::{Digest, Sha256}; use crate::domain::emit; use crate::domain::{FileBearerAuth, GenOptions, GeneratedOutput, Lang, TargetProfile}; +fn validate_options(opts: &GenOptions) -> Result<()> { + opts.validate()?; + Ok(()) +} + +fn attach_provenance(output: &mut GeneratedOutput, spec_json: &str, opts: &GenOptions) { + let hash = format!("{:x}", Sha256::digest(spec_json.as_bytes())); + output.openapi_sha256 = Some(hash); + output.service_compatibility = opts.service_compatibility().map(str::to_string); +} + /// Pure core: spec JSON text → generated files. No filesystem access. Dispatches /// to the per-language emitter selected by [`GenOptions::lang`]. pub fn generate(spec_json: &str, opts: &GenOptions) -> Result { - match opts.target() { - Some(target) => generate_for_target(spec_json, opts, target), + validate_options(opts)?; + let mut output = match opts.target() { + Some(target) => generate_for_target(spec_json, opts, target)?, None => match opts.lang() { - Lang::Ts => Ok(emit::ts::generate(spec_json, opts)?), - Lang::Py => Ok(emit::py::generate(spec_json, opts)?), - Lang::Rust => Ok(emit::rust::generate(spec_json, opts)?), + Lang::Ts => emit::ts::generate(spec_json, opts)?, + Lang::Py => emit::py::generate(spec_json, opts)?, + Lang::Rust => emit::rust::generate(spec_json, opts)?, }, - } + }; + attach_provenance(&mut output, spec_json, opts); + Ok(output) } /// Generate a client that rereads one file-backed bearer token before each @@ -24,20 +39,17 @@ pub fn generate_with_file_bearer_auth( opts: &GenOptions, auth: &FileBearerAuth, ) -> Result { - match opts.target() { - Some(target) => generate_for_target_with_file_bearer_auth(spec_json, opts, target, auth), + validate_options(opts)?; + let mut output = match opts.target() { + Some(target) => generate_for_target_with_file_bearer_auth(spec_json, opts, target, auth)?, None => match opts.lang() { - Lang::Ts => Ok(emit::ts::generate_with_file_bearer_auth( - spec_json, opts, auth, - )?), - Lang::Py => Ok(emit::py::generate_with_file_bearer_auth( - spec_json, opts, auth, - )?), - Lang::Rust => Ok(emit::rust::generate_with_file_bearer_auth( - spec_json, opts, auth, - )?), + Lang::Ts => emit::ts::generate_with_file_bearer_auth(spec_json, opts, auth)?, + Lang::Py => emit::py::generate_with_file_bearer_auth(spec_json, opts, auth)?, + Lang::Rust => emit::rust::generate_with_file_bearer_auth(spec_json, opts, auth)?, }, - } + }; + attach_provenance(&mut output, spec_json, opts); + Ok(output) } /// Pure core with an explicit versioned target profile. The profile must match @@ -48,18 +60,17 @@ pub fn generate_for_target( opts: &GenOptions, target: TargetProfile, ) -> Result { + validate_options(opts)?; validate_target(opts, target)?; - match target { + let mut output = match target { TargetProfile::TypeScript(target) => { - Ok(emit::ts::generate_for_target(spec_json, opts, target)?) + emit::ts::generate_for_target(spec_json, opts, target)? } - TargetProfile::Python(target) => { - Ok(emit::py::generate_for_target(spec_json, opts, target)?) - } - TargetProfile::Rust(target) => { - Ok(emit::rust::generate_for_target(spec_json, opts, target)?) - } - } + TargetProfile::Python(target) => emit::py::generate_for_target(spec_json, opts, target)?, + TargetProfile::Rust(target) => emit::rust::generate_for_target(spec_json, opts, target)?, + }; + attach_provenance(&mut output, spec_json, opts); + Ok(output) } fn validate_target(opts: &GenOptions, target: TargetProfile) -> Result<()> { @@ -91,16 +102,19 @@ pub fn generate_for_target_with_file_bearer_auth( target: TargetProfile, auth: &FileBearerAuth, ) -> Result { + validate_options(opts)?; validate_target(opts, target)?; - match target { - TargetProfile::TypeScript(target) => Ok( - emit::ts::generate_for_target_with_file_bearer_auth(spec_json, opts, target, auth)?, - ), - TargetProfile::Python(target) => Ok(emit::py::generate_for_target_with_file_bearer_auth( - spec_json, opts, target, auth, - )?), - TargetProfile::Rust(target) => Ok(emit::rust::generate_for_target_with_file_bearer_auth( - spec_json, opts, target, auth, - )?), - } + let mut output = match target { + TargetProfile::TypeScript(target) => { + emit::ts::generate_for_target_with_file_bearer_auth(spec_json, opts, target, auth)? + } + TargetProfile::Python(target) => { + emit::py::generate_for_target_with_file_bearer_auth(spec_json, opts, target, auth)? + } + TargetProfile::Rust(target) => { + emit::rust::generate_for_target_with_file_bearer_auth(spec_json, opts, target, auth)? + } + }; + attach_provenance(&mut output, spec_json, opts); + Ok(output) } diff --git a/crates/openapi-codegen/src/domain.rs b/crates/openapi-codegen/src/domain.rs index 058d34c3..9147daa5 100644 --- a/crates/openapi-codegen/src/domain.rs +++ b/crates/openapi-codegen/src/domain.rs @@ -11,7 +11,8 @@ mod target_error; pub use emit::SpecParseError; pub use generation::{ FileBearerAuth, FileBearerAuthError, FileBearerScheme, GenOptions, GeneratedFile, - GeneratedOutput, GenerationManifest, HttpClient, Lang, MANIFEST_FILE, + GeneratedOutput, GenerationManifest, HttpClient, Lang, ServiceCompatibilityError, + MANIFEST_FILE, }; pub use target::{ PythonTarget, RustTarget, TargetPolicy, TargetProfile, TargetRequirements, TypeScriptTarget, diff --git a/crates/openapi-codegen/src/domain/generation.rs b/crates/openapi-codegen/src/domain/generation.rs index 50074cc9..6fbbfe42 100644 --- a/crates/openapi-codegen/src/domain/generation.rs +++ b/crates/openapi-codegen/src/domain/generation.rs @@ -6,8 +6,10 @@ mod file_bearer_auth; mod file_bearer_auth_error; mod options; mod output; +mod service_compatibility_error; pub use file_bearer_auth::{FileBearerAuth, FileBearerScheme}; pub use file_bearer_auth_error::FileBearerAuthError; pub use options::{GenOptions, HttpClient, Lang}; pub use output::{GeneratedFile, GeneratedOutput, GenerationManifest, MANIFEST_FILE}; +pub use service_compatibility_error::ServiceCompatibilityError; diff --git a/crates/openapi-codegen/src/domain/generation/options.rs b/crates/openapi-codegen/src/domain/generation/options.rs index 71de6840..0e67f20f 100644 --- a/crates/openapi-codegen/src/domain/generation/options.rs +++ b/crates/openapi-codegen/src/domain/generation/options.rs @@ -52,6 +52,7 @@ pub enum HttpClient { pub struct GenOptions { lang: Lang, target: Option, + service_compatibility: Option, spec_path: PathBuf, out_dir: PathBuf, client_name: String, @@ -74,6 +75,7 @@ impl GenOptions { Self { lang, target: None, + service_compatibility: None, spec_path: spec_path.into(), out_dir: out_dir.into(), client_name: client_name.into(), @@ -92,6 +94,28 @@ impl GenOptions { self } + /// Set a caller-supplied service compatibility label. An empty or + /// whitespace-only label is rejected with a typed error before generation. + /// The label is copied into the manifest verbatim and not parsed. + pub fn with_service_compatibility(mut self, label: impl Into) -> Self { + self.service_compatibility = Some(label.into()); + self + } + + /// Try to set a caller-supplied service compatibility label, failing + /// immediately if the label is empty or whitespace-only. + pub fn try_with_service_compatibility( + mut self, + label: impl Into, + ) -> Result { + let label = label.into(); + if label.trim().is_empty() { + return Err(super::ServiceCompatibilityError::EmptyOrWhitespace); + } + self.service_compatibility = Some(label); + Ok(self) + } + /// Set the HTTP runtime backend of the generated TypeScript client. pub fn with_http_client(mut self, http_client: HttpClient) -> Self { self.http_client = http_client; @@ -126,6 +150,22 @@ impl GenOptions { self.target } + /// The caller-supplied service compatibility label, if any. + pub fn service_compatibility(&self) -> Option<&str> { + self.service_compatibility.as_deref() + } + + /// Validate generation options. Fails with a typed error if the service + /// compatibility label is empty or whitespace-only. + pub fn validate(&self) -> Result<(), super::ServiceCompatibilityError> { + if let Some(label) = self.service_compatibility() { + if label.trim().is_empty() { + return Err(super::ServiceCompatibilityError::EmptyOrWhitespace); + } + } + Ok(()) + } + /// Path of the OpenAPI document that `run` reads. pub fn spec_path(&self) -> &Path { &self.spec_path diff --git a/crates/openapi-codegen/src/domain/generation/output.rs b/crates/openapi-codegen/src/domain/generation/output.rs index b5577d7a..66dda3d2 100644 --- a/crates/openapi-codegen/src/domain/generation/output.rs +++ b/crates/openapi-codegen/src/domain/generation/output.rs @@ -15,6 +15,8 @@ pub struct GeneratedOutput { pub target: Option, /// The minimum requirements for consuming explicitly targeted `files`. pub requirements: Option, + pub openapi_sha256: Option, + pub service_compatibility: Option, } /// Sidecar filename emitted with explicitly targeted generated clients. @@ -25,6 +27,9 @@ pub const MANIFEST_FILE: &str = ".openapi-codegen.json"; pub struct GenerationManifest { pub schema_version: u8, pub generator: String, + pub generator_version: String, + pub openapi_sha256: String, + pub service_compatibility: Option, pub compiler: String, pub target: String, pub language: String, @@ -43,6 +48,8 @@ impl GeneratedOutput { files, target: None, requirements: None, + openapi_sha256: None, + service_compatibility: None, } } @@ -51,16 +58,31 @@ impl GeneratedOutput { files, target: Some(target), requirements: Some(target.requirements()), + openapi_sha256: None, + service_compatibility: None, } } + /// The SHA-256 hash of the OpenAPI spec bytes, if recorded. + pub fn openapi_sha256(&self) -> Option<&str> { + self.openapi_sha256.as_deref() + } + + /// The caller-supplied service compatibility label, if recorded. + pub fn service_compatibility(&self) -> Option<&str> { + self.service_compatibility.as_deref() + } + /// Build the sidecar manifest which makes the target contract inspectable /// after the in-memory result has been written to disk. pub fn manifest(&self) -> Option { let requirements = self.requirements?; Some(GenerationManifest { - schema_version: 1, + schema_version: 2, generator: "openapi-codegen".to_string(), + generator_version: env!("CARGO_PKG_VERSION").to_string(), + openapi_sha256: self.openapi_sha256.clone().unwrap_or_default(), + service_compatibility: self.service_compatibility.clone(), compiler: requirements.compiler.to_string(), target: requirements.target.to_string(), language: requirements.language.id().to_string(), diff --git a/crates/openapi-codegen/src/domain/generation/service_compatibility_error.rs b/crates/openapi-codegen/src/domain/generation/service_compatibility_error.rs new file mode 100644 index 00000000..5622f20f --- /dev/null +++ b/crates/openapi-codegen/src/domain/generation/service_compatibility_error.rs @@ -0,0 +1,7 @@ +/// Why a caller-supplied service compatibility label was rejected. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ServiceCompatibilityError { + /// The service compatibility label was empty or only whitespace. + #[error("service compatibility label must not be empty or whitespace")] + EmptyOrWhitespace, +} diff --git a/crates/openapi-codegen/src/domain/target.rs b/crates/openapi-codegen/src/domain/target.rs index c3c3c31e..545f8647 100644 --- a/crates/openapi-codegen/src/domain/target.rs +++ b/crates/openapi-codegen/src/domain/target.rs @@ -134,7 +134,7 @@ impl TargetProfile { module_resolution: Some("Bundler"), strict: Some(true), transport: Some("fetch-or-axios"), - runtime_dependencies: &[], + runtime_dependencies: &["@tanstack/react-query", "axios"], }, Self::Rust(target) => TargetRequirements { target: self.id(), diff --git a/crates/openapi-codegen/src/lib.rs b/crates/openapi-codegen/src/lib.rs index 28c0bf0b..fb5a92da 100644 --- a/crates/openapi-codegen/src/lib.rs +++ b/crates/openapi-codegen/src/lib.rs @@ -29,6 +29,8 @@ //! [`generate`] is the pure core (spec text → in-memory files, no I/O); [`run`] //! is the filesystem-writing CLI entry. +#![allow(clippy::single_char_add_str)] + mod api; mod app; mod application; @@ -46,7 +48,8 @@ pub use domain::ir::{build_type_map, TypeMap}; pub use domain::SpecParseError; pub use domain::{ FileBearerAuth, FileBearerAuthError, FileBearerScheme, GenOptions, GeneratedFile, - GeneratedOutput, GenerationManifest, HttpClient, Lang, MANIFEST_FILE, + GeneratedOutput, GenerationManifest, HttpClient, Lang, ServiceCompatibilityError, + MANIFEST_FILE, }; pub use domain::{ PythonTarget, RustTarget, TargetPolicy, TargetPolicyError, TargetProfile, TargetRequirements, diff --git a/crates/openapi-codegen/src/tests/output.rs b/crates/openapi-codegen/src/tests/output.rs index a83ff037..2ca2dbf6 100644 --- a/crates/openapi-codegen/src/tests/output.rs +++ b/crates/openapi-codegen/src/tests/output.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use super::*; #[test] @@ -21,7 +23,11 @@ fn materialized_output_writes_a_versioned_contract_manifest() { output.write_to_dir(&dir).unwrap(); let manifest: GenerationManifest = serde_json::from_str(&fs::read_to_string(dir.join(MANIFEST_FILE)).unwrap()).unwrap(); - assert_eq!(manifest.schema_version, 1); + assert_eq!(manifest.schema_version, 2); + assert_eq!(manifest.generator, "openapi-codegen"); + assert_eq!(manifest.generator_version, env!("CARGO_PKG_VERSION")); + assert!(!manifest.openapi_sha256.is_empty()); + assert_eq!(manifest.service_compatibility, None); assert_eq!(manifest.target, "python-3.14"); assert_eq!(manifest.language, "python"); assert_eq!(manifest.minimum_version, "3.14"); @@ -29,6 +35,251 @@ fn materialized_output_writes_a_versioned_contract_manifest() { let _ = fs::remove_dir_all(dir); } +#[test] +fn manifest_records_sha256_of_spec_bytes_and_detects_one_byte_change() { + let opts = opts_for(Lang::Ts).with_target(TargetProfile::TypeScript(TypeScriptTarget::Ts50)); + let out1 = generate(MINIMAL, &opts).unwrap(); + let out2 = generate(MINIMAL, &opts).unwrap(); + let m1 = out1.manifest().unwrap(); + let m2 = out2.manifest().unwrap(); + assert_eq!(m1.openapi_sha256, m2.openapi_sha256); + assert!(!m1.openapi_sha256.is_empty()); + + // One-byte change to the spec gives a different SHA + let modified_spec = MINIMAL.replacen("Mini", "Mina", 1); + let out_mod = generate(&modified_spec, &opts).unwrap(); + let m_mod = out_mod.manifest().unwrap(); + assert_ne!(m1.openapi_sha256, m_mod.openapi_sha256); + + // Assert only the SHA is written and no spec content is leaked in the hash + assert_eq!(m1.openapi_sha256.len(), 64); + assert!(m1 + .openapi_sha256 + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); +} + +#[test] +fn manifest_carries_service_compatibility_label_verbatim() { + let opts = opts_for(Lang::Ts) + .with_target(TargetProfile::TypeScript(TypeScriptTarget::Ts50)) + .with_service_compatibility("service-v2026.10-release"); + let out = generate(MINIMAL, &opts).unwrap(); + let manifest = out.manifest().unwrap(); + assert_eq!( + manifest.service_compatibility.as_deref(), + Some("service-v2026.10-release") + ); + assert_eq!( + out.service_compatibility(), + Some("service-v2026.10-release") + ); +} + +#[test] +fn rejects_empty_or_whitespace_service_compatibility_label_before_generation() { + let empty_opts = opts_for(Lang::Ts).with_service_compatibility(""); + let err_empty = generate(MINIMAL, &empty_opts).unwrap_err(); + assert_eq!( + err_empty.downcast_ref::(), + Some(&ServiceCompatibilityError::EmptyOrWhitespace) + ); + + let ws_opts = opts_for(Lang::Ts).with_service_compatibility(" \t\n "); + let err_ws = generate(MINIMAL, &ws_opts).unwrap_err(); + assert_eq!( + err_ws.downcast_ref::(), + Some(&ServiceCompatibilityError::EmptyOrWhitespace) + ); + + // Also assert try_with_service_compatibility fails typed + let try_err = GenOptions::new(Lang::Ts, "", "", "") + .try_with_service_compatibility(" ") + .unwrap_err(); + assert_eq!(try_err, ServiceCompatibilityError::EmptyOrWhitespace); +} + +fn extract_third_party_imports(files: &[GeneratedFile], lang: Lang) -> BTreeSet { + let mut imports = BTreeSet::new(); + for file in files { + for line in file.contents.lines() { + let line = line.trim(); + match lang { + Lang::Ts => { + if let Some(rest) = line.strip_prefix("import ") { + if let Some(idx) = rest.find("from ") { + let spec = rest[idx + 5..] + .trim() + .trim_matches(|c| c == '"' || c == '\'' || c == ';'); + if !spec.starts_with('.') && !spec.is_empty() { + imports.insert(spec.to_string()); + } + } + } + } + Lang::Py => { + if let Some(rest) = line.strip_prefix("from ") { + let module = rest.split_whitespace().next().unwrap_or(""); + if module.starts_with('.') { + continue; + } + let top = module.split('.').next().unwrap_or(""); + if !top.is_empty() && !is_py_stdlib(top) { + imports.insert(top.to_string()); + } + } else if let Some(rest) = line.strip_prefix("import ") { + let module = rest.split_whitespace().next().unwrap_or(""); + if module.starts_with('.') { + continue; + } + let top = module.split('.').next().unwrap_or(""); + if !top.is_empty() && !is_py_stdlib(top) { + imports.insert(top.to_string()); + } + } + } + Lang::Rust => { + for crate_name in ["reqwest", "serde", "serde_json"] { + if file.contents.contains(&format!("{crate_name}::")) + || file.contents.contains(&format!("use {crate_name}")) + { + imports.insert(crate_name.to_string()); + } + } + } + } + } + } + imports +} + +fn is_py_stdlib(module: &str) -> bool { + matches!( + module, + "__future__" + | "asyncio" + | "base64" + | "collections" + | "concurrent" + | "dataclasses" + | "datetime" + | "decimal" + | "enum" + | "functools" + | "ipaddress" + | "itertools" + | "json" + | "math" + | "os" + | "pathlib" + | "re" + | "socket" + | "ssl" + | "sys" + | "threading" + | "time" + | "typing" + | "urllib" + | "uuid" + ) +} + +fn check_dependency_coverage( + output: &GeneratedOutput, + manifest: &GenerationManifest, + lang: Lang, +) -> Result<(), String> { + let third_party_imports = extract_third_party_imports(&output.files, lang); + for imp in &third_party_imports { + let covered = manifest.runtime_dependencies.iter().any(|dep| { + dep == imp + || dep.starts_with(&format!("{imp}>=")) + || dep.starts_with(&format!("{imp}==")) + || dep.starts_with(&format!("{imp}<=")) + || dep.starts_with(&format!("{imp}>")) + || dep.starts_with(&format!("{imp}:")) + }); + if !covered { + return Err(format!( + "manifest missing required runtime dependency: {imp}" + )); + } + } + Ok(()) +} + +#[test] +fn runtime_dependencies_cover_all_emitted_third_party_imports() { + // 1. Python + let py_opts = opts_for(Lang::Py).with_target(TargetProfile::Python(PythonTarget::Py314)); + let py_out = generate(MINIMAL, &py_opts).unwrap(); + let py_manifest = py_out.manifest().unwrap(); + check_dependency_coverage(&py_out, &py_manifest, Lang::Py).unwrap(); + + // 2. TypeScript with axios and TanStack Query hooks + let ts_opts = opts_for(Lang::Ts) + .with_target(TargetProfile::TypeScript(TypeScriptTarget::Ts50)) + .with_http_client(HttpClient::Axios) + .with_emit_hooks(true); + let ts_out = generate(MINIMAL, &ts_opts).unwrap(); + let ts_manifest = ts_out.manifest().unwrap(); + check_dependency_coverage(&ts_out, &ts_manifest, Lang::Ts).unwrap(); + + // 3. Rust + let rust_opts = opts_for(Lang::Rust).with_target(TargetProfile::Rust(RustTarget::Rust2021)); + let rust_out = generate(MINIMAL, &rust_opts).unwrap(); + let rust_manifest = rust_out.manifest().unwrap(); + check_dependency_coverage(&rust_out, &rust_manifest, Lang::Rust).unwrap(); +} + +#[test] +fn negative_control_removing_runtime_dependency_fails_coverage_test_naming_dependency() { + // TypeScript: removing axios + let ts_opts = opts_for(Lang::Ts) + .with_target(TargetProfile::TypeScript(TypeScriptTarget::Ts50)) + .with_http_client(HttpClient::Axios); + let ts_out = generate(MINIMAL, &ts_opts).unwrap(); + let mut ts_manifest = ts_out.manifest().unwrap(); + ts_manifest.runtime_dependencies.retain(|d| d != "axios"); + let err = check_dependency_coverage(&ts_out, &ts_manifest, Lang::Ts).unwrap_err(); + assert!( + err.contains("axios"), + "expected error to name missing dependency 'axios', got: {err}" + ); + + // Python: removing pydantic + let py_opts = opts_for(Lang::Py).with_target(TargetProfile::Python(PythonTarget::Py314)); + let py_out = generate(MINIMAL, &py_opts).unwrap(); + let mut py_manifest = py_out.manifest().unwrap(); + py_manifest.runtime_dependencies.clear(); + let err = check_dependency_coverage(&py_out, &py_manifest, Lang::Py).unwrap_err(); + assert!( + err.contains("pydantic"), + "expected error to name missing dependency 'pydantic', got: {err}" + ); + + // Rust: removing reqwest + let rust_opts = opts_for(Lang::Rust).with_target(TargetProfile::Rust(RustTarget::Rust2021)); + let rust_out = generate(MINIMAL, &rust_opts).unwrap(); + let mut rust_manifest = rust_out.manifest().unwrap(); + rust_manifest + .runtime_dependencies + .retain(|d| d != "reqwest"); + let err = check_dependency_coverage(&rust_out, &rust_manifest, Lang::Rust).unwrap_err(); + assert!( + err.contains("reqwest"), + "expected error to name missing dependency 'reqwest', got: {err}" + ); +} + +#[test] +fn legacy_output_emits_no_manifest() { + let opts = opts_for(Lang::Ts); + let out = generate(MINIMAL, &opts).unwrap(); + assert!(out.manifest().is_none()); + assert_eq!(out.target, None); +} + #[test] fn rejects_escaping_output_before_writing_anything() { let nonce = SystemTime::now() diff --git a/crates/openapi-codegen/tests/it/target_profile_matrix.rs b/crates/openapi-codegen/tests/it/target_profile_matrix.rs index 534d6478..ef13a6f7 100644 --- a/crates/openapi-codegen/tests/it/target_profile_matrix.rs +++ b/crates/openapi-codegen/tests/it/target_profile_matrix.rs @@ -1,7 +1,7 @@ // use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -74,10 +74,16 @@ fn assert_success(output: Output, description: &str) { ); } -fn expected_manifest(requirements: TargetRequirements) -> GenerationManifest { +fn expected_manifest( + requirements: TargetRequirements, + output: &GeneratedOutput, +) -> GenerationManifest { GenerationManifest { - schema_version: 1, + schema_version: 2, generator: "openapi-codegen".to_string(), + generator_version: env!("CARGO_PKG_VERSION").to_string(), + openapi_sha256: output.openapi_sha256.clone().unwrap_or_default(), + service_compatibility: output.service_compatibility.clone(), compiler: requirements.compiler.to_string(), target: requirements.target.to_string(), language: requirements.language.id().to_string(), @@ -95,7 +101,7 @@ fn expected_manifest(requirements: TargetRequirements) -> GenerationManifest { } } -fn assert_materialized_manifest(output: &GeneratedOutput, out_dir: &PathBuf) -> GenerationManifest { +fn assert_materialized_manifest(output: &GeneratedOutput, out_dir: &Path) -> GenerationManifest { output .write_to_dir(out_dir) .expect("materialize targeted generated output"); @@ -104,7 +110,7 @@ fn assert_materialized_manifest(output: &GeneratedOutput, out_dir: &PathBuf) -> serde_json::from_slice(&bytes).expect("parse target sidecar"); assert_eq!( manifest, - expected_manifest(output.requirements.expect("target requirements")) + expected_manifest(output.requirements.expect("target requirements"), output) ); manifest }