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
6 changes: 6 additions & 0 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,27 @@ when correcting output that was wrong or incomplete on the wire.

### Fixed

- Properties that are both `required` and nullable via OpenAPI 3.1's
`type: ["X", "null"]` now generate `Option<T>` instead of a bare `T`, in plain
object schemas and in `allOf`-composed ones alike. Previously such a client
compiled and then failed to deserialize the first real response containing
`null`. All three nullability spellings (`nullable: true`, the 3.1 type array,
and an `anyOf`/`oneOf` null branch) now route through one helper.
- Client operations whose only success content is `text/event-stream` return a
`futures_util::Stream` of bytes instead of `()`. They previously buffered the
response with `.text()`, which never returns on a live SSE stream and hung the
caller's task indefinitely.
- Generated clients default `base_url` to the document's `servers[0].url` when
configuration does not set one, so `HttpClient::new()` targets the real API
instead of an empty string. Explicit configuration still wins; relative and
templated server URLs are ignored.
- The `Default(..)` per-operation error variant is now constructed for responses
matched by the spec's `default` response. It was previously declared but
unreachable, so a typed `default` body still surfaced as `typed: None`.
- Specs with `multipart/form-data` operations now request reqwest's `multipart`
feature in `REQUIRED_DEPS.toml`. The feature was enabled for
`reqwest-middleware` but not for `reqwest` itself, so generated file-upload
clients failed to compile.
- Config-driven `server list` and `server add` now apply
`generator.schema_extensions`, so overlay-provided operations and SSE media
types match generation.
Expand Down
17 changes: 9 additions & 8 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1933,8 +1933,8 @@ impl SchemaAnalyzer {
};

let prop_details = prop_schema.details();
// Check for both explicit nullable and anyOf nullable patterns
let prop_nullable = prop_details.is_nullable() || prop_schema.is_nullable_pattern();
// Every nullability form, via one helper — see is_nullable_any.
let prop_nullable = prop_schema.is_nullable_any();
let prop_description = prop_details.description.clone();
let prop_default = prop_details.default.clone();

Expand Down Expand Up @@ -2506,12 +2506,13 @@ impl SchemaAnalyzer {
)?;
let prop_details = prop_schema.details();

