diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d2fe5c..8a89c263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ when correcting output that was wrong or incomplete on the wire. so existing validation error links resolve (#109). Website builds now check that every emitted problem URI has a page. Document response middleware for customizing the existing public validation error profile. +- An object schema whose `allOf` holds only `if`/`then`/`else` rules now + generates as its struct instead of `serde_json::Value`, so a discriminated + `oneOf` with it as a branch stays tagged (#111). Conditional-only members are + value rules and no longer shape the composed type, and an owner's own + `properties` are kept when its `allOf` has a single `$ref` or untyped member. +- Error variants that wrap a source with `#[from]` no longer repeat it in their + message, so error-chain reporters print each cause once (#112). This covers + generated `HttpError::{Network, Middleware}` and `StreamingError::Json`, plus + this crate's `GeneratorError`, `HttpError`, and `server` edit errors. Use + `std::error::Error::source()` (or a chain reporter such as `anyhow`'s `{:#}`) + to show the cause; the CLI and playground now render the full chain. ## [0.23.0] - 2026-10-25 diff --git a/examples/generated/REQUIRED_DEPS.toml b/examples/generated/REQUIRED_DEPS.toml new file mode 100644 index 00000000..6b3e1a17 --- /dev/null +++ b/examples/generated/REQUIRED_DEPS.toml @@ -0,0 +1,17 @@ +# Generated by openapi-to-rust. +# Complete direct dependencies for this generated output. +# Append this fragment to the consuming crate's Cargo.toml, or +# merge it with existing dependency and feature sections. + +[dependencies] +futures-util = "0.3" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "stream"] } +reqwest-middleware = "0.5" +reqwest-retry = "0.9" +reqwest-tracing = "0.7" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.4", features = ["wasm_js"] } diff --git a/examples/generated/client.rs b/examples/generated/client.rs index 245e0fda..ca2b52a1 100644 --- a/examples/generated/client.rs +++ b/examples/generated/client.rs @@ -6,8 +6,32 @@ #![allow(clippy::let_unit_value)] use super::types::*; use thiserror::Error; -/// Transport-level errors: failures where we never received an -/// inspectable HTTP response from the server. +/// The generated validation-problem profile based on RFC 9457. +/// The distinctive namespace avoids collisions with user schemas. +pub mod openapi_to_rust_problem { + #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] + pub struct ProblemDetails { + #[serde(rename = "type")] + pub type_uri: String, + pub title: String, + pub status: u16, + pub code: String, + #[serde(default)] + pub errors: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instance: Option, + } + #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] + pub struct InvalidParameter { + pub code: String, + pub location: String, + pub message: String, + } +} +/// Transport-level errors: failures where no safely inspectable +/// HTTP response is available to the caller. /// /// HTTP responses with non-2xx status codes are surfaced as /// [`ApiError`] inside [`ApiOpError::Api`], not here, so callers can @@ -16,10 +40,10 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum HttpError { /// Network or connection error (from reqwest) - #[error("Network error: {0}")] + #[error("Network error")] Network(#[from] reqwest::Error), /// Middleware error (from reqwest-middleware) - #[error("Middleware error: {0}")] + #[error("Middleware error")] Middleware(#[from] reqwest_middleware::Error), /// Request serialization error #[error("Failed to serialize request: {0}")] @@ -30,6 +54,9 @@ pub enum HttpError { /// Request timeout #[error("Request timeout")] Timeout, + /// A response body exceeded the configured in-memory limit + #[error("Response body exceeded configured limit of {limit} bytes")] + ResponseTooLarge { limit: usize }, /// Invalid configuration #[error("Configuration error: {0}")] Config(String), @@ -52,18 +79,35 @@ impl HttpError { /// /// Includes both non-2xx responses and 2xx responses whose body /// failed to deserialize into the expected success type. `status`, -/// `headers`, and `body` are always populated so callers can -/// inspect what the server sent without modifying the generated -/// code. `typed` carries the parsed per-operation error variant -/// when the body matched a declared schema. +/// `headers`, and `raw_body` preserve what the server actually sent, +/// while `body` is a convenient lossy UTF-8 rendering. `typed` +/// carries the parsed per-operation error variant +/// when the body matched a declared schema. Formatting the error +/// limits only the displayed body preview; the public fields +/// retain the complete response and parsing details. #[derive(Debug, Clone)] pub struct ApiError { pub status: u16, pub headers: reqwest::header::HeaderMap, pub body: String, + /// Exact response bytes before lossy UTF-8 conversion. + pub raw_body: Vec, pub typed: Option, pub parse_error: Option, } +const API_ERROR_BODY_DISPLAY_LIMIT: usize = 500; +const API_ERROR_BODY_TRUNCATION_MARKER: &str = "... [truncated]"; +fn display_api_error_body(body: &str) -> std::borrow::Cow<'_, str> { + let Some((end, _)) = body.char_indices().nth(API_ERROR_BODY_DISPLAY_LIMIT) else { + return std::borrow::Cow::Borrowed(body); + }; + let mut displayed = String::with_capacity( + end + API_ERROR_BODY_TRUNCATION_MARKER.len(), + ); + displayed.push_str(&body[..end]); + displayed.push_str(API_ERROR_BODY_TRUNCATION_MARKER); + std::borrow::Cow::Owned(displayed) +} impl ApiError { pub fn is_client_error(&self) -> bool { (400..500).contains(&self.status) @@ -76,10 +120,40 @@ impl ApiError { pub fn is_retryable(&self) -> bool { matches!(self.status, 429 | 500 | 502 | 503 | 504) } + /// Decode the generated RFC 9457 validation-problem profile + /// without replacing a documented per-operation error in `typed`. + /// + /// Returns `None` unless the response's `Content-Type` is + /// `application/problem+json`, which is how RFC 9457 identifies + /// a problem document. Most third-party APIs return their + /// errors as plain `application/json`, so this yields `None` + /// against them by design — use `typed` for a documented + /// per-operation error body, or `body` for the raw payload. + /// Servers generated by this tool always emit the problem + /// media type, so this succeeds against them. + pub fn problem_details(&self) -> Option { + let content_type = self + .headers + .get(reqwest::header::CONTENT_TYPE)? + .to_str() + .ok()?; + let media_type = content_type.split(';').next()?.trim(); + if !media_type.eq_ignore_ascii_case("application/problem+json") { + return None; + } + serde_json::from_str(&self.body).ok() + } } impl std::fmt::Display for ApiError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "API error {}: {}", self.status, self.body) + write!(f, "API error {}: {}", self.status, display_api_error_body(& self.body))?; + if let Some(typed) = &self.typed { + write!(f, "; typed: {typed:?}")?; + } + if let Some(parse_error) = &self.parse_error { + write!(f, "; parse error: {parse_error}")?; + } + Ok(()) } } impl std::error::Error for ApiError {} @@ -143,6 +217,8 @@ impl Default for RetryConfig { } use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use std::collections::BTreeMap; +/// Default upper bound for any response body buffered in memory. +pub const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024; /// HTTP client for making API requests #[derive(Clone)] pub struct HttpClient { @@ -150,6 +226,26 @@ pub struct HttpClient { api_key: Option, http_client: ClientWithMiddleware, custom_headers: BTreeMap, + max_response_body_bytes: usize, +} +async fn __read_bounded_response_body( + response: reqwest::Response, + limit: usize, +) -> Result, HttpError> { + use futures_util::StreamExt; + let mut body = Vec::new(); + let mut stream = std::pin::pin!(response.bytes_stream()); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(HttpError::Network)?; + let next_len = body.len().checked_add(chunk.len()); + if next_len.is_none_or(|next_len| next_len > limit) { + return Err(HttpError::ResponseTooLarge { + limit, + }); + } + body.extend_from_slice(&chunk); + } + Ok(body) } impl HttpClient { /// Create a new HTTP client with default configuration @@ -158,8 +254,30 @@ impl HttpClient { } /// Create a new HTTP client with custom configuration pub fn with_config(retry_config: Option, enable_tracing: bool) -> Self { - let reqwest_client = reqwest::Client::new(); - let mut client_builder = ClientBuilder::new(reqwest_client); + Self::with_client_and_config( + reqwest::Client::new(), + retry_config, + enable_tracing, + ) + } + /// Create an HTTP client using a preconfigured reqwest client. + /// + /// Preserves the supplied client's transport settings (for example, + /// TLS identity, trusted roots, proxy, timeout, and default headers). + /// Uses the same middleware defaults as `new()`. + pub fn with_client(client: reqwest::Client) -> Self { + Self::with_client_and_config(client, None, true) + } + /// Create an HTTP client using a preconfigured reqwest client + /// and explicit retry/tracing configuration. + /// + /// Wraps the supplied client with the generated middleware stack. + pub fn with_client_and_config( + client: reqwest::Client, + retry_config: Option, + enable_tracing: bool, + ) -> Self { + let mut client_builder = ClientBuilder::new(client); if enable_tracing { use reqwest_tracing::TracingMiddleware; client_builder = client_builder.with(TracingMiddleware::default()); @@ -179,10 +297,11 @@ impl HttpClient { } let http_client = client_builder.build(); Self { - base_url: String::new(), + base_url: "".to_string(), api_key: None, http_client, custom_headers: BTreeMap::new(), + max_response_body_bytes: 8388608usize, } } /// Set the base URL for all requests @@ -195,6 +314,11 @@ impl HttpClient { self.api_key = Some(api_key.into()); self } + /// Set the maximum number of response-body bytes buffered in memory. + pub fn with_max_response_body_bytes(mut self, limit: usize) -> Self { + self.max_response_body_bytes = limit; + self + } /// Add a custom header to all requests pub fn with_header( mut self, @@ -215,33 +339,55 @@ impl Default for HttpClient { Self::new() } } +fn __pct_encode_path_segment(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push_str(&format!("{:02X}", b)); + } + } + } + out +} impl HttpClient { - ///POST /todos + /// Create a new todo + /// + /// `POST /todos` pub async fn create_todo( &self, request: CreateTodoRequest, ) -> Result> { let request_url = format!("{}{}", self.base_url, "/todos"); - let mut req = self - .http_client - .post(request_url) + let mut req = self.http_client.post(request_url); + req = req .body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?) .header("content-type", "application/json"); if let Some(api_key) = &self.api_key { req = req.bearer_auth(api_key); } for (name, value) in &self.custom_headers { - req = req.header(name, value); + if !name.eq_ignore_ascii_case("accept") { + req = req.header(name, value); + } } + req = req.header(reqwest::header::ACCEPT, "application/json"); let response = req.send().await?; let status = response.status(); let status_code = status.as_u16(); let headers = response.headers().clone(); - let body_text = response - .text() - .await - .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?; - if status.is_success() { + let body_bytes = __read_bounded_response_body( + response, + self.max_response_body_bytes, + ) + .await?; + let raw_body = body_bytes; + let body_text = String::from_utf8_lossy(&raw_body).into_owned(); + if false || status_code == 201u16 { match serde_json::from_str(&body_text) { Ok(body) => Ok(body), Err(e) => { @@ -250,6 +396,7 @@ impl HttpClient { status: status_code, headers: headers, body: body_text, + raw_body, typed: None, parse_error: Some( format!("failed to deserialize 2xx response body: {}", e), @@ -258,6 +405,22 @@ impl HttpClient { ) } } + } else if status.is_success() { + Err( + ApiOpError::Api(ApiError { + status: status_code, + headers, + body: body_text, + raw_body, + typed: None, + parse_error: Some( + format!( + "unexpected successful status {}; generated return type selects `{}`", + status_code, "201", + ), + ), + }), + ) } else { let typed: Option; let parse_error: Option; @@ -280,19 +443,23 @@ impl HttpClient { status: status_code, headers, body: body_text, + raw_body, typed, parse_error, }), ) } } - ///DELETE /todos/{id} + /// Delete a todo + /// + /// `DELETE /todos/{id}` pub async fn delete_todo( &self, id: impl AsRef, ) -> Result<(), ApiOpError> { let request_url = format!( - "{}{}", self.base_url, format!("/todos/{}", id.as_ref()) + "{}{}", self.base_url, format!("/todos/{}", __pct_encode_path_segment(id + .as_ref())) ); let mut req = self.http_client.delete(request_url); if let Some(api_key) = &self.api_key { @@ -305,14 +472,34 @@ impl HttpClient { let status = response.status(); let status_code = status.as_u16(); let headers = response.headers().clone(); - let body_text = response - .text() - .await - .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?; - if status.is_success() { + let body_bytes = __read_bounded_response_body( + response, + self.max_response_body_bytes, + ) + .await?; + let raw_body = body_bytes; + let body_text = String::from_utf8_lossy(&raw_body).into_owned(); + if false || status_code == 204u16 { let _ = body_text; + let _ = raw_body; let _ = headers; Ok(()) + } else if status.is_success() { + Err( + ApiOpError::Api(ApiError { + status: status_code, + headers, + body: body_text, + raw_body, + typed: None, + parse_error: Some( + format!( + "unexpected successful status {}; generated return type selects `{}`", + status_code, "204", + ), + ), + }), + ) } else { let typed: Option; let parse_error: Option; @@ -335,36 +522,46 @@ impl HttpClient { status: status_code, headers, body: body_text, + raw_body, typed, parse_error, }), ) } } - ///GET /todos/{id} + /// Get a specific todo + /// + /// `GET /todos/{id}` pub async fn get_todo( &self, id: impl AsRef, ) -> Result> { let request_url = format!( - "{}{}", self.base_url, format!("/todos/{}", id.as_ref()) + "{}{}", self.base_url, format!("/todos/{}", __pct_encode_path_segment(id + .as_ref())) ); let mut req = self.http_client.get(request_url); if let Some(api_key) = &self.api_key { req = req.bearer_auth(api_key); } for (name, value) in &self.custom_headers { - req = req.header(name, value); + if !name.eq_ignore_ascii_case("accept") { + req = req.header(name, value); + } } + req = req.header(reqwest::header::ACCEPT, "application/json"); let response = req.send().await?; let status = response.status(); let status_code = status.as_u16(); let headers = response.headers().clone(); - let body_text = response - .text() - .await - .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?; - if status.is_success() { + let body_bytes = __read_bounded_response_body( + response, + self.max_response_body_bytes, + ) + .await?; + let raw_body = body_bytes; + let body_text = String::from_utf8_lossy(&raw_body).into_owned(); + if false || status_code == 200u16 { match serde_json::from_str(&body_text) { Ok(body) => Ok(body), Err(e) => { @@ -373,6 +570,7 @@ impl HttpClient { status: status_code, headers: headers, body: body_text, + raw_body, typed: None, parse_error: Some( format!("failed to deserialize 2xx response body: {}", e), @@ -381,6 +579,22 @@ impl HttpClient { ) } } + } else if status.is_success() { + Err( + ApiOpError::Api(ApiError { + status: status_code, + headers, + body: body_text, + raw_body, + typed: None, + parse_error: Some( + format!( + "unexpected successful status {}; generated return type selects `{}`", + status_code, "200", + ), + ), + }), + ) } else { let typed: Option; let parse_error: Option; @@ -403,13 +617,16 @@ impl HttpClient { status: status_code, headers, body: body_text, + raw_body, typed, parse_error, }), ) } } - ///GET /todos + /// List all todos + /// + /// `GET /todos` pub async fn list_todos( &self, ) -> Result> { @@ -419,17 +636,23 @@ impl HttpClient { req = req.bearer_auth(api_key); } for (name, value) in &self.custom_headers { - req = req.header(name, value); + if !name.eq_ignore_ascii_case("accept") { + req = req.header(name, value); + } } + req = req.header(reqwest::header::ACCEPT, "application/json"); let response = req.send().await?; let status = response.status(); let status_code = status.as_u16(); let headers = response.headers().clone(); - let body_text = response - .text() - .await - .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?; - if status.is_success() { + let body_bytes = __read_bounded_response_body( + response, + self.max_response_body_bytes, + ) + .await?; + let raw_body = body_bytes; + let body_text = String::from_utf8_lossy(&raw_body).into_owned(); + if false || status_code == 200u16 { match serde_json::from_str(&body_text) { Ok(body) => Ok(body), Err(e) => { @@ -438,6 +661,7 @@ impl HttpClient { status: status_code, headers: headers, body: body_text, + raw_body, typed: None, parse_error: Some( format!("failed to deserialize 2xx response body: {}", e), @@ -446,6 +670,22 @@ impl HttpClient { ) } } + } else if status.is_success() { + Err( + ApiOpError::Api(ApiError { + status: status_code, + headers, + body: body_text, + raw_body, + typed: None, + parse_error: Some( + format!( + "unexpected successful status {}; generated return type selects `{}`", + status_code, "200", + ), + ), + }), + ) } else { let typed: Option; let parse_error: Option; @@ -468,41 +708,50 @@ impl HttpClient { status: status_code, headers, body: body_text, + raw_body, typed, parse_error, }), ) } } - ///PUT /todos/{id} + /// Update a todo + /// + /// `PUT /todos/{id}` pub async fn update_todo( &self, id: impl AsRef, request: UpdateTodoRequest, ) -> Result> { let request_url = format!( - "{}{}", self.base_url, format!("/todos/{}", id.as_ref()) + "{}{}", self.base_url, format!("/todos/{}", __pct_encode_path_segment(id + .as_ref())) ); - let mut req = self - .http_client - .put(request_url) + let mut req = self.http_client.put(request_url); + req = req .body(serde_json::to_vec(&request).map_err(HttpError::serialization_error)?) .header("content-type", "application/json"); if let Some(api_key) = &self.api_key { req = req.bearer_auth(api_key); } for (name, value) in &self.custom_headers { - req = req.header(name, value); + if !name.eq_ignore_ascii_case("accept") { + req = req.header(name, value); + } } + req = req.header(reqwest::header::ACCEPT, "application/json"); let response = req.send().await?; let status = response.status(); let status_code = status.as_u16(); let headers = response.headers().clone(); - let body_text = response - .text() - .await - .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?; - if status.is_success() { + let body_bytes = __read_bounded_response_body( + response, + self.max_response_body_bytes, + ) + .await?; + let raw_body = body_bytes; + let body_text = String::from_utf8_lossy(&raw_body).into_owned(); + if false || status_code == 200u16 { match serde_json::from_str(&body_text) { Ok(body) => Ok(body), Err(e) => { @@ -511,6 +760,7 @@ impl HttpClient { status: status_code, headers: headers, body: body_text, + raw_body, typed: None, parse_error: Some( format!("failed to deserialize 2xx response body: {}", e), @@ -519,6 +769,22 @@ impl HttpClient { ) } } + } else if status.is_success() { + Err( + ApiOpError::Api(ApiError { + status: status_code, + headers, + body: body_text, + raw_body, + typed: None, + parse_error: Some( + format!( + "unexpected successful status {}; generated return type selects `{}`", + status_code, "200", + ), + ), + }), + ) } else { let typed: Option; let parse_error: Option; @@ -541,6 +807,7 @@ impl HttpClient { status: status_code, headers, body: body_text, + raw_body, typed, parse_error, }), diff --git a/examples/generated/mod.rs b/examples/generated/mod.rs index 113f3af3..47f62bb0 100644 --- a/examples/generated/mod.rs +++ b/examples/generated/mod.rs @@ -2,11 +2,15 @@ //! //! This module exports all generated API types and clients. //! Do not edit manually - regenerate using the appropriate script. +//! + +//! Configured `module_name` = `todo_api`. Mount this tree under your +//! preferred path, e.g. `pub mod todo_api;` in your crate root. #![allow(unused_imports)] -pub mod types; pub mod client; +pub mod types; -pub use types::*; pub use client::*; +pub use types::*; diff --git a/examples/generated/types.rs b/examples/generated/types.rs index abaf315c..9fd1356b 100644 --- a/examples/generated/types.rs +++ b/examples/generated/types.rs @@ -29,6 +29,40 @@ pub struct CreateTodoRequest { ///The todo item title pub title: String, } +impl CreateTodoRequest { + /// Construct this request with every required wire field. + pub fn new(title: String) -> Self { + Self { title, description: None } + } + /// Start a dependency-free builder with every required wire field. + pub fn builder(title: String) -> CreateTodoRequestBuilder { + CreateTodoRequestBuilder::new(title) + } +} +/// Dependency-free builder for [`#struct_name`]. +#[derive(Debug, Clone)] +#[must_use] +pub struct CreateTodoRequestBuilder { + value: CreateTodoRequest, +} +impl CreateTodoRequestBuilder { + /// Start a builder with every required wire field. + pub fn new(title: String) -> Self { + Self { + value: CreateTodoRequest::new(title), + } + } + #[doc = concat!("Set the optional `", "description", "` request field.")] + #[must_use] + pub fn description(mut self, description: String) -> Self { + self.value.description = Some(description); + self + } + /// Finish building the request model. + pub fn build(self) -> CreateTodoRequest { + self.value + } +} #[derive(Debug, Clone, Deserialize, Serialize)] pub struct UpdateTodoRequest { ///Whether the todo is completed @@ -39,3 +73,41 @@ pub struct UpdateTodoRequest { ///The todo item title pub title: String, } +impl UpdateTodoRequest { + /// Construct this request with every required wire field. + pub fn new(completed: bool, title: String) -> Self { + Self { + completed, + title, + description: None, + } + } + /// Start a dependency-free builder with every required wire field. + pub fn builder(completed: bool, title: String) -> UpdateTodoRequestBuilder { + UpdateTodoRequestBuilder::new(completed, title) + } +} +/// Dependency-free builder for [`#struct_name`]. +#[derive(Debug, Clone)] +#[must_use] +pub struct UpdateTodoRequestBuilder { + value: UpdateTodoRequest, +} +impl UpdateTodoRequestBuilder { + /// Start a builder with every required wire field. + pub fn new(completed: bool, title: String) -> Self { + Self { + value: UpdateTodoRequest::new(completed, title), + } + } + #[doc = concat!("Set the optional `", "description", "` request field.")] + #[must_use] + pub fn description(mut self, description: String) -> Self { + self.value.description = Some(description); + self + } + /// Finish building the request model. + pub fn build(self) -> UpdateTodoRequest { + self.value + } +} diff --git a/src/analysis.rs b/src/analysis.rs index 83adfa50..c843544d 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -1415,6 +1415,27 @@ fn schema_is_annotation_only(schema: &crate::openapi::Schema) -> bool { }) } +/// Whether an `allOf` member holds only `if`/`then`/`else` conditionals beside +/// annotations. Such a member constrains values (for example "if `zone` is +/// `utc`, `name` must end with `Z`") but never the wire shape, and generated +/// code does not validate, so it contributes nothing to the composed type. +fn schema_is_conditional_only(schema: &crate::openapi::Schema) -> bool { + serde_json::to_value(schema) + .ok() + .and_then(|value| value.as_object().cloned()) + .is_some_and(|object| { + object + .keys() + .any(|key| matches!(key.as_str(), "if" | "then" | "else")) + && object.keys().all(|key| { + matches!( + key.as_str(), + "if" | "then" | "else" | "default" | "nullable" + ) || is_annotation_keyword(key) + }) + }) +} + fn is_annotation_keyword(key: &str) -> bool { matches!( key, @@ -2437,15 +2458,32 @@ impl SchemaAnalyzer { return result; } // An allOf wrapper around one scalar/array carrier and neutral - // annotation siblings remains that non-object carrier. Other allOf - // shapes are object-like only when at least one meaningful member is. - if let Schema::AllOf { all_of, .. } = schema { + // annotation siblings remains that non-object carrier. An owner that + // declares `type: object` or its own properties is object-like itself. + // Other allOf shapes are object-like only when at least one meaningful + // (neither annotation- nor conditional-only) member is. + if let Schema::AllOf { + all_of, + schema_type, + details, + } = schema + { if Self::single_non_object_allof_carrier(all_of).is_some() { return false; } + if matches!(schema_type, Some(OpenApiSchemaType::Object)) + || details + .properties + .as_ref() + .is_some_and(|properties| !properties.is_empty()) + { + return true; + } return all_of .iter() - .filter(|member| !schema_is_annotation_only(member)) + .filter(|member| { + !schema_is_annotation_only(member) && !schema_is_conditional_only(member) + }) .any(|member| self.branch_resolves_to_object_inner(member, visited_refs)); } // A nested anyOf/oneOf can carry an object discriminator only when @@ -4338,20 +4376,43 @@ impl SchemaAnalyzer { let without_restated = self.without_restated_unions(all_of_schemas); let all_of_schemas = without_restated.as_deref().unwrap_or(all_of_schemas); - // After deduplication, only an owner with no assertions beside allOf - // can collapse to a bare member. Otherwise keep the merge path, retaining - // the owner's object shape and fields. - let preserve_owner_shape = without_restated.is_some() - && !serde_json::to_value(owner_schema) - .ok() - .and_then(|value| value.as_object().cloned()) - .is_some_and(|owner| { - owner.keys().all(|key| { - key == "allOf" - || is_annotation_keyword(key) - || matches!(key.as_str(), "default" | "nullable") - }) + // Conditional-only members are value rules, not shape. Leaving them in + // would make `allOf: [{if, then}]` look like a single untyped member + // and collapse the whole owner to `serde_json::Value`. + let without_conditionals = + all_of_schemas + .iter() + .any(schema_is_conditional_only) + .then(|| { + all_of_schemas + .iter() + .filter(|member| !schema_is_conditional_only(member)) + .cloned() + .collect::>() }); + let all_of_schemas = without_conditionals.as_deref().unwrap_or(all_of_schemas); + + // An owner that declares its own properties is an object no matter + // what its members are, so it must never collapse to a bare member. + // After deduplication, only an owner with no assertions beside allOf + // can collapse to a bare member. Otherwise keep the merge path, + // retaining the owner's object shape and fields. + let preserve_owner_shape = owner_schema + .details() + .properties + .as_ref() + .is_some_and(|properties| !properties.is_empty()) + || (without_restated.is_some() + && !serde_json::to_value(owner_schema) + .ok() + .and_then(|value| value.as_object().cloned()) + .is_some_and(|owner| { + owner.keys().all(|key| { + key == "allOf" + || is_annotation_keyword(key) + || matches!(key.as_str(), "default" | "nullable") + }) + })); // A scalar/array carrier intersected only with annotation-only // siblings keeps its wire type. This is deliberately narrower than @@ -4612,9 +4673,9 @@ impl SchemaAnalyzer { /// schemas, and multiple assertion-bearing members are intentionally not /// collapsed by this narrow recovery path. fn single_non_object_allof_carrier(all_of_schemas: &[Schema]) -> Option<&Schema> { - let mut meaningful = all_of_schemas - .iter() - .filter(|schema| !schema_is_annotation_only(schema)); + let mut meaningful = all_of_schemas.iter().filter(|schema| { + !schema_is_annotation_only(schema) && !schema_is_conditional_only(schema) + }); let carrier = meaningful.next()?; if meaningful.next().is_some() { return None; diff --git a/src/bin/openapi-to-rust.rs b/src/bin/openapi-to-rust.rs index 4e93ddd4..ba81e1ce 100644 --- a/src/bin/openapi-to-rust.rs +++ b/src/bin/openapi-to-rust.rs @@ -1,5 +1,6 @@ use clap::{Parser, Subcommand}; use openapi_to_rust::cli::load_spec; +use openapi_to_rust::error::error_chain; use openapi_to_rust::server::{ OperationIndex, Selector, edit::Editor as ServerEditor, @@ -182,15 +183,16 @@ fn main() { Commands::Generate { json: true, .. } | Commands::Init { json: true, .. } ); if let Err(e) = run(cli) { + let message = error_chain(e.as_ref()); if machine_readable_error { println!( "{}", - serde_json::json!({ "status": "error", "error": e.to_string() }) + serde_json::json!({ "status": "error", "error": message }) ); } else { // Use Display, not Debug, so thiserror messages render with // fuzzy-match suggestions instead of raw enum debug output. - eprintln!("Error: {e}"); + eprintln!("Error: {message}"); } std::process::exit(1); } @@ -209,7 +211,7 @@ fn run(cli: Cli) -> Result<(), Box> { } Err(e) => { eprintln!("❌ Configuration validation failed:"); - eprintln!("{}", e); + eprintln!("{}", error_chain(&e)); std::process::exit(1); } } diff --git a/src/error.rs b/src/error.rs index 1d5bcf00..47325ed8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,7 +2,7 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum GeneratorError { - #[error("Failed to parse OpenAPI spec: {0}")] + #[error("Failed to parse OpenAPI spec")] ParseError(#[from] serde_json::Error), /// Document-level parse failure located to the node that failed. Serde's @@ -24,7 +24,7 @@ pub enum GeneratorError { #[error("Code generation failed: {0}")] CodeGenError(String), - #[error("IO error: {0}")] + #[error("IO error")] IoError(#[from] std::io::Error), #[error("Invalid schema: {0}")] @@ -50,3 +50,20 @@ impl GeneratorError { } } } + +/// Render an error and its `source()` chain as `outer: inner: ...`. +/// +/// Error types in this crate (and the clients it generates) keep a wrapped +/// source out of their own message, so chain-aware reporters do not print the +/// cause twice. Callers that print only the outermost `Display` use this to +/// keep the cause. +pub fn error_chain(error: &(dyn std::error::Error + 'static)) -> String { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + message.push_str(": "); + message.push_str(&cause.to_string()); + source = cause.source(); + } + message +} diff --git a/src/generator.rs b/src/generator.rs index 2f560011..d6a3103d 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1820,11 +1820,11 @@ impl CodeGenerator { #[derive(Error, Debug)] pub enum HttpError { /// Network or connection error (from reqwest) - #[error("Network error: {0}")] + #[error("Network error")] Network(#[from] reqwest::Error), /// Middleware error (from reqwest-middleware) - #[error("Middleware error: {0}")] + #[error("Middleware error")] Middleware(#[from] reqwest_middleware::Error), /// Request serialization error @@ -5484,7 +5484,7 @@ impl CodeGenerator { Timeout(String), #[error("Response body exceeded configured limit of {limit} bytes")] ResponseTooLarge { limit: usize }, - #[error("JSON serialization/deserialization error: {0}")] + #[error("JSON serialization/deserialization error")] Json(#[from] serde_json::Error), #[error("Request error: {0}")] Request(reqwest::Error), diff --git a/src/http_error.rs b/src/http_error.rs index d043e8e1..daa5b0b1 100644 --- a/src/http_error.rs +++ b/src/http_error.rs @@ -151,7 +151,7 @@ pub mod openapi_to_rust_problem { #[derive(Error, Debug)] pub enum HttpError { /// Network or connection error - #[error("Network error: {0}")] + #[error("Network error")] Network(#[from] reqwest::Error), /// Request serialization error diff --git a/src/server/edit.rs b/src/server/edit.rs index 26dc92c2..20ecb575 100644 --- a/src/server/edit.rs +++ b/src/server/edit.rs @@ -7,19 +7,19 @@ use toml_edit::{Array, DocumentMut, Item, Table, Value}; #[derive(Debug, thiserror::Error)] pub enum EditError { - #[error("failed to read {path}: {source}")] + #[error("failed to read {path}")] Read { path: String, #[source] source: std::io::Error, }, - #[error("failed to write {path}: {source}")] + #[error("failed to write {path}")] Write { path: String, #[source] source: std::io::Error, }, - #[error("config is not valid TOML: {0}")] + #[error("config is not valid TOML")] Parse(#[from] toml_edit::TomlError), #[error("[server].operations exists but is not an array of strings — refusing to edit")] NotAnArray, diff --git a/tests/allof_conditional_member_test.rs b/tests/allof_conditional_member_test.rs new file mode 100644 index 00000000..3c2889ad --- /dev/null +++ b/tests/allof_conditional_member_test.rs @@ -0,0 +1,182 @@ +//! An `allOf` member holding only `if`/`then`/`else` rules constrains values, +//! not shape, and an owner's own `properties` always make it an object +//! (gpu-cli/openapi-to-rust#111). + +use openapi_to_rust::analysis::SchemaType; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; + +fn utc_name_rule() -> Value { + json!({ + "if": { "properties": { "zone": { "const": "utc" } }, "required": ["zone"] }, + "then": { "properties": { "name": { "pattern": "Z$" } } } + }) +} + +fn spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "conditional allOf members", "version": "1" }, + "paths": {}, + "components": { + "schemas": { + "Pet": { + "oneOf": [ + { "$ref": "#/components/schemas/Cat" }, + { "$ref": "#/components/schemas/Dog" } + ], + "discriminator": { "propertyName": "kind" } + }, + "Cat": { + "type": "object", + "required": ["kind", "name"], + "properties": { + "kind": { "type": "string", "const": "Cat" }, + "name": { "type": "string" }, + "zone": { "type": "string" } + }, + "allOf": [utc_name_rule()] + }, + "Dog": { + "type": "object", + "required": ["kind", "name"], + "properties": { + "kind": { "type": "string", "const": "Dog" }, + "name": { "type": "string" } + } + }, + "InlinePet": { + "oneOf": [ + { + "properties": { + "kind": { "type": "string", "const": "Bird" }, + "wings": { "type": "integer" } + }, + "required": ["kind"], + "allOf": [{ "if": { "required": ["wings"] }, "then": { "required": ["kind"] } }] + }, + { "$ref": "#/components/schemas/Dog" } + ], + "discriminator": { "propertyName": "kind" } + }, + "Base": { + "type": "object", + "required": ["id"], + "properties": { "id": { "type": "string" } } + }, + "ChildWithOwnProperties": { + "type": "object", + "required": ["extra"], + "properties": { "extra": { "type": "string" } }, + "allOf": [{ "$ref": "#/components/schemas/Base" }] + }, + "OwnPropertiesWithRequiredMember": { + "type": "object", + "properties": { "a": { "type": "string" } }, + "allOf": [{ "required": ["a"] }] + }, + "BaseWithRule": { + "allOf": [ + { "$ref": "#/components/schemas/Base" }, + { "if": { "required": ["id"] }, "else": { "description": "unused" } } + ] + } + } + } + }) +} + +fn analyze() -> openapi_to_rust::SchemaAnalysis { + SchemaAnalyzer::new(spec()) + .expect("spec should parse") + .analyze() + .expect("spec should analyze") +} + +fn object_fields(analysis: &openapi_to_rust::SchemaAnalysis, name: &str) -> Vec { + let SchemaType::Object { properties, .. } = &analysis.schemas[name].schema_type else { + panic!( + "{name} should be an object, got {:?}", + analysis.schemas[name].schema_type + ); + }; + properties.keys().cloned().collect() +} + +#[test] +fn conditional_only_member_keeps_the_owner_object() { + let analysis = analyze(); + assert_eq!(object_fields(&analysis, "Cat"), ["kind", "name", "zone"]); +} + +#[test] +fn discriminated_union_with_conditional_branch_stays_tagged() { + let analysis = analyze(); + for union in ["Pet", "InlinePet"] { + assert!( + matches!( + analysis.schemas[union].schema_type, + SchemaType::DiscriminatedUnion { ref discriminator_field, .. } + if discriminator_field == "kind" + ), + "{union} should stay discriminated on `kind`, got {:?}", + analysis.schemas[union].schema_type + ); + } +} + +#[test] +fn owner_properties_survive_single_member_allof() { + let analysis = analyze(); + assert_eq!( + object_fields(&analysis, "ChildWithOwnProperties"), + ["extra", "id"] + ); + assert_eq!( + object_fields(&analysis, "OwnPropertiesWithRequiredMember"), + ["a"] + ); +} + +#[test] +fn reference_with_conditional_sibling_is_an_alias() { + let analysis = analyze(); + assert!( + matches!( + analysis.schemas["BaseWithRule"].schema_type, + SchemaType::Reference { ref target } if target == "Base" + ), + "BaseWithRule should alias Base, got {:?}", + analysis.schemas["BaseWithRule"].schema_type + ); +} + +#[test] +fn generated_code_matches_schema_without_the_conditional() { + let generate = |spec: Value| { + let mut analysis = SchemaAnalyzer::new(spec) + .expect("spec should parse") + .analyze() + .expect("spec should analyze"); + CodeGenerator::new(GeneratorConfig { + module_name: "conditional_allof".into(), + enable_async_client: false, + ..Default::default() + }) + .generate(&mut analysis) + .expect("spec should generate") + }; + + let with_rule = generate(spec()); + let mut without_rule = spec(); + without_rule["components"]["schemas"]["Cat"] + .as_object_mut() + .expect("Cat is an object") + .remove("allOf"); + + assert!( + !with_rule.contains("pub type Cat = serde_json::Value"), + "Cat must not become opaque. Code:\n{with_rule}" + ); + assert_eq!(with_rule, generate(without_rule)); +} diff --git a/tests/corpus-manifest.txt b/tests/corpus-manifest.txt index eda88721..4f7cb2d7 100644 --- a/tests/corpus-manifest.txt +++ b/tests/corpus-manifest.txt @@ -11,228 +11,228 @@ # # columns: path bytes lines sha256[0:16] anthropic/REQUIRED_DEPS.toml 669 16 a7f60b37a7334009 -anthropic/client.rs 257995 6658 1a0963212815d979 +anthropic/client.rs 257985 6658 182418a5e3f3eb77 anthropic/mod.rs 444 17 5214bdbc37b918db anthropic/types.rs 4213874 87180 b24291c2719554fe arcade/REQUIRED_DEPS.toml 576 14 9228e95d38f5078e -arcade/client.rs 344609 8860 438861bb6aabdea5 +arcade/client.rs 344599 8860 6b95510873707955 arcade/mod.rs 438 17 b55cb3acf5fdbd02 arcade/types.rs 105165 2609 24d1eb8b7d02cb60 asana/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a -asana/client.rs 1949308 49156 0ef46a1d0f0922ff +asana/client.rs 1949298 49156 18311810c056dc11 asana/mod.rs 436 17 2390079a5e4e9f74 asana/types.rs 620104 13501 2f2baf1c317423d2 box/REQUIRED_DEPS.toml 771 18 5dbdfe553bf9dbeb -box/client.rs 1715965 44542 109291dec16f9658 +box/client.rs 1715955 44542 7a19c4085ffb7877 box/mod.rs 432 17 b40589a066c4b98b box/types.rs 1282705 33289 80cc9f769de77566 browserbase/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a -browserbase/client.rs 101008 2735 e07646ed5f69a110 +browserbase/client.rs 100998 2735 57549c8d858f5499 browserbase/mod.rs 448 17 7e721aa1bb802e26 browserbase/types.rs 51122 1293 43e2e11ccdc560c8 cal-com/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee -cal-com/client.rs 1281747 33389 b7c86ce9798cecda +cal-com/client.rs 1281737 33389 eaf8588aaf562475 cal-com/mod.rs 440 17 05fb649563c82805 cal-com/types.rs 1869078 47094 afddc8676879915b cartesia/REQUIRED_DEPS.toml 669 16 a7f60b37a7334009 -cartesia/client.rs 304892 8164 48e2b3849bde0bf8 +cartesia/client.rs 304882 8164 1a599ad5d9359408 cartesia/mod.rs 442 17 3c5001b7641597a2 cartesia/types.rs 232837 6363 7fa52e930c840933 cerebras/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d -cerebras/client.rs 42274 1129 d6c9699f04c45564 +cerebras/client.rs 42264 1129 6f70f41e2bbb56a0 cerebras/mod.rs 442 17 71f9d1b20905ad16 cerebras/types.rs 231216 5923 aad1193fbff6a57a circleci/REQUIRED_DEPS.toml 626 15 082a47f05075776b -circleci/client.rs 626997 16248 8e903224f1455d23 +circleci/client.rs 626987 16248 498d484de1334cac circleci/mod.rs 442 17 7a8b5691342a9d62 circleci/types.rs 384614 10072 6f277085e1ad9894 cloudflare/REQUIRED_DEPS.toml 787 19 09cccc57db93029b -cloudflare/client.rs 14164566 369093 263c089be637fd2b +cloudflare/client.rs 14164556 369093 b5f1af9ee7026a1c cloudflare/mod.rs 446 17 ce0e733f646147da -cloudflare/types.rs 16786215 390040 49b29a93e8d06f10 +cloudflare/types.rs 16812635 390624 6a9ce2342d8c060d coda/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 -coda/client.rs 848417 21853 bfee52292599720a +coda/client.rs 848407 21853 142d8fdac7b1912f coda/mod.rs 434 17 4e8b99caec52b623 coda/types.rs 2006132 42320 45d0edc024fb479c coingecko/REQUIRED_DEPS.toml 579 14 9277be96334b3809 -coingecko/client.rs 447719 12342 fa868bb3e15d0f92 +coingecko/client.rs 447709 12342 5ea00531314d9208 coingecko/mod.rs 444 17 0059623eabdef914 coingecko/types.rs 278768 6614 eb3ec8a86a40b025 datadog-v2/REQUIRED_DEPS.toml 700 16 e697f0e5afafb53b -datadog-v2/client.rs 5249848 135867 92999ee21ce1d815 +datadog-v2/client.rs 5249838 135867 49de5ff4d7143bca datadog-v2/mod.rs 446 17 56e45841c9753096 datadog-v2/types.rs 3898073 98372 b811070ed5b3631c digitalocean/REQUIRED_DEPS.toml 736 18 21727a31b033ca36 -digitalocean/client.rs 3864705 99379 8cf26dd8d4c0fb0d +digitalocean/client.rs 3864695 99379 01e255bab48bafad digitalocean/mod.rs 450 17 3b99e8d4d3161f03 digitalocean/types.rs 1548368 36436 3df74aca9b80384a discord/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a -discord/client.rs 1083060 28250 4343e114f37b5cc0 +discord/client.rs 1083050 28250 6541685f9f55d142 discord/mod.rs 440 17 1d9f7eee6c834b1c discord/types.rs 1843460 46571 63bbe5e6b4fc30d4 gcore/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 -gcore/client.rs 5203435 137249 c13ede4b4c1e9232 +gcore/client.rs 5203425 137249 7f09eae1e158175f gcore/mod.rs 436 17 d5c05d8821f23bcb -gcore/types.rs 6017263 140235 04b835bc308167b6 +gcore/types.rs 6079814 141627 b58d75be0eb68894 github/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee -github/client.rs 5855807 148083 8394075219b48dc2 +github/client.rs 5855797 148083 9861e9770b41f552 github/mod.rs 438 17 ac4d40f51fb06aa3 github/types.rs 8075234 216344 42b90f805a994a04 gitpod/REQUIRED_DEPS.toml 736 18 21727a31b033ca36 -gitpod/client.rs 1636152 43882 780b49ccf324cf91 +gitpod/client.rs 1636142 43882 c32d225a6d8f3591 gitpod/mod.rs 438 17 516880570ce5294f gitpod/types.rs 849831 19869 c2ab58c21a1573e6 google-calendar/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d -google-calendar/client.rs 134712 3571 29bb8923a43a59c4 +google-calendar/client.rs 134702 3571 6a2b175cf2e7a4fd google-calendar/mod.rs 456 17 c31832b05087624c google-calendar/types.rs 65472 1002 d25796f627e27a14 google-drive/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d -google-drive/client.rs 206521 5235 e5d1b18e24286930 +google-drive/client.rs 206511 5235 23ee768edc72772d google-drive/mod.rs 450 17 fdb9dcacd2e8d704 google-drive/types.rs 128460 1946 75bee84db99d2863 google-gmail/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d -google-gmail/client.rs 249067 6334 4b7c06e445198d61 +google-gmail/client.rs 249057 6334 9dbbae0237bdd58d google-gmail/mod.rs 450 17 ce127037493417e3 google-gmail/types.rs 65481 1177 aef0efe2b930b0dd google-tasks/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d -google-tasks/client.rs 53706 1423 9255b34f85ebe7b6 +google-tasks/client.rs 53696 1423 58bc24c2a33b5dda google-tasks/mod.rs 450 17 d25368315b206134 google-tasks/types.rs 10259 190 27998c94ecf7453b google-youtube/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d -google-youtube/client.rs 297190 7885 ed6b594437424619 +google-youtube/client.rs 297180 7885 dbf3a8e5208fe1ee google-youtube/mod.rs 454 17 dc6e7f7cef938c3e google-youtube/types.rs 397395 9292 4b91fb45be6db00a grafana/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee -grafana/client.rs 1890893 48892 7dd43a397b4288dd +grafana/client.rs 1890883 48892 ab4dba53d5377006 grafana/mod.rs 440 17 7b59b00331edb605 grafana/types.rs 391878 9248 e82c5cd4fb60daf2 groq/REQUIRED_DEPS.toml 630 15 7935666e65c0812e -groq/client.rs 122999 3229 c3f3662c34198be6 +groq/client.rs 122989 3229 4b6511c77a0d46fc groq/mod.rs 434 17 72fd6d54577d5597 groq/types.rs 390796 9825 b520125af19ef3c2 imagekit/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a -imagekit/client.rs 479594 12038 f04970a25927e9ac +imagekit/client.rs 479584 12038 d4048407b95dac79 imagekit/mod.rs 442 17 40484a363f5391c3 imagekit/types.rs 723243 15973 afb3e7f9514ddcd2 increase/REQUIRED_DEPS.toml 653 15 715b930adfa76491 -increase/client.rs 1285612 32597 acc607cc267f5041 +increase/client.rs 1285602 32597 fa904efe7349ecb5 increase/mod.rs 442 17 f6131a8548853a65 increase/types.rs 2545038 56919 be14c4c9967d53f2 knocklabs/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 -knocklabs/client.rs 477456 12560 920e3708f3bea84a +knocklabs/client.rs 477446 12560 8296d295ba816848 knocklabs/mod.rs 444 17 7822049ab73a5ed0 knocklabs/types.rs 325347 8096 d474ca0437a7852e langsmith/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 -langsmith/client.rs 2215624 57541 ac646f847b255531 +langsmith/client.rs 2215614 57541 85d7c1ad3dc3dbef langsmith/mod.rs 444 17 eb5007d340d34a8e langsmith/types.rs 1187951 33186 eaa138acef525f44 launchdarkly/REQUIRED_DEPS.toml 700 16 e697f0e5afafb53b -launchdarkly/client.rs 2526343 64289 bd3715fb5352d610 +launchdarkly/client.rs 2526333 64289 8c6c1dbd877401c6 launchdarkly/mod.rs 450 17 d4ed5579304ccd39 launchdarkly/types.rs 744704 18541 d8d6d99beba974d7 letta/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 -letta/client.rs 1513870 40469 f07782a7cb0be7d1 +letta/client.rs 1513860 40469 3d63dd57797a1ac8 letta/mod.rs 436 17 5cc3226a7c317d2a letta/types.rs 6956814 141666 9432028d9044d99f lithic/REQUIRED_DEPS.toml 688 17 9c8fe0ad1f161983 -lithic/client.rs 1352619 35374 eab34c99b623135f +lithic/client.rs 1352609 35374 544327ae7cfd5ea3 lithic/mod.rs 438 17 eba70abf1f172ff1 -lithic/types.rs 1681233 40761 e7fed374a5346f58 +lithic/types.rs 1685596 40875 d42bbfef5c71eeaa luma/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 -luma/client.rs 60243 1632 c9109a5ee8be703c +luma/client.rs 60233 1632 f7e33dbd55603173 luma/mod.rs 434 17 d85018b8cdd8f625 luma/types.rs 82848 2293 4dd743ed513956f8 meta-llama/REQUIRED_DEPS.toml 630 15 7935666e65c0812e -meta-llama/client.rs 60314 1574 6633c1fbae847508 +meta-llama/client.rs 60304 1574 7d5f1186463f658d meta-llama/mod.rs 446 17 6e485725d643b7c2 meta-llama/types.rs 98514 2444 a7397a14f584e6a5 microsoft-graph/REQUIRED_DEPS.toml 674 16 e864ec8321bc90dc -microsoft-graph/client.rs 110207817 2683076 6771c51338f41fbb +microsoft-graph/client.rs 110207807 2683076 6af84d52501ca227 microsoft-graph/mod.rs 456 17 b99d7781b4460b5d microsoft-graph/types.rs 15241503 390543 6a5e33709313a2b1 modern-treasury/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 -modern-treasury/client.rs 956604 25693 71b871cacde575be +modern-treasury/client.rs 956594 25693 3c94ded38d13ab6e modern-treasury/mod.rs 456 17 5f336a50419a709b modern-treasury/types.rs 1005951 26894 d2c5d35f9be7fa06 openai/REQUIRED_DEPS.toml 648 15 a3db003e7f3781e2 -openai/client.rs 1216761 32575 f20aeaf2619ce86a +openai/client.rs 1216751 32575 2383b5fd888b023c openai/mod.rs 438 17 59c0207149335235 openai/types.rs 6331103 142854 fd9462dcdcb7a655 opencode/REQUIRED_DEPS.toml 576 14 9228e95d38f5078e -opencode/client.rs 956674 25419 1da4e4960e3a3e4a +opencode/client.rs 956664 25419 4d0cee28129c45e2 opencode/mod.rs 442 17 98c109eb94b95dbf opencode/types.rs 6820664 146287 4e38f9d8cbd1024e pagerduty/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 -pagerduty/client.rs 2168880 58202 168bc9ef08e89604 +pagerduty/client.rs 2168870 58202 587b98f5f36bc531 pagerduty/mod.rs 444 17 2edbc04dfe892a50 pagerduty/types.rs 2058127 49376 e7800d4fc1435ac1 perplexity/REQUIRED_DEPS.toml 623 15 86a1a17387cf7108 -perplexity/client.rs 63557 1658 816858f76d63d419 +perplexity/client.rs 63547 1658 1fa403bd211638a7 perplexity/mod.rs 446 17 1f9103fe36426530 perplexity/types.rs 530449 12140 0456b118c51b5e00 resend/REQUIRED_DEPS.toml 674 16 e864ec8321bc90dc -resend/client.rs 270034 7316 5620efa9bb1bca52 +resend/client.rs 270024 7316 9c87d25a9481f94d resend/mod.rs 438 17 f7a8a77315259e44 resend/types.rs 133568 3604 b889f478d7735cfc retell/REQUIRED_DEPS.toml 602 14 044b5b6b31fc06a2 -retell/client.rs 490228 12733 9d0e3643a28c7f99 +retell/client.rs 490218 12733 4cd8092a85ddc031 retell/mod.rs 438 17 e5b430243537a3be retell/types.rs 1436327 37963 10fbc068fe24be61 runway/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 -runway/client.rs 194724 5327 1fa0c08b851f9d09 +runway/client.rs 194714 5327 bb8bfc38ac55782d runway/mod.rs 438 17 67ec5d69926d7742 runway/types.rs 963739 22398 587121e3f343ec8c sentry/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a -sentry/client.rs 991254 25612 f602e39e322cc36b +sentry/client.rs 991244 25612 80b96c3e43324fe2 sentry/mod.rs 438 17 2bdd2d5164ca444f sentry/types.rs 2426837 67442 ab34bfecb8faa9b6 snyk/REQUIRED_DEPS.toml 688 17 9c8fe0ad1f161983 -snyk/client.rs 1921768 49696 60f238e59edde838 +snyk/client.rs 1921758 49696 64d2f4f8181a8733 snyk/mod.rs 434 17 eec62ea6f01f40f6 snyk/types.rs 2776003 60800 f50e5cc2d997c5c4 spotify/REQUIRED_DEPS.toml 579 14 9277be96334b3809 -spotify/client.rs 565090 14862 aa2983753135152c +spotify/client.rs 565080 14862 d138488e267d556d spotify/mod.rs 440 17 954184fb7fd84586 spotify/types.rs 261914 6374 ec3db89e1975c7b0 storyden/REQUIRED_DEPS.toml 698 17 569479aa8197c912 -storyden/client.rs 1045264 27441 5b6c4e26e1789a08 +storyden/client.rs 1045254 27441 1224db8a5c585ad1 storyden/mod.rs 442 17 ff7a510276d50e59 storyden/types.rs 887093 20176 96f969322e485cda stripe/REQUIRED_DEPS.toml 627 15 265f2c061bc1155c -stripe/client.rs 2906096 75425 edd7b5741e02219d +stripe/client.rs 2906086 75425 a00c68d0e17a0358 stripe/mod.rs 438 17 cafa363545854cd3 stripe/types.rs 10237239 251262 76925f24489fc30c supabase/REQUIRED_DEPS.toml 771 18 5dbdfe553bf9dbeb -supabase/client.rs 604787 16340 bf8130b215c8f786 +supabase/client.rs 604777 16340 85d2ece77c349150 supabase/mod.rs 442 17 331ec75d430bc0a8 supabase/types.rs 503364 14054 1e07135f94046fd0 telnyx/REQUIRED_DEPS.toml 800 20 cb118ed14994d373 -telnyx/client.rs 5439335 142633 258fa58de5e436e3 +telnyx/client.rs 5439325 142633 4dad5da9c70d48fe telnyx/mod.rs 438 17 f3a44d93d532e621 telnyx/types.rs 4503323 108157 13f4a8ab963b0d98 terminal-shop/REQUIRED_DEPS.toml 529 14 241dda1403a4b0ba -terminal-shop/client.rs 208845 5578 1e78f4c3fb2d08f8 +terminal-shop/client.rs 208835 5578 ca1675da022fe64d terminal-shop/mod.rs 452 17 7014abd4e66e4e4e terminal-shop/types.rs 62233 1872 5c0e2ce07125c290 together/REQUIRED_DEPS.toml 762 18 2357560c3a71c3c6 -together/client.rs 572114 14972 4c4b827c16ea299c +together/client.rs 572104 14972 e0f4b1ec2aa1ca96 together/mod.rs 442 17 69fe71a3ff6db06e together/types.rs 685007 17471 876464750b65aea5 twilio/REQUIRED_DEPS.toml 650 16 79c204ac31d0efe3 -twilio/client.rs 844216 22157 aac52e92d81ef37e +twilio/client.rs 844206 22157 e514109d66a9848d twilio/mod.rs 438 17 1e85ee5c08c46793 twilio/types.rs 895681 20820 37d493aaebf60a09 val-town/REQUIRED_DEPS.toml 720 17 4d0f659a2898dcf4 -val-town/client.rs 177958 4699 770ab031bd4e1709 +val-town/client.rs 177948 4699 9fd35c3322c32405 val-town/mod.rs 442 17 8a07cd572c0c8638 val-town/types.rs 68086 1978 78ac613c49efff35 vercel/REQUIRED_DEPS.toml 673 16 b888661e2699f921 -vercel/client.rs 1511374 39685 957a51d5df0e24b2 +vercel/client.rs 1511364 39685 e76e9292be1adcb7 vercel/mod.rs 438 17 6e4cb11d3c843a23 vercel/types.rs 15046655 383513 3022713d78f22aa0 writer/REQUIRED_DEPS.toml 720 17 4d0f659a2898dcf4 -writer/client.rs 191302 5007 33061e5f6a03827d +writer/client.rs 191292 5007 26d9cb126c087d35 writer/mod.rs 438 17 0435bf1552e493d1 writer/types.rs 198394 4888 800dd8afe5798530 # -# specs: 56 files: 224 bytes: 330634511 +# specs: 56 files: 224 bytes: 330727285 diff --git a/tests/error_source_display_test.rs b/tests/error_source_display_test.rs new file mode 100644 index 00000000..f34c7bcd --- /dev/null +++ b/tests/error_source_display_test.rs @@ -0,0 +1,136 @@ +//! An error variant that wraps a source via `#[from]`/`#[source]` must not also +//! format that source into its own message, or chain-aware reporters print the +//! cause twice (gpu-cli/openapi-to-rust#112). + +use openapi_to_rust::GeneratorError; +use openapi_to_rust::error::error_chain; +use openapi_to_rust::streaming::{HttpMethod, StreamingConfig, StreamingEndpoint}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; + +/// Every `#[error("...")]` message in `source` that formats its variant's own +/// error source, as `Enum::Variant` names. +fn variants_displaying_their_source(source: &str) -> Vec { + let file = syn::parse_file(source).expect("source should parse"); + let mut offenders = Vec::new(); + for item in file.items { + let syn::Item::Enum(item) = item else { + continue; + }; + for variant in &item.variants { + let Some(message) = variant.attrs.iter().find_map(|attr| { + if !attr.path().is_ident("error") { + return None; + } + attr.parse_args::().ok().map(|lit| lit.value()) + }) else { + continue; + }; + let source_placeholder = + variant + .fields + .iter() + .enumerate() + .find_map(|(index, field)| { + let is_source = + field.attrs.iter().any(|attr| { + attr.path().is_ident("from") || attr.path().is_ident("source") + }) || field.ident.as_ref().is_some_and(|ident| ident == "source"); + is_source.then(|| match &field.ident { + Some(ident) => format!("{{{ident}"), + None => format!("{{{index}"), + }) + }); + if source_placeholder.is_some_and(|placeholder| message.contains(&placeholder)) { + offenders.push(format!("{}::{}", item.ident, variant.ident)); + } + } + } + offenders +} + +#[test] +fn generated_error_enums_leave_sources_out_of_their_messages() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "error sources", "version": "1" }, + "paths": { + "/events": { "get": { + "operationId": "streamEvents", + "responses": { "200": { + "description": "events", + "content": { "text/event-stream": { "schema": { + "$ref": "#/components/schemas/Event" + } } } + } } + } } + }, + "components": { "schemas": { + "Event": { + "type": "object", + "properties": { "message": { "type": "string" } } + } + } } + }); + let mut analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + enable_async_client: true, + enable_sse_client: true, + streaming_config: Some(StreamingConfig { + endpoints: vec![StreamingEndpoint { + operation_id: "streamEvents".into(), + path: "/events".into(), + http_method: HttpMethod::Get, + event_union_type: "Event".into(), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + + let mut checked = Vec::new(); + for file in &result.files { + let offenders = variants_displaying_their_source(&file.content); + assert!( + offenders.is_empty(), + "{} formats wrapped sources into messages: {offenders:?}", + file.path.display() + ); + checked.push(file.path.display().to_string()); + } + for expected in ["client.rs", "streaming.rs"] { + assert!( + checked.iter().any(|path| path == expected), + "{expected} should be generated, got {checked:?}" + ); + } +} + +#[test] +fn crate_error_enums_leave_sources_out_of_their_messages() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + for path in ["src/error.rs", "src/http_error.rs", "src/server/edit.rs"] { + let source = std::fs::read_to_string(root.join(path)).unwrap(); + let offenders = variants_displaying_their_source(&source); + assert!( + offenders.is_empty(), + "{path} formats wrapped sources into messages: {offenders:?}" + ); + } +} + +#[test] +fn error_chain_prints_each_cause_once() { + let error = GeneratorError::from(std::io::Error::other("disk full")); + assert_eq!(error_chain(&error), "IO error: disk full"); + + let error = GeneratorError::from(serde_json::from_str::("x").unwrap_err()); + let rendered = error_chain(&error); + assert!( + rendered.starts_with("Failed to parse OpenAPI spec: expected value"), + "unexpected rendering: {rendered}" + ); + assert_eq!(rendered.matches("expected value").count(), 1, "{rendered}"); +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index a8c7640a..3c3dec4c 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -4,6 +4,7 @@ //! `CodeGenerator::generate_all`) for the browser. No filesystem or network //! access — the page hands us a spec string, we hand back rendered files. +use openapi_to_rust::error::error_chain; use openapi_to_rust::spec_source::{ json_from_str_lossy, sanitize_source_provenance, validate_oas_document, yaml_to_json_value, }; @@ -73,10 +74,10 @@ pub fn generate(spec: &str, source_label: &str) -> Result { let crate_name = crate_name_from_spec(&value); let mut analyzer = - SchemaAnalyzer::new(value).map_err(|error| JsError::new(&error.to_string()))?; + SchemaAnalyzer::new(value).map_err(|error| JsError::new(&error_chain(&error)))?; let mut analysis = analyzer .analyze() - .map_err(|error| JsError::new(&error.to_string()))?; + .map_err(|error| JsError::new(&error_chain(&error)))?; // Mirror the CLI's `generate ` configuration so playground output // is byte-identical to a local run (modulo the source label). @@ -91,7 +92,7 @@ pub fn generate(spec: &str, source_label: &str) -> Result { CodeGenerator::new(config).with_source_provenance(sanitize_source_provenance(source_label)); let result = generator .generate_all(&mut analysis) - .map_err(|error| JsError::new(&error.to_string()))?; + .map_err(|error| JsError::new(&error_chain(&error)))?; let files: Vec = generator .output_artifacts(&result)