Skip to content
Open
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: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/openapi-codegen/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions crates/openapi-codegen/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion crates/openapi-codegen/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
90 changes: 52 additions & 38 deletions crates/openapi-codegen/src/application/generate.rs
Original file line number Diff line number Diff line change
@@ -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<GeneratedOutput> {
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
Expand All @@ -24,20 +39,17 @@ pub fn generate_with_file_bearer_auth(
opts: &GenOptions,
auth: &FileBearerAuth,
) -> Result<GeneratedOutput> {
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
Expand All @@ -48,18 +60,17 @@ pub fn generate_for_target(
opts: &GenOptions,
target: TargetProfile,
) -> Result<GeneratedOutput> {
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<()> {
Expand Down Expand Up @@ -91,16 +102,19 @@ pub fn generate_for_target_with_file_bearer_auth(
target: TargetProfile,
auth: &FileBearerAuth,
) -> Result<GeneratedOutput> {
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)
}
3 changes: 2 additions & 1 deletion crates/openapi-codegen/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/openapi-codegen/src/domain/generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
40 changes: 40 additions & 0 deletions crates/openapi-codegen/src/domain/generation/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub enum HttpClient {
pub struct GenOptions {
lang: Lang,
target: Option<TargetProfile>,
service_compatibility: Option<String>,
spec_path: PathBuf,
out_dir: PathBuf,
client_name: String,
Expand All @@ -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(),
Expand All @@ -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<String>) -> 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<String>,
) -> Result<Self, super::ServiceCompatibilityError> {
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;
Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion crates/openapi-codegen/src/domain/generation/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub struct GeneratedOutput {
pub target: Option<TargetProfile>,
/// The minimum requirements for consuming explicitly targeted `files`.
pub requirements: Option<TargetRequirements>,
pub openapi_sha256: Option<String>,
pub service_compatibility: Option<String>,
}

/// Sidecar filename emitted with explicitly targeted generated clients.
Expand All @@ -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<String>,
pub compiler: String,
pub target: String,
pub language: String,
Expand All @@ -43,6 +48,8 @@ impl GeneratedOutput {
files,
target: None,
requirements: None,
openapi_sha256: None,
service_compatibility: None,
}
}

Expand All @@ -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<GenerationManifest> {
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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}
2 changes: 1 addition & 1 deletion crates/openapi-codegen/src/domain/target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
5 changes: 4 additions & 1 deletion crates/openapi-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Loading
Loading