diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a9a58a7..28f4562 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,7 @@ +{"id":"openapi-generator-dsu","title":"OpenAPI 3.1 type:[X,null] on required properties generates non-Option field","description":"For OpenAPI 3.1 schemas where a property is BOTH listed in 'required' AND declared nullable via the 3.1 type-array form (type: [\"string\",\"null\"]), the generator emits a non-Option field. Deserialization then fails against real responses that send null.\n\nVERIFIED AGAINST THE LIVE RUNPOD v2 API. Two operations fail outright today:\n- GET /v2/catalog/gpus -\u003e GpuType.pool is null in production; spec says type: [\"string\",\"null\"], required. Generated: 'pub pool: String'.\n- GET /v2/pods -\u003e Pod.template is null in production. Generated: 'pub template: String'.\n\nConfirmed affected fields in https://api.runpod.io/v2/openapi.json (4, walking allOf branches):\n GpuType.pool, Pod.dataCenterId, Pod.template, Pod.startedAt\n\nPod.startedAt is the worst latent case: any pod that has not started returns null, which takes out the entire listPods call for that account.\n\nNote Pod composes via allOf, so the required list and properties live in an allOf branch — a fix must traverse composition, not just top-level component properties.\n\nRoot cause reported by live-testing agent (unverified by me): nullability is computed as is_nullable() || is_nullable_pattern() in src/analysis.rs (~1936-1937, ~2506-2514), covering only 3.0 'nullable: true' and the anyOf-with-null shape. Schema::type_array_contains_null() (src/openapi.rs ~649-656) is called from only one site (analysis.rs ~1555). generator.rs (~2497-2517) then yields a plain String.\n\nClosed issue openapi-generator-bgo fixed only the anyOf case; this type-array case is uncovered.\n\nSeverity: 3.1 is the modern dialect and this silently produces a client that cannot read production responses. This is first-run breakage of the worst kind — it compiles, then fails at runtime.","acceptance_criteria":"A required property declared type: [\"string\",\"null\"] generates Option\u003cString\u003e, including when declared inside an allOf branch. Regression test covers both the direct component-schema case and the allOf-composed case. Generating the RunPod v2 spec yields Option for GpuType.pool, Pod.template, Pod.startedAt, and Pod.dataCenterId.","status":"open","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:51:03Z","created_by":"James Lal","updated_at":"2026-07-26T23:51:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-x9v","title":"Client generator ignores SSE detection: streaming ops return () and hang forever","description":"Operations whose response is text/event-stream generate a client method returning Result\u003c(), ApiOpError\u003c..\u003e\u003e. The generated body calls response.text().await on an open SSE stream and discards the result. Since the stream never ends, the call never returns.\n\nVERIFIED: GET /v2/pods/{id}/logs on RunPod v2 generates:\n pub async fn get_pod_logs(..) -\u003e Result\u003c(), ApiOpError\u003cGetPodLogsApiError\u003e\u003e\nThe live-testing agent measured the call hung past 30s and past 600s. The generated HttpClient sets no timeout, so it deadlocks the caller's task rather than erroring. Raw SSE against the same endpoint works and returns well-formed frames.\n\nThe detection already exists and is correct: supports_streaming is computed in src/analysis.rs (~4562-4577) and the SERVER generator honors it (src/server/codegen.rs ~2263-2290). The CLIENT generator never reads it — reported as zero occurrences in client_generator.rs.\n\nTwo defects in one: (1) the streaming contract is dropped, (2) a discarded body plus no default timeout turns it into a hang instead of a visible failure.\n\nMinimum fix: have client_generator consult supports_streaming and emit a streaming return type instead of (). Independently worth doing: give the generated client a default request timeout so a mis-generated call fails loudly rather than hanging.","acceptance_criteria":"An operation declaring a text/event-stream response generates a client method returning a stream type rather than (), and does not call response.text() on it. Covered by a test over a spec with an SSE endpoint.","status":"open","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:51:19Z","created_by":"James Lal","updated_at":"2026-07-26T23:51:19Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-upz","title":"Generated multipart clients miss reqwest multipart feature in REQUIRED_DEPS","description":"For specs with multipart/form-data operations, the generator emits client code calling reqwest::multipart::Form and RequestBuilder::multipart(), and correctly adds features=[\"multipart\"] to reqwest-middleware in REQUIRED_DEPS.toml — but NOT to reqwest itself, which is pinned with default-features=false. The generated crate therefore fails to compile with E0433 (cannot find multipart in reqwest) and E0599 (no method named multipart on reqwest_middleware::RequestBuilder when its feature is also absent).\n\nReproducer: generate a client from https://api.studio.nebius.com/openapi.json (Nebius AI Studio, OpenAI-compatible, has POST /v1/files with multipart/form-data), then cargo check. Fails at client.rs with the two errors above.\n\nImpact: hits any spec with a file-upload endpoint, including OpenAI's own spec. This is first-run breakage — the user runs the tool, the output does not compile, and they leave.\n\nFix: when any operation uses multipart/form-data, add \"multipart\" to the reqwest feature list in the emitted REQUIRED_DEPS.toml fragment (alongside the existing reqwest-middleware feature).","acceptance_criteria":"Generating from a spec with a multipart/form-data operation produces a REQUIRED_DEPS.toml whose reqwest entry includes the multipart feature, and the resulting crate compiles clean. Covered by a corpus compile-check.","status":"open","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:35:12Z","created_by":"James Lal","updated_at":"2026-07-26T23:35:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-8gz","title":"Publish openapi-to-rust 0.10.0","description":"Cut the merged 0.10.0 release from origin/main through the tag-triggered trusted publishing workflow, then verify the workflow, crates.io publication, and GitHub release.","acceptance_criteria":"Annotated tag v0.10.0 points exactly at the fetched origin/main merge; publish workflow passes its verification and trusted publishing jobs; crates.io and GitHub expose v0.10.0.","status":"in_progress","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:03:42Z","created_by":"James Lal","updated_at":"2026-07-26T23:03:46Z","started_at":"2026-07-26T23:03:46Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-1ux","title":"Resolve local response references by JSON pointer","description":"The strict response semantics resolver introduced for 0.10.0 only accepts refs under components.responses. PagerDuty stores a structurally valid Response Object under components.requestBodies and references it from an operation response, causing full corpus generation to fail.","acceptance_criteria":"Local response refs resolve through their actual JSON pointer and deserialize as Response Objects regardless of component namespace; missing, external, structurally incompatible, and cyclic refs fail with actionable errors; PagerDuty parse-only generation and focused response tests pass.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T22:45:49Z","created_by":"James Lal","updated_at":"2026-07-26T22:54:46Z","started_at":"2026-07-26T22:45:55Z","closed_at":"2026-07-26T22:54:46Z","close_reason":"Response refs now resolve through validated local JSON pointers with actionable missing/external/incompatible/cycle errors. PagerDuty generate+check passes, all 54 supported specs generate, and full all-features tests plus clippy pass.","dependencies":[{"issue_id":"openapi-generator-1ux","depends_on_id":"openapi-generator-in6","type":"discovered-from","created_at":"2026-07-26T16:45:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-in6.3","title":"Preserve OpenAPI server response semantics","description":"Server analysis/codegen ignores component Response refs and bodyless statuses, loses +json media types and SSE status codes, and collapses wildcard/default responses to arbitrary fixed codes. Model and emit the declared response semantics faithfully.","acceptance_criteria":"Referenced responses resolve; bodyless 201/202/204 variants are emitted; +json Content-Type is preserved; SSE uses its declared status; wildcard/default variants can return valid runtime statuses; focused generated/runtime tests cover each case.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:42Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:08Z","started_at":"2026-07-26T21:55:54Z","closed_at":"2026-07-26T22:15:08Z","close_reason":"Preserved reusable, bodyless, media-type, SSE-status, wildcard, and default response semantics with generated runtime coverage; full suite passes.","dependencies":[{"issue_id":"openapi-generator-in6.3","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-in6.2","title":"Gate Anthropic generated server with official Python SDK on PR","description":"PR CI runs the official OpenAI Python SDK against the generated OpenAI Axum server, but Anthropic has no equivalent coverage and ignored server example tests have no CI job.","acceptance_criteria":"PR CI pins and runs the official Anthropic Python SDK against an ephemeral generated Anthropic Axum server for unary and SSE messages; both server examples are regenerated and tested on PR; CI/docs no longer claim a missing examples job.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:41Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:07Z","started_at":"2026-07-26T21:55:53Z","closed_at":"2026-07-26T22:15:07Z","close_reason":"Added every-PR official Anthropic Python SDK unary/SSE gate, hardened both SDK harnesses, and verified both generated examples and both official clients end to end.","dependencies":[{"issue_id":"openapi-generator-in6.2","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:40Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -38,6 +42,8 @@ {"id":"openapi-generator-cv4","title":"Support exploded query parameters (GH #27)","description":"GH issue 27: query params with style=form, explode=true (OAS defaults) and object schemas currently map to Option\u003cimpl AsRef\u003cstr\u003e\u003e and are sent as one opaque string. Per OAS/RFC6570 form-explode, each object property must become its own query pair (?color=red). Fix: analyzer synthesizes/resolves a typed struct for object query params with form+explode semantics; client generator emits req.query(\u0026struct) so reqwest/serde_urlencoded serializes properties as individual pairs.","notes":"Implemented on branch issue-27-exploded-query-params, draft PR https://github.com/gpu-cli/openapi-to-rust/pull/28. Close when PR merges. Follow-ups: openapi-generator-anu (deepObject/explode=false/arrays), openapi-generator-0jz (server side).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:22:24Z","created_by":"James Lal","updated_at":"2026-07-13T04:21:19Z","started_at":"2026-07-12T23:22:49Z","closed_at":"2026-07-13T04:21:19Z","close_reason":"Released in v0.6.0 (PR #28)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-5q8","title":"DateStrategy::Time emits broken serde codec for time::Date/time::Time (GH #25)","description":"GitHub issue gpu-cli/openapi-to-rust#25: fields with format: date/time under DateStrategy::Time get #[serde(with = \"time::serde::iso8601\")], but that module only supports OffsetDateTime — generated code fails to compile. Fix: emit time::serde::format_description! helper codec modules (time_date_format / time_time_format) into generated code, and correct the time dep requirement features (serde alone doesn't even enable rfc3339).","notes":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/26 (branch worktree-issue-25-time-date-serde). Close when PR merges.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:36:28Z","created_by":"James Lal","updated_at":"2026-07-11T18:57:02Z","started_at":"2026-07-11T18:36:45Z","closed_at":"2026-07-11T18:57:02Z","close_reason":"Fixed in PR #26, merged to main, released in v0.5.3","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dpd","title":"Hybrid string-or-object discriminated unions deserialize-fail","description":"When an anyOf/oneOf union contains a discriminator AND a non-object branch (e.g. string-enum like ToolChoiceOptions), the generator emits a tagged enum that cannot deserialize the string form. Real-world hit: OpenAI ToolChoiceParam returns 'auto' in Response.tool_choice, but generated type is #[serde(tag=\"type\")] enum with no untagged String variant. Need to fall back to #[serde(untagged)] when the union mixes string/scalar branches with tagged-object branches, OR add a String fallback variant before the tagged variants.","notes":"Live repro: `ToolChoiceParam` from openai.yaml line 52518. anyOf has 8 branches; the first (`ToolChoiceOptions`) is a string-enum (\"none\"|\"auto\"|\"required\"), the rest are objects with discriminator propertyName=type. Generator emits `#[serde(tag=\"type\")] enum ToolChoiceParam { ToolChoiceOptions(ToolChoiceOptions), ... }` which cannot deserialize the string \"auto\" because serde tries to read a \"type\" field from a JSON string. Fix: when an anyOf/oneOf branch is a non-object schema (string/number/etc), the generator must emit `#[serde(untagged)]` with the scalar branch first OR add a String variant before the tagged variants. Hit on real OpenAI Responses API `Response.tool_choice` field.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:35Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:10Z","closed_at":"2026-05-11T00:12:46Z","close_reason":"Fixed in src/analysis.rs: (dpd) analyze_oneof_union now downgrades to untagged when any branch is non-object — verified live against OpenAI Response.tool_choice='auto' which now deserializes as ToolChoiceParam::ToolChoiceOptions(Auto). (bgo) merge_schema_into_properties now ORs in is_nullable_pattern() for allOf-merged props — verified live against OpenAI Response.incomplete_details which is now Option\u003cResponseIncompleteDetails\u003e and deserializes null cleanly. All 4 smoke tests (OpenAI+Anthropic, sync+stream) pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-oug","title":"Default(ErrorResponse) error variant is generated but never constructed","description":"Every generated per-operation error enum includes a Default(ErrorResponse) variant for the spec's 'default' response, but no code path ever constructs it: 'grep -c \"ApiError::Default(\"' returns 0 in generated clients.\n\nVERIFIED on the RunPod v2 client: Default(ErrorResponse) is declared (types/client around lines 298, 308, 318 of the generated client) and constructed zero times.\n\nConsequence: a live 422 whose body parses cleanly into ErrorResponse still yields typed: None, so callers fall back to raw body strings. The per-operation typed-error feature — one of the generator's headline selling points — silently does not work for default responses.\n\nFound by live-testing the RunPod v2 API against the generated client.","acceptance_criteria":"A response matched only by the spec's 'default' response constructs the Default(..) variant with the typed body, and a test asserts typed is Some for that case.","status":"open","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:51:30Z","created_by":"James Lal","updated_at":"2026-07-26T23:51:30Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-igg","title":"Default client base_url from servers[0].url when config omits it","description":"HttpClient::new() initializes base_url to String::new() when the TOML config has no [http_client] base_url, even when the OpenAPI document declares servers[0].url. Users must read the docs and supply the base URL manually or every request 404s.\n\nObserved generating from https://api.runpod.io/v2/openapi.json, whose spec declares servers[0].url = https://api.runpod.io; the generated client still starts with an empty base_url.\n\nThis matters most for published, generated client crates, where HttpClient::new() working out of the box is the difference between a crate that feels native and one that appears broken on first use.\n\nFix: when [http_client].base_url is absent, fall back to servers[0].url from the spec. Explicit config keeps precedence. Consider also emitting it as a pub const BASE_URL so downstream crates can reference it without hardcoding a string.","acceptance_criteria":"Generating from a spec with a servers entry and no configured base_url yields a client that targets servers[0].url by default; an explicitly configured base_url still wins.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-26T23:35:28Z","created_by":"James Lal","updated_at":"2026-07-26T23:35:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-tk1","title":"Cache CI scratch compilation targets","description":"Route install-smoke and spec-compile Cargo target directories into paths retained by the existing rust-cache steps so hosted runners reuse compiled dependencies across runs.","acceptance_criteria":"PR and scheduled spec compile jobs use a stable cached scratch target; install-smoke uses a stable cached install target; temporary packaging and generated fixture cleanup remain isolated; workflow syntax and relevant smoke commands pass.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T22:35:49Z","created_by":"James Lal","updated_at":"2026-07-26T22:40:16Z","started_at":"2026-07-26T22:35:53Z","closed_at":"2026-07-26T22:40:16Z","close_reason":"CI now routes install-smoke and both spec-compile scratch targets through absolute paths under the rust-cache-managed root target tree. actionlint, targeted Anthropic spec compile (offline), and install smoke pass.","dependencies":[{"issue_id":"openapi-generator-tk1","depends_on_id":"openapi-generator-in6","type":"discovered-from","created_at":"2026-07-26T16:35:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-in6.4","title":"Detect normalized server tag identifier collisions","description":"Distinct OpenAPI tags such as foo-bar and foo_bar normalize to identical Rust trait/router identifiers and generate duplicate items.","acceptance_criteria":"Server generation rejects or deterministically disambiguates colliding normalized tag identifiers with an actionable diagnostic; regression test covers colliding tags.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:43Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:08Z","started_at":"2026-07-26T21:55:54Z","closed_at":"2026-07-26T22:15:08Z","close_reason":"Added deterministic normalized tag collision diagnostics with unit and integration coverage; full suite passes.","dependencies":[{"issue_id":"openapi-generator-in6.4","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:42Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-in6.1","title":"Support YAML schema extension overlays","description":"README documents JSON/YAML schema_extensions, but analysis parses every overlay as JSON. Parse .yaml/.yml overlays consistently with specs and add merge/SSE regression coverage.","acceptance_criteria":"JSON and YAML overlays both deep-merge; a YAML text/event-stream overlay marks the operation streaming; malformed overlays report their file context.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:39Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:05Z","started_at":"2026-07-26T21:55:53Z","closed_at":"2026-07-26T22:15:05Z","close_reason":"Implemented JSON/YAML/YML schema extension parsing with path-rich errors; 4 focused tests and full all-features suite pass.","dependencies":[{"issue_id":"openapi-generator-in6.1","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0845b35..75fd26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,27 @@ when correcting output that was wrong or incomplete on the wire. ### Fixed +- Properties that are both `required` and nullable via OpenAPI 3.1's + `type: ["X", "null"]` now generate `Option` instead of a bare `T`, in plain + object schemas and in `allOf`-composed ones alike. Previously such a client + compiled and then failed to deserialize the first real response containing + `null`. All three nullability spellings (`nullable: true`, the 3.1 type array, + and an `anyOf`/`oneOf` null branch) now route through one helper. +- Client operations whose only success content is `text/event-stream` return a + `futures_util::Stream` of bytes instead of `()`. They previously buffered the + response with `.text()`, which never returns on a live SSE stream and hung the + caller's task indefinitely. +- Generated clients default `base_url` to the document's `servers[0].url` when + configuration does not set one, so `HttpClient::new()` targets the real API + instead of an empty string. Explicit configuration still wins; relative and + templated server URLs are ignored. +- The `Default(..)` per-operation error variant is now constructed for responses + matched by the spec's `default` response. It was previously declared but + unreachable, so a typed `default` body still surfaced as `typed: None`. +- Specs with `multipart/form-data` operations now request reqwest's `multipart` + feature in `REQUIRED_DEPS.toml`. The feature was enabled for + `reqwest-middleware` but not for `reqwest` itself, so generated file-upload + clients failed to compile. - Config-driven `server list` and `server add` now apply `generator.schema_extensions`, so overlay-provided operations and SSE media types match generation. diff --git a/src/analysis.rs b/src/analysis.rs index 8e40e69..a27864e 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -1933,8 +1933,8 @@ impl SchemaAnalyzer { }; let prop_details = prop_schema.details(); - // Check for both explicit nullable and anyOf nullable patterns - let prop_nullable = prop_details.is_nullable() || prop_schema.is_nullable_pattern(); + // Every nullability form, via one helper — see is_nullable_any. + let prop_nullable = prop_schema.is_nullable_any(); let prop_description = prop_details.description.clone(); let prop_default = prop_details.default.clone(); @@ -2506,12 +2506,13 @@ impl SchemaAnalyzer { )?; let prop_details = prop_schema.details(); - // Bug openapi-generator-bgo: pick up 3.1-style nullability - // (anyOf/oneOf with a `type: null` branch) in addition to the - // 3.0 `nullable: true` keyword. Without this, properties merged - // through allOf composition lose their null-branch detection - // (real hit: OpenAI Response.incomplete_details). - let nullable = prop_details.is_nullable() || prop_schema.is_nullable_pattern(); + // Properties merged through allOf composition must go through + // the same nullability check as plain object properties. + // Real hits: OpenAI Response.incomplete_details (anyOf-with-null, + // openapi-generator-bgo) and RunPod Pod.startedAt / Pod.template + // (3.1 type-array, openapi-generator-dsu) — the latter arrive + // as `null` from the live API for any pod that hasn't started. + let nullable = prop_schema.is_nullable_any(); merged_properties.insert( prop_name.clone(), PropertyInfo { diff --git a/src/bin/openapi-to-rust.rs b/src/bin/openapi-to-rust.rs index 6a6e54c..4df9bff 100644 --- a/src/bin/openapi-to-rust.rs +++ b/src/bin/openapi-to-rust.rs @@ -344,6 +344,7 @@ fn run_generate(args: GenerateArgs) -> Result<(), Box> { let spec_content = load_spec(&load_source)?; let spec_value = parse_spec(&spec_content, &load_source)?; let warning = openapi_to_rust::spec_source::validate_oas_document(&spec_value)?; + generator_config.apply_spec_server_default(&spec_value); let mapper = openapi_to_rust::TypeMapper::new(generator_config.types.clone()); let mut analyzer = if generator_config.schema_extensions.is_empty() { SchemaAnalyzer::with_type_mapper(spec_value, mapper)? diff --git a/src/client_generator.rs b/src/client_generator.rs index d1321fd..8117acc 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -285,6 +285,20 @@ impl CodeGenerator { /// Generate the constructor method fn generate_constructor(&self, has_retry: bool, has_tracing: bool) -> TokenStream { + // Seed `base_url` from configuration rather than always starting empty. + // A client built with an empty base URL sends every request to a + // relative path and fails, so a user who set `[http_client] base_url` + // in their TOML — or whose spec declares `servers[0].url`, which the + // config layer resolves into the same field — previously had to repeat + // it via `with_base_url` or watch every call 404 (openapi-generator-igg). + let configured_base_url = self + .config() + .http_client_config + .as_ref() + .and_then(|http| http.base_url.as_deref()) + .unwrap_or_default(); + let default_base_url = quote! { #configured_base_url.to_string() }; + let retry_param = if has_retry { quote! { retry_config: Option, } } else { @@ -358,7 +372,7 @@ impl CodeGenerator { let http_client = client_builder.build(); Self { - base_url: String::new(), + base_url: #default_base_url, api_key: None, http_client, custom_headers: BTreeMap::new(), @@ -382,7 +396,7 @@ impl CodeGenerator { let http_client = client_builder.build(); Self { - base_url: String::new(), + base_url: #default_base_url, api_key: None, http_client, custom_headers: BTreeMap::new(), @@ -2106,11 +2120,30 @@ impl CodeGenerator { let rust_type_name = self.to_rust_type_name(response_type); let response_ident = syn::Ident::new(&rust_type_name, proc_macro2::Span::call_site()); quote! { #response_ident } + } else if Self::returns_raw_event_stream(op) { + quote! { impl futures_util::Stream> } } else { quote! { () } } } + /// True when the operation's success response carries `text/event-stream` + /// and nothing this generator can model as a JSON body. + /// + /// These previously generated `-> Result<(), _>` and then called + /// `response.text().await`, which on a live SSE stream never returns: the + /// caller's task deadlocks rather than erroring (openapi-generator-x9v). + /// The streaming signal was already detected in analysis and honored by the + /// server generator; only the client ignored it. + /// + /// Operations declaring *both* a JSON body and `text/event-stream` keep + /// their JSON contract here — those are the `stream: true` style endpoints + /// covered by the explicit `[streaming]` configuration, and silently + /// changing their return type would break existing callers. + fn returns_raw_event_stream(op: &OperationInfo) -> bool { + op.supports_streaming + } + /// Generate error handling. /// /// Always reads the response body to a string before attempting any typed @@ -2148,6 +2181,35 @@ impl CodeGenerator { let error_match_arms = self.generate_error_match_arms(op); + // Streaming success path: hand back the live byte stream instead of + // buffering it. Reading an SSE body to a string blocks until the server + // closes the connection, which is precisely what it will not do. + // The error path still buffers — an error response is finite. + if !has_response_body && Self::returns_raw_event_stream(op) { + return quote! { + let status = response.status(); + let status_code = status.as_u16(); + let headers = response.headers().clone(); + + if status.is_success() { + Ok(response.bytes_stream()) + } else { + let body_text = response.text().await + .map_err(|e| ApiOpError::Transport(HttpError::Network(e)))?; + let typed: Option<#op_error_type>; + let parse_error: Option; + #error_match_arms + Err(ApiOpError::Api(ApiError { + status: status_code, + headers, + body: body_text, + typed, + parse_error, + })) + } + }; + } + quote! { let status = response.status(); let status_code = status.as_u16(); @@ -2232,7 +2294,36 @@ impl CodeGenerator { .iter() .any(|(code, _)| !code.starts_with('2')); - let default_arm = if has_typed_enum { + // A spec-declared `default` response is the catch-all arm's payload + // type. Without this the generated enum carries a `Default(..)` variant + // that nothing ever constructs, so a response matched only by `default` + // — a perfectly parseable typed body — still surfaces as + // `typed: None` and callers fall back to raw strings + // (openapi-generator-nu7). + let default_payload = op + .response_schemas + .iter() + .find(|(code, _)| matches!(code.as_str(), "default" | "Default")) + .map(|(_, schema)| self.to_rust_type_name(schema)); + + let default_arm = if let Some(payload_ty_name) = default_payload { + let payload_ty = syn::Ident::new(&payload_ty_name, proc_macro2::Span::call_site()); + let enum_ident = self.op_error_enum_ident(op); + quote! { + _ => { + match serde_json::from_str::<#payload_ty>(&body_text) { + Ok(v) => { + typed = Some(#enum_ident::Default(v)); + parse_error = None; + } + Err(e) => { + typed = None; + parse_error = Some(e.to_string()); + } + } + } + } + } else if has_typed_enum { quote! { _ => { typed = None; diff --git a/src/generator.rs b/src/generator.rs index 213a30f..ec3097d 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -209,6 +209,51 @@ impl Default for GeneratorConfig { } } +impl GeneratorConfig { + /// Adopt the document's `servers[0].url` as the client's default base URL + /// when configuration didn't supply one. + /// + /// The spec already states where the API lives; making every user restate + /// it in TOML (or discover at runtime that requests go nowhere) is friction + /// with no upside. Explicit configuration always wins. + /// + /// Two server URLs are deliberately ignored: relative ones (`/v1`), which + /// are meaningless without an origin, and templated ones containing `{}` + /// server variables, which are not usable until substituted. + pub fn apply_spec_server_default(&mut self, spec: &serde_json::Value) { + let already_configured = self + .http_client_config + .as_ref() + .and_then(|http| http.base_url.as_deref()) + .is_some_and(|url| !url.is_empty()); + if already_configured { + return; + } + + let Some(url) = spec + .pointer("/servers/0/url") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) + .filter(|url| !url.starts_with('/')) + .filter(|url| !url.contains('{')) + else { + return; + }; + + match self.http_client_config.as_mut() { + Some(http) => http.base_url = Some(url.to_string()), + None => { + self.http_client_config = Some(crate::http_config::HttpClientConfig { + base_url: Some(url.to_string()), + timeout_seconds: None, + default_headers: Default::default(), + }) + } + } + } +} + pub fn default_type_mappings() -> BTreeMap { let mut mappings = BTreeMap::new(); mappings.insert("integer".to_string(), "i64".to_string()); diff --git a/src/openapi.rs b/src/openapi.rs index 140589f..fdd3af6 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -655,6 +655,23 @@ impl Schema { } } + /// True when the schema is nullable in any form OpenAPI allows: + /// 3.0's `nullable: true`, 3.1's `type: ["X", "null"]`, or an + /// `anyOf`/`oneOf` carrying a `null` branch. + /// + /// Property nullability must be decided through this, not through any + /// single one of the three checks. Each form was added separately and each + /// time a call site was missed: `nullable: true` first, then the + /// `anyOf`-with-null shape (openapi-generator-bgo), leaving the 3.1 + /// canonical type-array form unhandled on properties + /// (openapi-generator-dsu) — which silently generated non-`Option` fields + /// for values the API really does send as `null`. + pub fn is_nullable_any(&self) -> bool { + self.details().is_nullable() + || self.type_array_contains_null() + || self.is_nullable_pattern() + } + /// Get schema details pub fn details(&self) -> &SchemaDetails { static EMPTY_DETAILS: Lazy = Lazy::new(SchemaDetails::default); diff --git a/src/snapshots/openapi_to_rust__test_helpers__all_nullability_spellings.snap b/src/snapshots/openapi_to_rust__test_helpers__all_nullability_spellings.snap new file mode 100644 index 0000000..a500811 --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__all_nullability_spellings.snap @@ -0,0 +1,22 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Mixed { + #[serde(skip_serializing_if = "Option::is_none")] + pub any_of: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub legacy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub type_array: Option, +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null.snap b/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null.snap new file mode 100644 index 0000000..0ea6ae8 --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null.snap @@ -0,0 +1,19 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GpuType { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub pool: Option, +} diff --git a/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null_allof.snap b/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null_allof.snap new file mode 100644 index 0000000..54f015f --- /dev/null +++ b/src/snapshots/openapi_to_rust__test_helpers__required_type_array_null_allof.snap @@ -0,0 +1,28 @@ +--- +source: src/test_helpers.rs +expression: "&generated_code" +--- +//! Generated types from OpenAPI specification +//! +//! This file contains all the generated types for the API. +//! Do not edit manually - regenerate using the appropriate script. +#![allow(clippy::large_enum_variant)] +#![allow(clippy::format_in_format_args)] +#![allow(clippy::let_unit_value)] +#![allow(unreachable_patterns)] +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Pod { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(rename = "startedAt", skip_serializing_if = "Option::is_none")] + pub started_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, +} +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct ContainerConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, +} diff --git a/src/type_mapping.rs b/src/type_mapping.rs index b37c3cb..8045aca 100644 --- a/src/type_mapping.rs +++ b/src/type_mapping.rs @@ -314,6 +314,20 @@ pub fn collect_generated_dep_requirements<'a>( if uses(".json(&") { features.push("json"); } + // Multipart operations reference `reqwest::multipart::Form` directly. + // reqwest is pinned with default-features off, so the feature has to be + // requested explicitly or the generated client fails to compile with + // "cannot find `multipart` in `reqwest`". The reqwest-middleware side + // of this was already handled below; reqwest itself was missed + // (openapi-generator-upz). Hits any spec with a file upload. + if uses("reqwest::multipart") { + features.push("multipart"); + } + // `Response::bytes_stream()`, used by generated SSE operations, is + // gated behind reqwest's `stream` feature. + if uses(".bytes_stream()") { + features.push("stream"); + } dependencies.push( DepRequirement::new("reqwest", "0.12") .without_default_features() diff --git a/tests/generation_requirements_test.rs b/tests/generation_requirements_test.rs index 27b1d78..c3e9852 100644 --- a/tests/generation_requirements_test.rs +++ b/tests/generation_requirements_test.rs @@ -309,6 +309,11 @@ fn every_generation_mode_compiles_from_its_exact_dependency_fragment() { "base64", "bytes", "chrono", + // The fixture's streaming operation declares only + // `text/event-stream`, so its client method now returns a + // `futures_util::Stream` of bytes rather than `()` + // (openapi-generator-x9v). + "futures-util", "reqwest", "reqwest-middleware", "serde", diff --git a/tests/nullable_type_array_test.rs b/tests/nullable_type_array_test.rs new file mode 100644 index 0000000..3ec6d0e --- /dev/null +++ b/tests/nullable_type_array_test.rs @@ -0,0 +1,127 @@ +#![cfg(feature = "test-helpers")] + +//! Tests for OpenAPI 3.1 canonical nullability on *required* properties +//! (openapi-generator-dsu). +//! +//! A property that is both listed in `required` and declared nullable via the +//! 3.1 type-array form (`type: ["string", "null"]`) must generate +//! `Option`. Emitting a bare `String` compiles, then fails at runtime +//! the first time the API sends `null` — the worst kind of breakage, because +//! nothing catches it until a real response arrives. +//! +//! Found by live-testing a generated client against the RunPod v2 API, where +//! `GpuType.pool` and `Pod.template` are null in production. + +use openapi_to_rust::test_helpers::*; +use serde_json::json; + +#[test] +fn required_type_array_null_property_is_optional() { + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0"}, + "components": { + "schemas": { + "GpuType": { + "type": "object", + "required": ["id", "pool"], + "properties": { + "id": {"type": "string"}, + "pool": {"type": ["string", "null"]} + } + } + } + } + }); + + let result = test_generation("required_type_array_null", spec).expect("Generation failed"); + + assert!( + result.contains("pub pool: Option"), + "A required property typed [\"string\", \"null\"] must be Option, got:\n{result}" + ); + assert!( + result.contains("pub id: String") && !result.contains("pub id: Option"), + "A required non-nullable property must stay non-Option, got:\n{result}" + ); +} + +/// The allOf-composed case, which merges properties through a separate code +/// path. RunPod's `Pod` is shaped exactly like this: the `required` list and +/// the nullable properties both live inside an allOf branch. +#[test] +fn required_type_array_null_through_allof_is_optional() { + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0"}, + "components": { + "schemas": { + "ContainerConfig": { + "type": "object", + "properties": {"image": {"type": "string"}} + }, + "Pod": { + "allOf": [ + {"$ref": "#/components/schemas/ContainerConfig"}, + { + "type": "object", + "required": ["id", "startedAt", "template"], + "properties": { + "id": {"type": "string"}, + "startedAt": {"type": ["string", "null"], "format": "date-time"}, + "template": {"type": ["string", "null"]} + } + } + ] + } + } + } + }); + + let result = + test_generation("required_type_array_null_allof", spec).expect("Generation failed"); + + assert!( + result.contains("pub template: Option"), + "A required nullable property merged through allOf must be Option, got:\n{result}" + ); + assert!( + result.contains("pub started_at: Option<"), + "A required nullable date-time merged through allOf must be Option, got:\n{result}" + ); +} + +/// The 3.0 and anyOf spellings must keep working — the fix consolidated three +/// separate checks into one helper, so all three forms are covered here to +/// stop a future refactor from dropping one again. +#[test] +fn all_three_nullability_spellings_agree() { + let spec = json!({ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0"}, + "components": { + "schemas": { + "Mixed": { + "type": "object", + "required": ["legacy", "type_array", "any_of"], + "properties": { + "legacy": {"type": "string", "nullable": true}, + "type_array": {"type": ["string", "null"]}, + "any_of": { + "anyOf": [{"type": "string"}, {"type": "null"}] + } + } + } + } + } + }); + + let result = test_generation("all_nullability_spellings", spec).expect("Generation failed"); + + for field in ["legacy", "type_array", "any_of"] { + assert!( + result.contains(&format!("pub {field}: Option<")), + "required nullable `{field}` must be Option, got:\n{result}" + ); + } +}