From ab7a39c3b7b92771348b52cba685ecc8527f409f Mon Sep 17 00:00:00 2001 From: James Lal Date: Thu, 29 Oct 2026 14:08:24 -0600 Subject: [PATCH 1/2] fix: keep object shape under conditional allOf; stop double-printing error sources #111: an object schema whose allOf held only if/then/else rules collapsed to serde_json::Value, untagging any discriminated oneOf it was a branch of. The allOf shortcuts ignored the owner's own properties, so a single untyped or $ref member replaced the whole schema (Cloudflare and gcore lost required and optional fields this way). Conditional-only members no longer shape the composed type, owners with their own properties keep their object shape, and branch_resolves_to_object treats such owners as objects. #112: error variants wrapping a source via #[from] also formatted it with {0}, so chain reporters printed every cause twice. Generated HttpError and StreamingError, plus this crate's error types, now leave the source out of their messages. The CLI and wasm playground render the full chain with the new error::error_chain helper. Closes #111 Closes #112 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 + examples/generated/REQUIRED_DEPS.toml | 17 ++ examples/generated/client.rs | 377 +++++++++++++++++++++---- examples/generated/mod.rs | 8 +- examples/generated/types.rs | 72 +++++ src/analysis.rs | 101 +++++-- src/bin/openapi-to-rust.rs | 8 +- src/error.rs | 21 +- src/generator.rs | 6 +- src/http_error.rs | 2 +- src/server/edit.rs | 6 +- tests/allof_conditional_member_test.rs | 182 ++++++++++++ tests/error_source_display_test.rs | 136 +++++++++ wasm/src/lib.rs | 7 +- 14 files changed, 862 insertions(+), 92 deletions(-) create mode 100644 examples/generated/REQUIRED_DEPS.toml create mode 100644 tests/allof_conditional_member_test.rs create mode 100644 tests/error_source_display_test.rs 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/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) From fcd78dc0e70395e21546b663632e601ed9844f57 Mon Sep 17 00:00:00 2001 From: James Lal Date: Thu, 29 Oct 2026 14:45:37 -0600 Subject: [PATCH 2/2] chore: refresh corpus manifest for #111/#112 output changes Co-Authored-By: Claude Opus 5.5 --- tests/corpus-manifest.txt | 120 +++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 60 deletions(-) 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