// Bug openapi-generator-bgo: pick up 3.1-style nullability
// (anyOf/oneOf with a `type: null` branch) in addition to the
// 3.0 `nullable: true` keyword. Without this, properties merged
// through allOf composition lose their null-branch detection
// (real hit: OpenAI Response.incomplete_details).
let nullable = prop_details.is_nullable() || prop_schema.is_nullable_pattern();
// Properties merged through allOf composition must go through
// the same nullability check as plain object properties.
// Real hits: OpenAI Response.incomplete_details (anyOf-with-null,
// openapi-generator-bgo) and RunPod Pod.startedAt / Pod.template
// (3.1 type-array, openapi-generator-dsu) — the latter arrive
// as `null` from the live API for any pod that hasn't started.
let nullable = prop_schema.is_nullable_any();
merged_properties.insert(
prop_name.clone(),
PropertyInfo {
Expand Down
1 change: 1 addition & 0 deletions src/bin/openapi-to-rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ fn run_generate(args: GenerateArgs) -> Result<(), Box<dyn std::error::Error>> {
let spec_content = load_spec(&load_source)?;
let spec_value = parse_spec(&spec_content, &load_source)?;
let warning = openapi_to_rust::spec_source::validate_oas_document(&spec_value)?;
generator_config.apply_spec_server_default(&spec_value);
let mapper = openapi_to_rust::TypeMapper::new(generator_config.types.clone());
let mut analyzer = if generator_config.schema_extensions.is_empty() {
SchemaAnalyzer::with_type_mapper(spec_value, mapper)?
Expand Down
97 changes: 94 additions & 3 deletions src/client_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,20 @@ impl CodeGenerator {

/// Generate the constructor method
fn generate_constructor(&self, has_retry: bool, has_tracing: bool) -> TokenStream {
// Seed `base_url` from configuration rather than always starting empty.
// A client built with an empty base URL sends every request to a
// relative path and fails, so a user who set `[http_client] base_url`
// in their TOML — or whose spec declares `servers[0].url`, which the
// config layer resolves into the same field — previously had to repeat
// it via `with_base_url` or watch every call 404 (openapi-generator-igg).
let configured_base_url = self
.config()
.http_client_config
.as_ref()
.and_then(|http| http.base_url.as_deref())
.unwrap_or_default();
let default_base_url = quote! { #configured_base_url.to_string() };

let retry_param = if has_retry {
quote! { retry_config: Option<RetryConfig>, }
} else {
Expand Down Expand Up @@ -358,7 +372,7 @@ impl CodeGenerator {
let http_client = client_builder.build();

Self {
base_url: String::new(),
base_url: #default_base_url,
api_key: None,
http_client,
custom_headers: BTreeMap::new(),
Expand All @@ -382,7 +396,7 @@ impl CodeGenerator {
let http_client = client_builder.build();

Self {
base_url: String::new(),
base_url: #default_base_url,
api_key: None,
http_client,
custom_headers: BTreeMap::new(),
Expand Down Expand Up @@ -2106,11 +2120,30 @@ impl CodeGenerator {
let rust_type_name = self.to_rust_type_name(response_type);
let response_ident = syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site());
quote! { #response_ident }
} else if Self::returns_raw_event_stream(op) {
quote! { impl futures_util::Stream<Item = Result<bytes::Bytes, reqwest::Error>> }
} else {
quote! { () }
}
}

/// True when the operation's success response carries `text/event-stream`
/// and nothing this generator can model as a JSON body.
///
/// These previously generated `-> Result<(), _>` and then called
/// `response.text().await`, which on a live SSE stream never returns: the
/// caller's task deadlocks rather than erroring (openapi-generator-x9v).
/// The streaming signal was already detected in analysis and honored by the
/// server generator; only the client ignored it.
///
/// Operations declaring *both* a JSON body and `text/event-stream` keep
/// their JSON contract here — those are the `stream: true` style endpoints
/// covered by the explicit `[streaming]` configuration, and silently
/// changing their return type would break existing callers.
fn returns_raw_event_stream(op: &OperationInfo) -> bool {
op.supports_streaming
}

/// Generate error handling.
///
/// Always reads the response body to a string before attempting any typed
Expand Down Expand Up @@ -2148,6 +2181,35 @@ impl CodeGenerator {

let error_match_arms = self.generate_error_match_arms(op);

// Streaming success path: hand back the live byte stream instead of
// buffering it. Reading an SSE body to a string blocks until the server
// closes the connection, which is precisely what it will not do.
// The error path still buffers — an error response is finite.
if !has_response_body && Self::returns_raw_event_stream(op) {
return quote! {
let status = response.status();
let status_code = status.as_u16();
let headers = response.headers().clone();

if status.is_success() {
Ok(response.bytes_stream())
} else {
let body_text = response.text().await
.map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?;
let typed: Option<#op_error_type>;
let parse_error: Option<String>;
#error_match_arms
Err(ApiOpError::Api(ApiError {
status: status_code,
headers,
body: body_text,
typed,
parse_error,
}))
}
};
}

quote! {
let status = response.status();
let status_code = status.as_u16();
Expand Down Expand Up @@ -2232,7 +2294,36 @@ impl CodeGenerator {
.iter()
.any(|(code, _)| !code.starts_with('2'));

let default_arm = if has_typed_enum {
// A spec-declared `default` response is the catch-all arm's payload
// type. Without this the generated enum carries a `Default(..)` variant
// that nothing ever constructs, so a response matched only by `default`
// — a perfectly parseable typed body — still surfaces as
// `typed: None` and callers fall back to raw strings
// (openapi-generator-nu7).
let default_payload = op
.response_schemas
.iter()
.find(|(code, _)| matches!(code.as_str(), "default" | "Default"))
.map(|(_, schema)| self.to_rust_type_name(schema));

let default_arm = if let Some(payload_ty_name) = default_payload {
let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site());
let enum_ident = self.op_error_enum_ident(op);
quote! {
_ => {
match serde_json::from_str::<#payload_ty>(&body_text) {
Ok(v) => {
typed = Some(#enum_ident::Default(v));
parse_error = None;
}
Err(e) => {
typed = None;
parse_error = Some(e.to_string());
}
}
}
}
} else if has_typed_enum {
quote! {
_ => {
typed = None;
Expand Down
45 changes: 45 additions & 0 deletions src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,51 @@ impl Default for GeneratorConfig {
}
}

impl GeneratorConfig {
/// Adopt the document's `servers[0].url` as the client's default base URL
/// when configuration didn't supply one.
///
/// The spec already states where the API lives; making every user restate
/// it in TOML (or discover at runtime that requests go nowhere) is friction
/// with no upside. Explicit configuration always wins.
///
/// Two server URLs are deliberately ignored: relative ones (`/v1`), which
/// are meaningless without an origin, and templated ones containing `{}`
/// server variables, which are not usable until substituted.
pub fn apply_spec_server_default(&mut self, spec: &serde_json::Value) {
let already_configured = self
.http_client_config
.as_ref()
.and_then(|http| http.base_url.as_deref())
.is_some_and(|url| !url.is_empty());
if already_configured {
return;
}

let Some(url) = spec
.pointer("/servers/0/url")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|url| !url.is_empty())
.filter(|url| !url.starts_with('/'))
.filter(|url| !url.contains('{'))
else {
return;
};

match self.http_client_config.as_mut() {
Some(http) => http.base_url = Some(url.to_string()),
None => {
self.http_client_config = Some(crate::http_config::HttpClientConfig {
base_url: Some(url.to_string()),
timeout_seconds: None,
default_headers: Default::default(),
})
}
}
}
}

pub fn default_type_mappings() -> BTreeMap<String, String> {
let mut mappings = BTreeMap::new();
mappings.insert("integer".to_string(), "i64".to_string());
Expand Down
17 changes: 17 additions & 0 deletions src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,23 @@ impl Schema {
}
}

/// True when the schema is nullable in any form OpenAPI allows:
/// 3.0's `nullable: true`, 3.1's `type: ["X", "null"]`, or an
/// `anyOf`/`oneOf` carrying a `null` branch.
///
/// Property nullability must be decided through this, not through any
/// single one of the three checks. Each form was added separately and each
/// time a call site was missed: `nullable: true` first, then the
/// `anyOf`-with-null shape (openapi-generator-bgo), leaving the 3.1
/// canonical type-array form unhandled on properties
/// (openapi-generator-dsu) — which silently generated non-`Option` fields
/// for values the API really does send as `null`.
pub fn is_nullable_any(&self) -> bool {
self.details().is_nullable()
|| self.type_array_contains_null()
|| self.is_nullable_pattern()
}

/// Get schema details
pub fn details(&self) -> &SchemaDetails {
static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(SchemaDetails::default);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
source: src/test_helpers.rs
expression: "&generated_code"
---
//! Generated types from OpenAPI specification
//!
//! This file contains all the generated types for the API.
//! Do not edit manually - regenerate using the appropriate script.
#![allow(clippy::large_enum_variant)]
#![allow(clippy::format_in_format_args)]
#![allow(clippy::let_unit_value)]
#![allow(unreachable_patterns)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Mixed {
#[serde(skip_serializing_if = "Option::is_none")]
pub any_of: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legacy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub type_array: Option<String>,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
source: src/test_helpers.rs
expression: "&generated_code"
---
//! Generated types from OpenAPI specification
//!
//! This file contains all the generated types for the API.
//! Do not edit manually - regenerate using the appropriate script.
#![allow(clippy::large_enum_variant)]
#![allow(clippy::format_in_format_args)]
#![allow(clippy::let_unit_value)]
#![allow(unreachable_patterns)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GpuType {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pool: Option<String>,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
source: src/test_helpers.rs
expression: "&generated_code"
---
//! Generated types from OpenAPI specification
//!
//! This file contains all the generated types for the API.
//! Do not edit manually - regenerate using the appropriate script.
#![allow(clippy::large_enum_variant)]
#![allow(clippy::format_in_format_args)]
#![allow(clippy::let_unit_value)]
#![allow(unreachable_patterns)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Pod {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(rename = "startedAt", skip_serializing_if = "Option::is_none")]
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ContainerConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
}
14 changes: 14 additions & 0 deletions src/type_mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,20 @@ pub fn collect_generated_dep_requirements<'a>(
if uses(".json(&") {
features.push("json");
}
// Multipart operations reference `reqwest::multipart::Form` directly.
// reqwest is pinned with default-features off, so the feature has to be
// requested explicitly or the generated client fails to compile with
// "cannot find `multipart` in `reqwest`". The reqwest-middleware side
// of this was already handled below; reqwest itself was missed
// (openapi-generator-upz). Hits any spec with a file upload.
if uses("reqwest::multipart") {
features.push("multipart");
}
// `Response::bytes_stream()`, used by generated SSE operations, is
// gated behind reqwest's `stream` feature.
if uses(".bytes_stream()") {
features.push("stream");
}
dependencies.push(
DepRequirement::new("reqwest", "0.12")
.without_default_features()
Expand Down
5 changes: 5 additions & 0 deletions tests/generation_requirements_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ fn every_generation_mode_compiles_from_its_exact_dependency_fragment() {
"base64",
"bytes",
"chrono",
// The fixture's streaming operation declares only
// `text/event-stream`, so its client method now returns a
// `futures_util::Stream` of bytes rather than `()`
// (openapi-generator-x9v).
"futures-util",
"reqwest",
"reqwest-middleware",
"serde",
Expand Down
Loading
Loading