From 1714346d79896c6e588a4755231e3147ba550f1e Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 12 Jul 2026 17:33:08 -0600 Subject: [PATCH 1/7] feat(client): form-exploded object query parameters (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query params with object schemas and form-explode semantics (the OAS 3.x defaults for in:query) previously mapped to Option> and were sent as one opaque filter= pair. Per RFC 6570 form-explosion each object property must be its own query pair (?color=red&size=big). - analyzer: type such params as a struct — synthesized (e.g. FindWidgetsFilter) for inline object schemas, resolved for $refs — and mark them form_explode_object - client: emit req.query(&value) for marked params; reqwest serializes the struct via serde_urlencoded, one pair per set property, None fields omitted - deepObject and form+explode=false keep the string fallback (tracked in openapi-generator-anu); server codegen unchanged (openapi-generator-0jz) Closes gpu-cli/openapi-to-rust#27 Co-Authored-By: Claude Fable 5 --- .beads/issues.jsonl | 3 + src/analysis.rs | 85 ++++++++- src/client_generator.rs | 45 ++++- tests/exploded_query_params_test.rs | 285 ++++++++++++++++++++++++++++ 4 files changed, 401 insertions(+), 17 deletions(-) create mode 100644 tests/exploded_query_params_test.rs diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c11b00c..db1f1b7 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,7 @@ +{"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.","status":"in_progress","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-12T23:22:49Z","started_at":"2026-07-12T23:22:49Z","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-anu","title":"Query param styles not yet generated: deepObject, form explode=false, exploded arrays","description":"Follow-up to GH #27 (openapi-generator-cv4). Client now supports form-exploded object query params (?color=red). Still on the string fallback: style=deepObject (?filter[color]=red — Stripe uses this heavily, 1600+ occurrences), form+explode=false objects (?filter=color,red,size,big), and typed exploded arrays (?tags=a\u0026tags=b currently a single string param). Each needs analyzer typing + client serialization.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:32:14Z","created_by":"James Lal","updated_at":"2026-07-12T23:32:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-cwd","title":"Config docs say [generator.types] but parser reads top-level [types] — documented block is silently ignored","description":"README (§'[generator.types] — typed-scalar strategy per format', also [generator.types.strategies]) and the doc comment on ConfigFile::types say the type-mapping block lives at [generator.types], but ConfigFile deserializes it from top-level [types]. A TOML file using the documented path is silently ignored (unknown fields aren't denied), so users think they enabled e.g. date = \"time\" but get chrono defaults. Found while testing GH #25. Fix: either move the field under GeneratorSection or fix README + doc comment, and consider deny_unknown_fields or a validation warning.","status":"open","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:47:50Z","created_by":"James Lal","updated_at":"2026-07-11T18:47:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-k07","title":"[bug] Query/path parameters ignore integer/number format (int32 -\u003e i64)","description":"analyze_parameter in src/analysis.rs hardcodes Integer =\u003e i64 and Number =\u003e f64, ignoring the schema format field. Body/schema properties correctly route through TypeMapper::integer_format/number_format, so int32 becomes i32 there, but the same format on a query/path parameter always yields i64. No type_mappings/strategies config can override it because analyze_parameter never calls TypeMapper. Fixes GitHub issue #23.","notes":"Fix in PR #24 (gpu-cli/openapi-to-rust), branch worktree-fix-i32-param-format. Root cause: analyze_parameter hardcoded Integer=\u003ei64 / Number=\u003ef64, bypassing TypeMapper. Now routes through integer_format/number_format. CI green (test/clippy/fmt/doc/spec-compile). Commented on GH issue #23. Awaiting review/merge.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T03:12:09Z","created_by":"James Lal","updated_at":"2026-07-11T03:29:45Z","started_at":"2026-07-11T03:12:21Z","closed_at":"2026-07-11T03:29:45Z","close_reason":"Fixed in PR #24, merged to main, released as v0.5.2 (crates.io + GitHub release). GH issue #23 commented.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-76h","title":"Add ByteStrategy::Base64UrlUnpadded for format: byte","description":"Add a new ByteStrategy variant emitting RFC 7515 §2 url-safe unpadded base64 in the inlined base64_serde helper. Needed by gpu-cli portal-schema work (JWK/DPoP thumbprints, sealing pubkeys) so format: byte fields can round-trip without falling back to plain String + spec-side documentation. Spec-global config knob, not per-field.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-16T17:58:36Z","created_by":"James Lal","updated_at":"2026-05-16T18:29:10Z","started_at":"2026-05-16T17:58:50Z","closed_at":"2026-05-16T18:29:10Z","close_reason":"Shipped on worktree-base64url-unpadded (commit afc8c88). 16 typed_scalars_test, 23 type_mapping unit tests, 51-binary cargo test suite all green; clippy --lib clean.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -24,6 +26,7 @@ {"id":"openapi-generator-st8","title":"[Q3] Builder pattern for operations with many parameters","description":"OpenAI's responses_create has 25+ parameters. Even with Option\u003cT\u003e for optionals, the call site is hostile: client.responses_create(model, None, None, ..., Some('system prompt'), None, ...). Goal: emit a \u003cOp\u003eBuilder\u003c'_\u003e per op with .field(value) setters and a final .send().await. Required path/header params remain positional on the entry method; optional + body fields become builder setters. For struct-typed bodies, also generate per-field setters on the builder (delegating into the body struct).\n\n## Context\nFiles: src/client_generator.rs. Evidence: src/client_generator.rs:836 generate_request_param emits flat positional method args. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.builders] enabled = true; threshold = 3 in TOML config.\n- [ ] Each operation with \u003ethreshold optional params gets a builder struct.\n- [ ] Required params stay positional on the entry method.\n- [ ] .send(self) -\u003e Result\u003c\u003cResponseT\u003e, ApiOpError\u003c...\u003e\u003e runs the existing emitted body.\n- [ ] Snapshot tests for an op with many optional params show the new shape compiles and the existing call compiles.\n- [ ] All 49 currently-compiling specs still compile.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:11:55Z","created_by":"James Lal","updated_at":"2026-05-08T23:11:55Z","labels":["codegen","phase4","quality"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"openapi-generator-quq","title":"[Q2] Format-typed scalars (date-time, uuid, byte, binary, ipv4, ipv6, uri)","description":"Real-world specs use 'format' tags everywhere. Today everything collapses to String/Vec\u003cu8\u003e. This issue adds typed scalars to the generator with **on-by-default** behavior and per-format opt-out via [generator.types] TOML.\n\n## Defaults (flipped to opt-out model)\n\n| format | default strategy | rust type | opt-out |\n|---|---|---|---|\n| date-time | chrono | chrono::DateTime\u003cUtc\u003e | = \"string\" or \"time\" |\n| date | chrono | chrono::NaiveDate | = \"string\" or \"time\" |\n| time | chrono | chrono::NaiveTime | = \"string\" or \"time\" |\n| duration | chrono | chrono::Duration | = \"string\" or \"iso8601\" |\n| uuid | uuid | uuid::Uuid | = \"string\" |\n| byte | base64 | Vec\u003cu8\u003e + inline base64_serde mod | = \"string\" or \"vec_u8\" |\n| binary | bytes | bytes::Bytes | = \"string\" or \"vec_u8\" |\n| ipv4/ipv6 | std | std::net::Ipv*Addr | = \"string\" |\n| uri | url | url::Url | = \"string\" |\n| email | string (off) | String | = \"email_address\" to opt in |\n\n## Implementation\n\nGoes through new TypeMapper chokepoint (see Q2.0). Each used optional crate is reported via REQUIRED_DEPS.toml (see Q2.8).\n\n## Context\nFiles: src/analysis.rs (lines 2967, 1151), src/generator.rs, src/type_mapping.rs (new). Evidence: src/analysis.rs:2973 returns bare \"String\" for OpenApiSchemaType::String regardless of format. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.types] TOML section with per-format strategy strings.\n- [ ] Each format's default is on (typed) when crate is small/common; opt-out via = \"string\".\n- [ ] CLI --types-conservative flag sets all strategies back to \"string\" for regression bisects.\n- [ ] date-time uses chrono::serde::rfc3339 codec.\n- [ ] uuid uses uuid::Uuid with serde feature.\n- [ ] byte round-trips via base64 (inline mod base64_serde, no runtime crate).\n- [ ] binary uses bytes::Bytes with serde feature.\n- [ ] One conformance fixture per format under tests/conformance/fixtures/schema/format-*.yaml.\n- [ ] All 49 currently-compiling specs still compile under default config (i.e. with typed scalars on).\n- [ ] All 49 specs also still compile under --types-conservative.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:11:40Z","created_by":"James Lal","updated_at":"2026-05-09T08:59:12Z","started_at":"2026-05-09T06:44:01Z","closed_at":"2026-05-09T08:59:12Z","close_reason":"Q2 typed-scalar formats land with flipped defaults (chrono/uuid/url/bytes/std::net::Ip*Addr/base64+codec). TypeMappingConfig switched from Option\u003cString\u003e placeholders to enum-typed strategies (DateStrategy/UuidStrategy/ByteStrategy/...) with opt-out per format. Wired through SchemaType::Primitive's new serde_with field, surfaced via #[serde(with = ...)] in generator. base64_serde helper module (with Option submodule for nullable byte fields) emitted only when format:byte is actually used. type_lacks_default extended for chrono/url/time types. --types-conservative CLI flag collapses everything back to String for bisecting. spec-compile gate: all 54 specs pass with default typed-on config; 1 skipped (gitea, baseline). Integration suite: zero failures. New tests: 10 typed-scalar end-to-end + 7 TypeMapper unit tests. Email + duration kept off by default (email less universal; chrono::Duration's native serde is seconds, not ISO 8601 — proper duration support is a follow-up).","labels":["phase4","quality","schema"],"dependencies":[{"issue_id":"openapi-generator-quq","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:02Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"openapi-generator-99a","title":"[Q1] Method-name canonicalization","description":"Heuristic post-processor on snake-cased operationId: tokenize path template, drop trailing tokens that match path tokens (in reverse path order), drop trailing HTTP-method verb. Re-check uniqueness; restore tokens for collisions. Goal: Anthropic's betaGetFileMetadataV1FilesFileIdGet + path /v1/files/{fileId} + GET → get_file_metadata.\n\n## Context\nToday get_method_name emits op.operation_id.to_snake_case() verbatim. Anthropic's spec produces names like beta_get_file_metadata_v1_files_file_id_get — the path and HTTP method are literally appended into the operationId. See umbrella issue gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] Heuristic implemented in src/client_generator.rs:get_method_name (line ~859).\n- [ ] Unique across operation set; collisions fall back to original.\n- [ ] CLI/config flag [generator.method_names] strip_path = true (default true).\n- [ ] Snapshot tests confirm anthropic produces get_file_metadata not beta_get_file_metadata_v1_files_file_id_get.\n- [ ] All 49 currently-compiling specs still compile.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:10:47Z","created_by":"James Lal","updated_at":"2026-05-08T23:10:47Z","labels":["codegen","phase4","quality"],"dependencies":[{"issue_id":"openapi-generator-99a","depends_on_id":"openapi-generator-st8","type":"blocks","created_at":"2026-05-08T17:11:55Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-0jz","title":"Server codegen: form-exploded object query params arrive as String","description":"Follow-up to GH #27 (openapi-generator-cv4). The client now types form-exploded object query params as structs, but server codegen (src/server/codegen.rs emit_query_struct/emit_method_sig) still keys off ParameterInfo.rust_type which stays String for these params, so handlers see a String they can never receive correctly. Proper fix needs serde(flatten) of the object struct into the per-op Query extractor struct (watch serde_urlencoded flatten limitations with non-string scalars).","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:32:23Z","created_by":"James Lal","updated_at":"2026-07-12T23:32:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-vl2","title":"[Client] Selective operations option (parallel to [server].operations)","description":"Today the client generator emits methods for every operation in the spec. For users who only need a subset, a parallel '[client] operations = [\"opId\", ...]' selection would mirror the server-side opt-in.\n\nCombined with model pruning, this becomes the dual scenario: pick the ops you call (client) AND the ops you host (server), prune to the union of both reachable sets. The selector grammar from src/server/selector.rs is reusable as-is.\n\nFor now this is filed under 'maybe useful'. Most client users want every op. But the symmetric server-client design would be cleaner once it exists.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-11T03:40:20Z","created_by":"James Lal","updated_at":"2026-05-11T03:40:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-q3k","title":"[Server] Aggressive model pruning via analyzer-tracked synthetic ownership","description":"The current [server].prune_models implementation walks transitive $refs from picked ops, then keeps every schema not referenced by any $ref anywhere as a 'synthetic'. For OpenAI's spec this yields ~13% reduction because many real spec schemas are reached only via operations or multipart bodies, making 'never $ref'd' a poor synthetic signal.\n\nTo get \u003e50% reduction safely, the analyzer needs to track which synthetic enums/structs belong to which parent schema. Concretely: when analysis registers WebSearchApproximateLocationType as a synthetic of WebSearchApproximateLocation's inline 'type: enum' field, it should record the parent→synthetic edge in DependencyGraph or in AnalyzedSchema (new field 'synthesised_from: Option\u003cString\u003e').\n\nWith that edge tracked, the prune walk becomes: walk transitive $refs from picked ops, then for every kept name, also keep all schemas whose synthesised_from points at it. That's both more aggressive and more correct than the current heuristic.\n\nRelated: the analyzer's existing AnalyzedSchema.dependencies field is also incomplete (Response.deps lists ResponseError but ResponseError.deps is empty even though it has a field of type ResponseErrorCode). Same root cause — analyzer registers synthetic siblings but doesn't track ownership.","notes":"Discovered while implementing prune_models in commit (current). Conservative impl ships; aggressive impl requires analyzer changes.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-11T03:40:12Z","created_by":"James Lal","updated_at":"2026-05-11T03:40:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-in6","title":"[Server] Anthropic spec missing text/event-stream content type on messages_post","description":"Anthropic's published OpenAPI spec (specs/anthropic.yaml) declares POST /v1/messages 200 response with content-type application/json only. The real API streams when stream:true is set on the request body, but the spec never declares text/event-stream as a valid response content type.\n\nConsequence: 'server list' does not mark messages_post as [SSE], and downstream server codegen will not emit an SSE response variant for it. Both are technically correct given the spec text.\n\nMitigation options:\n1. Use the existing schema-extensions mechanism to overlay a text/event-stream response on /v1/messages.\n2. Add a config knob ('force_stream_for_operations') that promotes nominated ops to streaming regardless of declared response content.\n3. Detect that the request body has a 'stream:bool' field and auto-promote (heuristic — risky).\n\nOption 1 is the path that fits the existing project model. Add an example extension file documenting how to do this, and reference it from the server codegen docs once P6 lands.","notes":"Discovered while validating server P1 against specs/anthropic.yaml. messages_post is one of our two canonical test cases (umbrella sot, P6 9ek).","status":"closed","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:54:29Z","created_by":"James Lal","updated_at":"2026-05-11T02:41:32Z","closed_at":"2026-05-11T02:41:32Z","close_reason":"Fixed via examples/server-anthropic-messages/sse-overlay.json — declares text/event-stream on POST /v1/messages 200, which makes the generator emit MessagesPostResponse::OkStream. The Anthropic example now exercises both unary and streaming branches. Future docs/PRs should mention this pattern as the canonical fix for missing-content-type spec gaps.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/src/analysis.rs b/src/analysis.rs index d8370fa..d1e158a 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -391,6 +391,15 @@ pub struct ParameterInfo { /// Empty/none = use sanitize from `name`. #[serde(skip_serializing_if = "Option::is_none")] pub rust_ident: Option, + /// True for a query parameter whose schema is an object and whose + /// style/explode resolve to form + explode=true (the OAS 3.x defaults + /// for query). Per RFC 6570 form-explosion each object property becomes + /// its own query pair (`?color=red&size=big`); the parameter name itself + /// never appears in the query string. When set, `schema_ref` holds the + /// struct type generated/resolved for the object and the client emits + /// `req.query(&value)` instead of a single `name=value` pair. Issue #27. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub form_explode_object: bool, } impl Default for DependencyGraph { @@ -4325,7 +4334,10 @@ impl SchemaAnalyzer { // Extract parameters (operation-level first, then merge path-item-level) if let Some(parameters) = &operation.parameters { for param in parameters { - let resolved = self.resolve_parameter(param); + // into_owned: analyze_parameter needs `&mut self` (it may + // register an inline object schema for form-exploded query + // params), which can't coexist with the Cow's `&self` borrow. + let resolved = self.resolve_parameter(param).into_owned(); if let Some(param_info) = self.analyze_parameter(&resolved, operation_id)? { op_info.parameters.push(param_info); } @@ -4340,7 +4352,7 @@ impl SchemaAnalyzer { .map(|p| (p.name.clone(), p.location.clone())) .collect(); for param in path_params { - let resolved = self.resolve_parameter(param); + let resolved = self.resolve_parameter(param).into_owned(); if let Some(param_info) = self.analyze_parameter(&resolved, operation_id)? { if !existing_keys .contains(&(param_info.name.clone(), param_info.location.clone())) @@ -4399,6 +4411,7 @@ impl SchemaAnalyzer { description: None, enum_values: None, rust_ident: None, + form_explode_object: false, }); } @@ -4510,9 +4523,10 @@ impl SchemaAnalyzer { /// alongside the operation methods. See issue #10 follow-up. /// Look up `#/components/schemas/{name}` in the raw OpenAPI document and /// decide whether it's a string with enum values. Used by analyze_parameter - /// (T10) so that only string-enum refs flow through to the codegen-typed - /// parameter path; struct/object refs stay as `String` until we have - /// proper deepObject / form-style query serialization (T14). + /// (T10). String-enum refs flow through to the codegen-typed parameter + /// path; object refs are typed only when form-exploded (issue #27), and + /// other struct refs stay `String` until deepObject / explode=false + /// serialization is generated (T14). fn referenced_schema_is_string_enum(&self, name: &str) -> bool { let Some(schema_value) = self .openapi_spec @@ -4533,7 +4547,7 @@ impl SchemaAnalyzer { } fn analyze_parameter( - &self, + &mut self, param: &crate::openapi::Parameter, operation_id: &str, ) -> Result> { @@ -4546,20 +4560,43 @@ impl SchemaAnalyzer { let mut rust_type = "String".to_string(); let mut schema_ref = None; let mut enum_values: Option> = None; + let mut form_explode_object = false; + + // OAS 3.x defaults for `in: query` are style=form, and explode=true + // when the style is form. So an object query parameter with nothing + // specified is *already* form-exploded per spec (issue #27). + let form_exploded_query = location == "query" + && matches!(param.style.as_deref(), None | Some("form")) + && param.explode.unwrap_or(true); if let Some(schema) = ¶m.schema { if let Some(ref_str) = schema.reference() { // T10: keep the resolved type when the target is a string-enum // (then `Display`/`as_str` are emitted, see generate_string_enum). - // For struct/object refs we fall back to `String` here — those - // need deepObject / form / serde_urlencoded handling that's - // not yet generated; emitting the typed name would produce - // `(struct).to_string()` and not compile. + // Object refs on form-exploded query params keep the resolved + // struct type too — the client serializes each property as its + // own query pair via `req.query(&value)` (issue #27). Other + // struct/object refs (deepObject, explode=false) still fall + // back to `String` — those wire formats aren't generated yet. if let Some(name) = self.extract_schema_name(ref_str) { if self.referenced_schema_is_string_enum(name) { schema_ref = Some(name.to_string()); + } else if form_exploded_query && self.referenced_schema_is_object(name) { + schema_ref = Some(name.to_string()); + form_explode_object = true; } } + } else if form_exploded_query && Self::schema_is_inline_object(schema) { + // Inline object schema on a form-exploded query parameter: + // synthesize a struct (e.g. `FindWidgetsFilter`) so the + // caller passes typed fields instead of a pre-encoded string. + let op_pascal = operation_id.replace('.', "_").to_pascal_case(); + let param_pascal = name.to_pascal_case(); + let synthetic_name = format!("{op_pascal}{param_pascal}"); + let mut deps = HashSet::new(); + self.add_inline_schema(&synthetic_name, schema, &mut deps)?; + schema_ref = Some(synthetic_name); + form_explode_object = true; } else if let Some(schema_type) = schema.schema_type() { // Route integer/number through the same TypeMapper the schema // property path uses (see analyze_property), so `format: int32` @@ -4604,6 +4641,34 @@ impl SchemaAnalyzer { description: param.description.clone(), enum_values, rust_ident: None, + form_explode_object, })) } + + /// True when `#/components/schemas/{name}` is an object schema — declared + /// `type: object` or, absent a `type`, carrying `properties`. Used to + /// decide whether a $ref query parameter can be form-exploded (issue #27). + fn referenced_schema_is_object(&self, name: &str) -> bool { + let Some(schema_value) = self + .openapi_spec + .get("components") + .and_then(|c| c.get("schemas")) + .and_then(|s| s.get(name)) + else { + return false; + }; + match schema_value.get("type").and_then(|v| v.as_str()) { + Some(t) => t == "object", + None => schema_value.get("properties").is_some(), + } + } + + /// Inline-schema counterpart of [`Self::referenced_schema_is_object`]. + fn schema_is_inline_object(schema: &crate::openapi::Schema) -> bool { + match schema.schema_type() { + Some(crate::openapi::SchemaType::Object) => true, + None => schema.details().properties.is_some(), + _ => false, + } + } } diff --git a/src/client_generator.rs b/src/client_generator.rs index 887a48f..5532a8f 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -769,12 +769,32 @@ impl CodeGenerator { } let mut param_building = Vec::new(); + let mut exploded_objects = Vec::new(); for param in query_params { // Use snake_case for Rust variable name with keyword escaping let param_name_snake = self.param_ident_str(param); let param_name = Self::to_field_ident(¶m_name_snake); + if param.form_explode_object { + // form-exploded object (issue #27): reqwest serializes the + // struct through serde_urlencoded, so each property becomes + // its own `key=value` pair. The parameter's own name never + // appears in the query string per RFC 6570 form-explosion. + if param.required { + exploded_objects.push(quote! { + req = req.query(&#param_name); + }); + } else { + exploded_objects.push(quote! { + if let Some(v) = #param_name { + req = req.query(&v); + } + }); + } + continue; + } + // Use the original parameter name from OpenAPI spec as the query string key let param_key = ¶m.name; @@ -807,15 +827,26 @@ impl CodeGenerator { } } - quote! { - // Add query parameters - { - let mut query_params: Vec<(&str, String)> = Vec::new(); - #(#param_building)* - if !query_params.is_empty() { - req = req.query(&query_params); + // Ops whose query params are all form-exploded objects skip the + // pair-vector block entirely. + let pairs_block = if param_building.is_empty() { + quote! {} + } else { + quote! { + { + let mut query_params: Vec<(&str, String)> = Vec::new(); + #(#param_building)* + if !query_params.is_empty() { + req = req.query(&query_params); + } } } + }; + + quote! { + // Add query parameters + #pairs_block + #(#exploded_objects)* } } diff --git a/tests/exploded_query_params_test.rs b/tests/exploded_query_params_test.rs new file mode 100644 index 0000000..0faa7cb --- /dev/null +++ b/tests/exploded_query_params_test.rs @@ -0,0 +1,285 @@ +//! Regression tests for https://github.com/gpu-cli/openapi-to-rust/issues/27 +//! +//! Query parameters with object schemas and form-explode semantics previously +//! mapped to `Option>` and were sent as a single opaque +//! `name=` pair. Per OAS 3.x (style=form + explode=true are the +//! defaults for `in: query`) and RFC 6570 form-explosion, each object property +//! must become its own query pair: `?color=red&size=big` — the parameter name +//! itself never appears in the query string. +//! +//! Now: the analyzer types those parameters as a struct (synthesized for +//! inline schemas, resolved for $refs) and the client emits `req.query(&v)`, +//! which reqwest/serde_urlencoded serializes property-by-property. Styles we +//! don't generate yet (deepObject, form with explode=false) keep the string +//! fallback. +//! +//! Method-body assertions use the raw token-stream spacing +//! (`filter : Option < FindWidgetsFilter >`), matching +//! `parameter_integer_format_test.rs`. + +use openapi_to_rust::{CodeGenerator, GeneratorConfig, analysis::SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::path::PathBuf; + +fn config() -> GeneratorConfig { + GeneratorConfig { + spec_path: PathBuf::from("test.json"), + output_dir: PathBuf::from("test_output"), + module_name: "test".to_string(), + enable_async_client: true, + ..Default::default() + } +} + +/// Token-spaced client method text for assertions on signatures/bodies. +fn generate_methods(spec: Value) -> String { + let mut analyzer = SchemaAnalyzer::new(spec).expect("analyzer construction"); + let analysis = analyzer.analyze().expect("analysis"); + let generator = CodeGenerator::new(config()); + generator.generate_operation_methods(&analysis).to_string() +} + +/// Full pretty-printed types output, for asserting emitted model structs. +fn generate_types(spec: Value) -> String { + let mut analyzer = SchemaAnalyzer::new(spec).expect("analyzer construction"); + let mut analysis = analyzer.analyze().expect("analysis"); + let generator = CodeGenerator::new(config()); + generator.generate(&mut analysis).expect("generation") +} + +fn spec_with_filter_param(param: Value) -> Value { + json!({ + "openapi": "3.0.3", + "info": {"title": "Exploded Query Param Repro", "version": "1.0.0"}, + "paths": { + "/widgets": { + "get": { + "operationId": "findWidgets", + "parameters": [param], + "responses": {"200": {"description": "OK"}} + } + } + } + }) +} + +fn inline_object_schema() -> Value { + json!({ + "type": "object", + "properties": { + "color": {"type": "string"}, + "size": {"type": "integer"} + } + }) +} + +#[test] +fn inline_object_with_explicit_form_explode_generates_struct_param() { + let spec = spec_with_filter_param(json!({ + "name": "filter", + "in": "query", + "style": "form", + "explode": true, + "schema": inline_object_schema() + })); + let code = generate_methods(spec.clone()); + + assert!( + code.contains("filter : Option < FindWidgetsFilter >"), + "exploded object param should be typed as the synthesized struct; got:\n{code}" + ); + assert!( + code.contains("req = req . query (& v)"), + "optional exploded param must serialize via req.query(&v); got:\n{code}" + ); + assert!( + !code.contains("(\"filter\" , v . as_ref () . to_string ())"), + "exploded param must not be sent as a single `filter=` pair; got:\n{code}" + ); + + let types = generate_types(spec); + assert!( + types.contains("pub struct FindWidgetsFilter"), + "synthesized struct for the inline object schema must be emitted; got:\n{types}" + ); + assert!( + types.contains("pub color: Option"), + "struct must carry the object's properties; got:\n{types}" + ); +} + +#[test] +fn object_query_param_defaults_to_form_explode() { + // OAS defaults for `in: query` are style=form, explode=true — an object + // param with neither specified is already exploded per spec. + let code = generate_methods(spec_with_filter_param(json!({ + "name": "filter", + "in": "query", + "schema": inline_object_schema() + }))); + + assert!( + code.contains("filter : Option < FindWidgetsFilter >"), + "object query param with default style/explode should be exploded; got:\n{code}" + ); + assert!( + code.contains("req = req . query (& v)"), + "default-exploded param must serialize via req.query(&v); got:\n{code}" + ); +} + +#[test] +fn required_exploded_object_param_is_not_option() { + let code = generate_methods(spec_with_filter_param(json!({ + "name": "filter", + "in": "query", + "required": true, + "schema": inline_object_schema() + }))); + + assert!( + code.contains("filter : FindWidgetsFilter"), + "required exploded param should be a bare struct arg; got:\n{code}" + ); + assert!( + code.contains("req = req . query (& filter)"), + "required exploded param must serialize unconditionally; got:\n{code}" + ); +} + +#[test] +fn ref_object_query_param_uses_referenced_struct() { + let mut spec = spec_with_filter_param(json!({ + "name": "filter", + "in": "query", + "explode": true, + "schema": {"$ref": "#/components/schemas/WidgetFilter"} + })); + spec["components"] = json!({ + "schemas": { + "WidgetFilter": { + "type": "object", + "properties": {"color": {"type": "string"}} + } + } + }); + let code = generate_methods(spec); + + assert!( + code.contains("filter : Option < WidgetFilter >"), + "$ref exploded object param should use the referenced struct; got:\n{code}" + ); + assert!( + code.contains("req = req . query (& v)"), + "$ref exploded param must serialize via req.query(&v); got:\n{code}" + ); +} + +#[test] +fn explode_false_keeps_string_fallback() { + // form + explode=false (`?filter=color,red,size,big`) isn't generated + // yet — the parameter must keep the pre-#27 string passthrough. + let code = generate_methods(spec_with_filter_param(json!({ + "name": "filter", + "in": "query", + "explode": false, + "schema": inline_object_schema() + }))); + + assert!( + code.contains("filter : Option < impl AsRef < str > >"), + "explode=false object param should keep the string fallback; got:\n{code}" + ); + assert!( + !code.contains("FindWidgetsFilter"), + "no struct should be synthesized for explode=false; got:\n{code}" + ); +} + +#[test] +fn deep_object_style_keeps_string_fallback() { + // deepObject (`?filter[color]=red`) isn't generated yet — string fallback. + let code = generate_methods(spec_with_filter_param(json!({ + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": inline_object_schema() + }))); + + assert!( + code.contains("filter : Option < impl AsRef < str > >"), + "deepObject param should keep the string fallback; got:\n{code}" + ); +} + +/// Runtime proof of the mechanism the generated code relies on: reqwest +/// serializes a struct passed to `.query(&v)` through serde_urlencoded, +/// yielding one `key=value` pair per set property and omitting `None`s — +/// i.e. exactly RFC 6570 form-explosion for flat objects. +#[test] +fn reqwest_query_serializes_struct_as_exploded_pairs() { + #[derive(serde::Serialize)] + struct FindWidgetsFilter { + #[serde(skip_serializing_if = "Option::is_none")] + color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + size: Option, + } + + let filter = FindWidgetsFilter { + color: Some("red".to_string()), + size: Some(5), + }; + let req = reqwest::Client::new() + .get("https://api.example.com/widgets") + .query(&filter) + .build() + .expect("request builds"); + assert_eq!( + req.url().as_str(), + "https://api.example.com/widgets?color=red&size=5" + ); + + let empty = FindWidgetsFilter { + color: None, + size: None, + }; + let req = reqwest::Client::new() + .get("https://api.example.com/widgets") + .query(&empty) + .build() + .expect("request builds"); + assert_eq!( + req.url().as_str(), + "https://api.example.com/widgets", + "all-None struct must not leave a dangling `?`" + ); +} + +#[test] +fn exploded_and_plain_query_params_coexist() { + let mut spec = spec_with_filter_param(json!({ + "name": "filter", + "in": "query", + "schema": inline_object_schema() + })); + spec["paths"]["/widgets"]["get"]["parameters"] + .as_array_mut() + .expect("parameters array") + .push(json!({ + "name": "limit", + "in": "query", + "schema": {"type": "integer"} + })); + let code = generate_methods(spec); + + assert!( + code.contains("(\"limit\" , v . to_string ())"), + "plain scalar param must still go through the pair vector; got:\n{code}" + ); + assert!( + code.contains("req = req . query (& v)"), + "exploded param must still serialize via req.query(&v); got:\n{code}" + ); +} From 512c9c091606c91cb9364bf6450e3ae2832dad33 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 12 Jul 2026 17:35:35 -0600 Subject: [PATCH 2/7] chore(beads): track GH #27 work (PR #28) and follow-up issues Co-Authored-By: Claude Fable 5 --- .beads/issues.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index db1f1b7..ee882db 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"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.","status":"in_progress","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-12T23:22:49Z","started_at":"2026-07-12T23:22:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"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":"in_progress","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-12T23:35:21Z","started_at":"2026-07-12T23:22:49Z","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-anu","title":"Query param styles not yet generated: deepObject, form explode=false, exploded arrays","description":"Follow-up to GH #27 (openapi-generator-cv4). Client now supports form-exploded object query params (?color=red). Still on the string fallback: style=deepObject (?filter[color]=red — Stripe uses this heavily, 1600+ occurrences), form+explode=false objects (?filter=color,red,size,big), and typed exploded arrays (?tags=a\u0026tags=b currently a single string param). Each needs analyzer typing + client serialization.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:32:14Z","created_by":"James Lal","updated_at":"2026-07-12T23:32:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} From 5ba0c68f38fa6cbb2549d02b6b631efe0a14c7b9 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 12 Jul 2026 17:49:25 -0600 Subject: [PATCH 3/7] feat(client): typed query serialization for all form/deepObject styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes T14 for the client (openapi-generator-anu), building on the form-exploded object support from GH #27: - QuerySerialization enum on ParameterInfo replaces the form_explode_object bool; the analyzer assigns it from style/explode + schema shape per OAS 3.x defaults - form + explode=false objects: struct arg, one comma-joined ?filter=k1,v1,k2,v2 pair - deepObject objects: struct arg, bracketed ?filter[color]=red pairs (percent-encoded by reqwest; runtime-pinned in tests) - form arrays: Vec args — explode=true repeats ?tags=a&tags=b, explode=false joins ?tags=a,b,c, empty vecs omitted; T maps scalars through the TypeMapper and $ref items to generated string enums (which emit Display) - still on the string fallback: deepObject arrays (undefined in OAS; stripe expand[]), space/pipeDelimited, arrays of objects - README gains a pre-1.0 breaking-changes log (user request): these are signature-level breaks on regeneration, replacing silently-wrong wire output; ship as 0.6.0 Server codegen and registry still see rust_type=String for all of these (openapi-generator-0jz). Co-Authored-By: Claude Fable 5 --- .beads/issues.jsonl | 2 +- README.md | 30 +++++ src/analysis.rs | 166 ++++++++++++++++++----- src/client_generator.rs | 195 +++++++++++++++++++++++++--- tests/exploded_query_params_test.rs | 178 ++++++++++++++++++++++--- 5 files changed, 502 insertions(+), 69 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ee882db..cc1773e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,7 +1,7 @@ {"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":"in_progress","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-12T23:35:21Z","started_at":"2026-07-12T23:22:49Z","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-anu","title":"Query param styles not yet generated: deepObject, form explode=false, exploded arrays","description":"Follow-up to GH #27 (openapi-generator-cv4). Client now supports form-exploded object query params (?color=red). Still on the string fallback: style=deepObject (?filter[color]=red — Stripe uses this heavily, 1600+ occurrences), form+explode=false objects (?filter=color,red,size,big), and typed exploded arrays (?tags=a\u0026tags=b currently a single string param). Each needs analyzer typing + client serialization.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:32:14Z","created_by":"James Lal","updated_at":"2026-07-12T23:32:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-anu","title":"Query param styles not yet generated: deepObject, form explode=false, exploded arrays","description":"Follow-up to GH #27 (openapi-generator-cv4). Client now supports form-exploded object query params (?color=red). Still on the string fallback: style=deepObject (?filter[color]=red — Stripe uses this heavily, 1600+ occurrences), form+explode=false objects (?filter=color,red,size,big), and typed exploded arrays (?tags=a\u0026tags=b currently a single string param). Each needs analyzer typing + client serialization.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:32:14Z","created_by":"James Lal","updated_at":"2026-07-12T23:44:58Z","started_at":"2026-07-12T23:44:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-cwd","title":"Config docs say [generator.types] but parser reads top-level [types] — documented block is silently ignored","description":"README (§'[generator.types] — typed-scalar strategy per format', also [generator.types.strategies]) and the doc comment on ConfigFile::types say the type-mapping block lives at [generator.types], but ConfigFile deserializes it from top-level [types]. A TOML file using the documented path is silently ignored (unknown fields aren't denied), so users think they enabled e.g. date = \"time\" but get chrono defaults. Found while testing GH #25. Fix: either move the field under GeneratorSection or fix README + doc comment, and consider deny_unknown_fields or a validation warning.","status":"open","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:47:50Z","created_by":"James Lal","updated_at":"2026-07-11T18:47:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-k07","title":"[bug] Query/path parameters ignore integer/number format (int32 -\u003e i64)","description":"analyze_parameter in src/analysis.rs hardcodes Integer =\u003e i64 and Number =\u003e f64, ignoring the schema format field. Body/schema properties correctly route through TypeMapper::integer_format/number_format, so int32 becomes i32 there, but the same format on a query/path parameter always yields i64. No type_mappings/strategies config can override it because analyze_parameter never calls TypeMapper. Fixes GitHub issue #23.","notes":"Fix in PR #24 (gpu-cli/openapi-to-rust), branch worktree-fix-i32-param-format. Root cause: analyze_parameter hardcoded Integer=\u003ei64 / Number=\u003ef64, bypassing TypeMapper. Now routes through integer_format/number_format. CI green (test/clippy/fmt/doc/spec-compile). Commented on GH issue #23. Awaiting review/merge.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T03:12:09Z","created_by":"James Lal","updated_at":"2026-07-11T03:29:45Z","started_at":"2026-07-11T03:12:21Z","closed_at":"2026-07-11T03:29:45Z","close_reason":"Fixed in PR #24, merged to main, released as v0.5.2 (crates.io + GitHub release). GH issue #23 commented.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-76h","title":"Add ByteStrategy::Base64UrlUnpadded for format: byte","description":"Add a new ByteStrategy variant emitting RFC 7515 §2 url-safe unpadded base64 in the inlined base64_serde helper. Needed by gpu-cli portal-schema work (JWK/DPoP thumbprints, sealing pubkeys) so format: byte fields can round-trip without falling back to plain String + spec-side documentation. Spec-global config knob, not per-field.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-16T17:58:36Z","created_by":"James Lal","updated_at":"2026-05-16T18:29:10Z","started_at":"2026-05-16T17:58:50Z","closed_at":"2026-05-16T18:29:10Z","close_reason":"Shipped on worktree-base64url-unpadded (commit afc8c88). 16 typed_scalars_test, 23 type_mapping unit tests, 51-binary cargo test suite all green; clippy --lib clean.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index 22652ab..aeb6e3d 100644 --- a/README.md +++ b/README.md @@ -551,6 +551,36 @@ cargo run -p openapi-to-rust -- generate --config examples/server-anthropic-mess cargo run --manifest-path examples/server-anthropic-messages/Cargo.toml ``` +## Breaking changes (pre-1.0) + +Until 1.0.0, a minor version bump may change the generated API surface — +usually because the previous output was wrong on the wire. Regenerating makes +the compiler point at every affected call site; there is no silent behavior +change without a signature change. + +### 0.6.0 (unreleased) + +Object- and array-schema **query parameters** are now serialized according to +their OpenAPI `style`/`explode` (issue [#27](https://github.com/gpu-cli/openapi-to-rust/issues/27)). +Previously every such parameter was `Option>` and the caller's +string went out as a single opaque `name=` pair — which no server +expecting the declared style could parse. Signatures change as follows: + +| Parameter shape | Old argument | New argument | Wire format | +|---|---|---|---| +| object, form + explode=true (OAS defaults) | `Option>` | `Option` | `?color=red&size=5` | +| object, form + explode=false | `Option>` | `Option` | `?filter=color,red,size,5` | +| object, deepObject | `Option>` | `Option` | `?filter[color]=red` | +| array, form + explode=true (OAS defaults) | `Option>` | `Option>` | `?tags=a&tags=b` | +| array, form + explode=false | `Option>` | `Option>` | `?tags=a,b,c` | + +`Struct` is the referenced component model for `$ref` schemas or a +synthesized `{Operation}{Param}` struct for inline objects. `T` is the scalar +item type (via the same type mapping as properties) or the referenced +string-enum model. Unchanged (still the opaque string passthrough): deepObject +arrays, `spaceDelimited`/`pipeDelimited`, arrays of objects, and server-side +extraction (tracked separately). + ## Release notes ### 0.5 (this release) diff --git a/src/analysis.rs b/src/analysis.rs index d1e158a..aacff26 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -391,15 +391,52 @@ pub struct ParameterInfo { /// Empty/none = use sanitize from `name`. #[serde(skip_serializing_if = "Option::is_none")] pub rust_ident: Option, - /// True for a query parameter whose schema is an object and whose - /// style/explode resolve to form + explode=true (the OAS 3.x defaults - /// for query). Per RFC 6570 form-explosion each object property becomes - /// its own query pair (`?color=red&size=big`); the parameter name itself - /// never appears in the query string. When set, `schema_ref` holds the - /// struct type generated/resolved for the object and the client emits - /// `req.query(&value)` instead of a single `name=value` pair. Issue #27. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub form_explode_object: bool, + /// Wire serialization for object/array query parameters, decided from + /// the parameter's `style`/`explode` and schema shape (T14, GH #27). + /// `None` = plain single `name=value` pair (scalars, string enums, and + /// the opaque-string fallback for styles that aren't generated yet). + /// For the object modes, `schema_ref` holds the struct type + /// generated/resolved for the object schema. + #[serde(skip_serializing_if = "Option::is_none")] + pub query_serialization: Option, +} + +/// How the client serializes an object- or array-schema query parameter +/// onto the request URL. Consumed by `ClientGenerator::generate_query_params` +/// and `get_param_rust_type`; server codegen and the registry deliberately +/// keep the `String` fallback for now (openapi-generator-0jz). +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub enum QuerySerialization { + /// style=form + explode=true object (the OAS 3.x defaults for query): + /// each property is its own pair — `?color=red&size=big`. The parameter + /// name never appears in the query string (RFC 6570 form-explosion). + FormExplodedObject, + /// style=form + explode=false object: one comma-joined key,value list — + /// `?filter=color,red,size,big`. + FormObject, + /// style=deepObject (explode=true) object: bracketed keys — + /// `?filter[color]=red`. + DeepObject, + /// style=form + explode=true array: repeated pairs — `?tags=a&tags=b`. + /// Parameter typed `Vec`. + FormExplodedArray { item_type: ArrayItemType }, + /// style=form + explode=false array: one comma-joined pair — + /// `?tags=a,b,c`. Parameter typed `Vec`. + FormArray { item_type: ArrayItemType }, +} + +/// Item type of a typed array query parameter. The two variants need +/// different handling in codegen: scalars are already Rust type strings +/// (possibly paths like `rust_decimal::Decimal` from `[type_mappings]`), +/// while enum refs are raw *schema names* that must run through +/// `to_rust_type_name` sanitization (cloudflare: +/// `resource-sharing_resource_type`). +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub enum ArrayItemType { + /// A Rust scalar type string from the TypeMapper (`String`, `i32`, …). + Scalar(String), + /// The schema name of a referenced string enum (emits `Display`). + EnumRef(String), } impl Default for DependencyGraph { @@ -4411,7 +4448,7 @@ impl SchemaAnalyzer { description: None, enum_values: None, rust_ident: None, - form_explode_object: false, + query_serialization: None, }); } @@ -4560,43 +4597,79 @@ impl SchemaAnalyzer { let mut rust_type = "String".to_string(); let mut schema_ref = None; let mut enum_values: Option> = None; - let mut form_explode_object = false; - - // OAS 3.x defaults for `in: query` are style=form, and explode=true - // when the style is form. So an object query parameter with nothing - // specified is *already* form-exploded per spec (issue #27). - let form_exploded_query = location == "query" - && matches!(param.style.as_deref(), None | Some("form")) - && param.explode.unwrap_or(true); + let mut query_serialization: Option = None; + + // OAS 3.x style/explode resolution for `in: query`. Defaults are + // style=form and — for form only — explode=true, so an object/array + // query parameter with nothing specified is already form-exploded + // per spec (issue #27). deepObject is only defined with explode=true; + // an explicit explode=false there is undefined and keeps the fallback. + let is_query = location == "query"; + let form_style = matches!(param.style.as_deref(), None | Some("form")); + let form_exploded = form_style && param.explode.unwrap_or(true); + let deep_object = + param.style.as_deref() == Some("deepObject") && param.explode != Some(false); + + let object_serialization = if !is_query { + None + } else if deep_object { + Some(QuerySerialization::DeepObject) + } else if form_exploded { + Some(QuerySerialization::FormExplodedObject) + } else if form_style { + Some(QuerySerialization::FormObject) + } else { + None + }; if let Some(schema) = ¶m.schema { if let Some(ref_str) = schema.reference() { // T10: keep the resolved type when the target is a string-enum // (then `Display`/`as_str` are emitted, see generate_string_enum). - // Object refs on form-exploded query params keep the resolved - // struct type too — the client serializes each property as its - // own query pair via `req.query(&value)` (issue #27). Other - // struct/object refs (deepObject, explode=false) still fall - // back to `String` — those wire formats aren't generated yet. + // Object refs on query params with a generated wire style keep + // the resolved struct type too (T14/issue #27); anything else + // stays on the opaque `String` fallback. if let Some(name) = self.extract_schema_name(ref_str) { if self.referenced_schema_is_string_enum(name) { schema_ref = Some(name.to_string()); - } else if form_exploded_query && self.referenced_schema_is_object(name) { + } else if object_serialization.is_some() + && self.referenced_schema_is_object(name) + { schema_ref = Some(name.to_string()); - form_explode_object = true; + query_serialization = object_serialization.clone(); } } - } else if form_exploded_query && Self::schema_is_inline_object(schema) { - // Inline object schema on a form-exploded query parameter: - // synthesize a struct (e.g. `FindWidgetsFilter`) so the - // caller passes typed fields instead of a pre-encoded string. + } else if object_serialization.is_some() && Self::schema_is_inline_object(schema) { + // Inline object schema on a query parameter with a generated + // wire style: synthesize a struct (e.g. `FindWidgetsFilter`) + // so the caller passes typed fields instead of a pre-encoded + // string. let op_pascal = operation_id.replace('.', "_").to_pascal_case(); let param_pascal = name.to_pascal_case(); let synthetic_name = format!("{op_pascal}{param_pascal}"); let mut deps = HashSet::new(); self.add_inline_schema(&synthetic_name, schema, &mut deps)?; schema_ref = Some(synthetic_name); - form_explode_object = true; + query_serialization = object_serialization.clone(); + } else if is_query + && form_style + && matches!( + schema.schema_type(), + Some(crate::openapi::SchemaType::Array) + ) + && let Some(item_type) = self.array_param_item_type(schema) + { + // Typed form-style array (openapi-generator-anu): the client + // takes `Vec` and emits repeated (explode=true) or + // comma-joined (explode=false) pairs. `rust_type` deliberately + // stays "String": server codegen and the registry key off it + // (openapi-generator-0jz). Arrays whose items don't type + // (objects, nested arrays) fall through to the fallback. + query_serialization = Some(if form_exploded { + QuerySerialization::FormExplodedArray { item_type } + } else { + QuerySerialization::FormArray { item_type } + }); } else if let Some(schema_type) = schema.schema_type() { // Route integer/number through the same TypeMapper the schema // property path uses (see analyze_property), so `format: int32` @@ -4641,10 +4714,41 @@ impl SchemaAnalyzer { description: param.description.clone(), enum_values, rust_ident: None, - form_explode_object, + query_serialization, })) } + /// Rust item type for a typed array query parameter + /// (openapi-generator-anu). Scalar items map through the TypeMapper; + /// $ref items resolve only when the target is a generated string enum + /// (those emit `Display`, so `item.to_string()` works in the client). + /// Anything else — objects, nested arrays — returns None and the + /// parameter keeps the opaque-string fallback. Inline-enum'd string + /// items stay plain `String`: the op-scoped enum synthesis (issue #10) + /// is wired for scalar params only. + fn array_param_item_type(&self, schema: &crate::openapi::Schema) -> Option { + let items = schema.details().items.as_deref()?; + if let Some(ref_str) = items.reference() { + let name = self.extract_schema_name(ref_str)?; + return self + .referenced_schema_is_string_enum(name) + .then(|| ArrayItemType::EnumRef(name.to_string())); + } + let format = items.details().format.clone(); + let scalar = match items.schema_type()? { + crate::openapi::SchemaType::String => "String".to_string(), + crate::openapi::SchemaType::Integer => { + self.type_mapper.integer_format(format.as_deref()).rust_type + } + crate::openapi::SchemaType::Number => { + self.type_mapper.number_format(format.as_deref()).rust_type + } + crate::openapi::SchemaType::Boolean => "bool".to_string(), + _ => return None, + }; + Some(ArrayItemType::Scalar(scalar)) + } + /// True when `#/components/schemas/{name}` is an object schema — declared /// `type: object` or, absent a `type`, carrying `properties`. Used to /// decide whether a $ref query parameter can be form-exploded (issue #27). diff --git a/src/client_generator.rs b/src/client_generator.rs index 5532a8f..f26df12 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -769,35 +769,165 @@ impl CodeGenerator { } let mut param_building = Vec::new(); - let mut exploded_objects = Vec::new(); + // Serialization applied on `req` directly, after the pair-vector + // block: form-exploded objects and deepObject objects, whose keys + // aren't the static parameter name. + let mut req_appends = Vec::new(); for param in query_params { + use crate::analysis::QuerySerialization; + // Use snake_case for Rust variable name with keyword escaping let param_name_snake = self.param_ident_str(param); let param_name = Self::to_field_ident(¶m_name_snake); - if param.form_explode_object { - // form-exploded object (issue #27): reqwest serializes the - // struct through serde_urlencoded, so each property becomes - // its own `key=value` pair. The parameter's own name never - // appears in the query string per RFC 6570 form-explosion. - if param.required { - exploded_objects.push(quote! { - req = req.query(&#param_name); - }); - } else { - exploded_objects.push(quote! { - if let Some(v) = #param_name { - req = req.query(&v); + // Use the original parameter name from OpenAPI spec as the query string key + let param_key = ¶m.name; + + match ¶m.query_serialization { + Some(QuerySerialization::FormExplodedObject) => { + // Issue #27: reqwest serializes the struct through + // serde_urlencoded, so each property becomes its own + // `key=value` pair; the parameter's own name never + // appears in the query string (RFC 6570 form-explosion). + if param.required { + req_appends.push(quote! { + req = req.query(&#param_name); + }); + } else { + req_appends.push(quote! { + if let Some(v) = #param_name { + req = req.query(&v); + } + }); + } + continue; + } + Some(QuerySerialization::DeepObject) => { + // `?filter[color]=red&filter[size]=5`. Property values + // stringify through their JSON form; Null (unset + // Option) properties are skipped. + let apply = quote! { + if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(&v) { + let mut deep_params: Vec<(String, String)> = Vec::new(); + for (k, val) in map { + let s = match val { + serde_json::Value::Null => continue, + serde_json::Value::String(s) => s, + other => other.to_string(), + }; + deep_params.push((format!("{}[{}]", #param_key, k), s)); + } + if !deep_params.is_empty() { + req = req.query(&deep_params); + } } - }); + }; + if param.required { + req_appends.push(quote! { + { + let v = #param_name; + #apply + } + }); + } else { + req_appends.push(quote! { + if let Some(v) = #param_name { + #apply + } + }); + } + continue; } - continue; + Some(QuerySerialization::FormObject) => { + // `?filter=color,red,size,big` — one pair whose value is + // the comma-joined key,value list (RFC 6570 form, + // explode=false). + let apply = quote! { + if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(&v) { + let mut parts: Vec = Vec::new(); + for (k, val) in map { + let s = match val { + serde_json::Value::Null => continue, + serde_json::Value::String(s) => s, + other => other.to_string(), + }; + parts.push(k); + parts.push(s); + } + if !parts.is_empty() { + query_params.push((#param_key, parts.join(","))); + } + } + }; + if param.required { + param_building.push(quote! { + { + let v = #param_name; + #apply + } + }); + } else { + param_building.push(quote! { + if let Some(v) = #param_name { + #apply + } + }); + } + continue; + } + Some(QuerySerialization::FormExplodedArray { .. }) => { + // `?tags=a&tags=b` — one pair per element. + if param.required { + param_building.push(quote! { + for item in #param_name { + query_params.push((#param_key, item.to_string())); + } + }); + } else { + param_building.push(quote! { + if let Some(v) = #param_name { + for item in v { + query_params.push((#param_key, item.to_string())); + } + } + }); + } + continue; + } + Some(QuerySerialization::FormArray { .. }) => { + // `?tags=a,b,c` — one comma-joined pair. Empty vectors + // are omitted entirely. + let apply = quote! { + if !v.is_empty() { + query_params.push(( + #param_key, + v.iter() + .map(|item| item.to_string()) + .collect::>() + .join(","), + )); + } + }; + if param.required { + param_building.push(quote! { + { + let v = #param_name; + #apply + } + }); + } else { + param_building.push(quote! { + if let Some(v) = #param_name { + #apply + } + }); + } + continue; + } + None => {} } - // Use the original parameter name from OpenAPI spec as the query string key - let param_key = ¶m.name; - if param.required { // Required parameters: always add if Self::param_uses_as_ref_str(param) { @@ -827,7 +957,7 @@ impl CodeGenerator { } } - // Ops whose query params are all form-exploded objects skip the + // Ops whose query params all serialize on `req` directly skip the // pair-vector block entirely. let pairs_block = if param_building.is_empty() { quote! {} @@ -846,7 +976,7 @@ impl CodeGenerator { quote! { // Add query parameters #pairs_block - #(#exploded_objects)* + #(#req_appends)* } } @@ -1040,6 +1170,29 @@ impl CodeGenerator { /// Get the Rust type for a parameter fn get_param_rust_type(&self, param: &crate::analysis::ParameterInfo) -> TokenStream { + use crate::analysis::QuerySerialization; + // Typed form-style arrays take Vec (openapi-generator-anu). + // Scalars parse as-is (they may be type paths from [type_mappings]); + // enum refs are raw schema names and go through the same + // to_rust_type_name sanitization as every other schema reference + // (cloudflare has enum schemas like `resource-sharing_resource_type`). + if let Some( + QuerySerialization::FormExplodedArray { item_type } + | QuerySerialization::FormArray { item_type }, + ) = ¶m.query_serialization + { + use crate::analysis::ArrayItemType; + let item_ty: syn::Type = match item_type { + ArrayItemType::Scalar(rust_type) => syn::parse_str(rust_type) + .unwrap_or_else(|_| panic!("invalid scalar item type `{rust_type}`")), + ArrayItemType::EnumRef(schema_name) => { + let rust_name = self.to_rust_type_name(schema_name); + syn::parse_str(&rust_name) + .unwrap_or_else(|_| panic!("invalid enum item type `{rust_name}`")) + } + }; + return quote! { Vec<#item_ty> }; + } // T10: $ref-typed parameters used to lose their type because we only // consulted `rust_type` (which stays "String"). Now: prefer the // resolved schema reference if present. diff --git a/tests/exploded_query_params_test.rs b/tests/exploded_query_params_test.rs index 0faa7cb..d0996ee 100644 --- a/tests/exploded_query_params_test.rs +++ b/tests/exploded_query_params_test.rs @@ -7,11 +7,13 @@ //! must become its own query pair: `?color=red&size=big` — the parameter name //! itself never appears in the query string. //! -//! Now: the analyzer types those parameters as a struct (synthesized for -//! inline schemas, resolved for $refs) and the client emits `req.query(&v)`, -//! which reqwest/serde_urlencoded serializes property-by-property. Styles we -//! don't generate yet (deepObject, form with explode=false) keep the string -//! fallback. +//! Now (T14 / openapi-generator-anu): the analyzer assigns each object/array +//! query param a `QuerySerialization` from its style/explode + schema shape, +//! and the client generates the matching wire format — form-exploded objects +//! via `req.query(&v)`, explode=false objects comma-joined, deepObject +//! objects with bracketed keys, form arrays as `Vec` (repeated or +//! comma-joined pairs). Shapes with no defined/wired format (deepObject +//! arrays, arrays of objects, space/pipeDelimited) keep the string fallback. //! //! Method-body assertions use the raw token-stream spacing //! (`filter : Option < FindWidgetsFilter >`), matching @@ -176,9 +178,9 @@ fn ref_object_query_param_uses_referenced_struct() { } #[test] -fn explode_false_keeps_string_fallback() { - // form + explode=false (`?filter=color,red,size,big`) isn't generated - // yet — the parameter must keep the pre-#27 string passthrough. +fn explode_false_object_serializes_comma_joined() { + // form + explode=false object: `?filter=color,red,size,big` — one pair, + // comma-joined key,value list (RFC 6570 form without explosion). let code = generate_methods(spec_with_filter_param(json!({ "name": "filter", "in": "query", @@ -187,18 +189,22 @@ fn explode_false_keeps_string_fallback() { }))); assert!( - code.contains("filter : Option < impl AsRef < str > >"), - "explode=false object param should keep the string fallback; got:\n{code}" + code.contains("filter : Option < FindWidgetsFilter >"), + "explode=false object param should be typed as the synthesized struct; got:\n{code}" + ); + assert!( + code.contains("parts . join (\",\")"), + "explode=false object must comma-join key,value parts; got:\n{code}" ); assert!( - !code.contains("FindWidgetsFilter"), - "no struct should be synthesized for explode=false; got:\n{code}" + code.contains("query_params . push ((\"filter\" , parts . join (\",\")))"), + "explode=false object keeps the parameter name as the single key; got:\n{code}" ); } #[test] -fn deep_object_style_keeps_string_fallback() { - // deepObject (`?filter[color]=red`) isn't generated yet — string fallback. +fn deep_object_style_serializes_bracketed_keys() { + // deepObject: `?filter[color]=red&filter[size]=5`. let code = generate_methods(spec_with_filter_param(json!({ "name": "filter", "in": "query", @@ -208,8 +214,131 @@ fn deep_object_style_keeps_string_fallback() { }))); assert!( - code.contains("filter : Option < impl AsRef < str > >"), - "deepObject param should keep the string fallback; got:\n{code}" + code.contains("filter : Option < FindWidgetsFilter >"), + "deepObject param should be typed as the synthesized struct; got:\n{code}" + ); + assert!( + code.contains("format ! (\"{}[{}]\" , \"filter\" , k)"), + "deepObject must emit bracketed `filter[key]` query keys; got:\n{code}" + ); + assert!( + code.contains("req = req . query (& deep_params)"), + "deepObject pairs must be appended to the request; got:\n{code}" + ); +} + +#[test] +fn deep_object_array_keeps_string_fallback() { + // deepObject on an *array* schema (stripe's `expand[]`) is undefined in + // OAS 3.x — keeps the opaque string fallback. + let code = generate_methods(spec_with_filter_param(json!({ + "name": "expand", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": {"type": "array", "items": {"type": "string"}} + }))); + + assert!( + code.contains("expand : Option < impl AsRef < str > >"), + "deepObject array param should keep the string fallback; got:\n{code}" + ); +} + +#[test] +fn form_exploded_array_repeats_pairs() { + // form + explode=true array (the OAS defaults): `?tags=a&tags=b`. + let code = generate_methods(spec_with_filter_param(json!({ + "name": "tags", + "in": "query", + "schema": {"type": "array", "items": {"type": "string"}} + }))); + + assert!( + code.contains("tags : Option < Vec < String > >"), + "exploded string array should be typed Vec; got:\n{code}" + ); + assert!( + code.contains("for item in v { query_params . push ((\"tags\" , item . to_string ())) ; }"), + "exploded array must push one pair per element; got:\n{code}" + ); +} + +#[test] +fn form_exploded_array_types_integer_items_through_type_mapper() { + let code = generate_methods(spec_with_filter_param(json!({ + "name": "ids", + "in": "query", + "required": true, + "schema": {"type": "array", "items": {"type": "integer", "format": "int32"}} + }))); + + assert!( + code.contains("ids : Vec < i32 >"), + "required int32 array should be a bare Vec; got:\n{code}" + ); + assert!( + code.contains("for item in ids"), + "required exploded array iterates the argument directly; got:\n{code}" + ); +} + +#[test] +fn form_noexplode_array_joins_with_commas() { + // form + explode=false array: `?tags=a,b,c`; empty vectors are omitted. + let code = generate_methods(spec_with_filter_param(json!({ + "name": "tags", + "in": "query", + "explode": false, + "schema": {"type": "array", "items": {"type": "string"}} + }))); + + assert!( + code.contains("tags : Option < Vec < String > >"), + "non-exploded string array should be typed Vec; got:\n{code}" + ); + assert!( + code.contains(". join (\",\")"), + "non-exploded array must comma-join its items; got:\n{code}" + ); + assert!( + code.contains("if ! v . is_empty ()"), + "empty non-exploded arrays must be omitted; got:\n{code}" + ); +} + +#[test] +fn array_of_ref_string_enum_items_uses_enum_type() { + let mut spec = spec_with_filter_param(json!({ + "name": "status", + "in": "query", + "schema": {"type": "array", "items": {"$ref": "#/components/schemas/WidgetStatus"}} + })); + spec["components"] = json!({ + "schemas": { + "WidgetStatus": {"type": "string", "enum": ["active", "retired"]} + } + }); + let code = generate_methods(spec); + + assert!( + code.contains("status : Option < Vec < WidgetStatus > >"), + "array of $ref string-enum items should be Vec; got:\n{code}" + ); +} + +#[test] +fn array_of_objects_keeps_string_fallback() { + // Arrays of objects have no defined form serialization — fallback. + let code = generate_methods(spec_with_filter_param(json!({ + "name": "filters", + "in": "query", + "schema": {"type": "array", "items": {"type": "object", "properties": {"k": {"type": "string"}}}} + }))); + + assert!( + code.contains("filters : Option < impl AsRef < str > >"), + "array-of-objects param should keep the string fallback; got:\n{code}" ); } @@ -257,6 +386,23 @@ fn reqwest_query_serializes_struct_as_exploded_pairs() { ); } +/// Runtime pin for the deepObject wire format: reqwest percent-encodes the +/// brackets in `filter[color]` (form-urlencoded rules), which servers decode +/// transparently — Stripe et al. accept `filter%5Bcolor%5D=red`. +#[test] +fn reqwest_query_percent_encodes_deep_object_brackets() { + let deep: Vec<(String, String)> = vec![("filter[color]".to_string(), "red".to_string())]; + let req = reqwest::Client::new() + .get("https://api.example.com/widgets") + .query(&deep) + .build() + .expect("request builds"); + assert_eq!( + req.url().as_str(), + "https://api.example.com/widgets?filter%5Bcolor%5D=red" + ); +} + #[test] fn exploded_and_plain_query_params_coexist() { let mut spec = spec_with_filter_param(json!({ From 6633bbf604200bf71fe2700fcbc3747e7106ff69 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 12 Jul 2026 18:10:21 -0600 Subject: [PATCH 4/7] perf(spec-compile): shared cargo target dir across scratch crates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every scratch crate has an identical dependency tree, but each got its own target/, so reqwest/chrono/hyper/serde recompiled from scratch for all ~55 specs (~2-4 min each) — dwarfing the seconds it takes to check the generated code itself. Route the cargo-check step through a shared SPEC_COMPILE_TARGET_DIR (default tmp/spec-compile-target) that lives outside the per-run rm -rf: deps compile once ever, later runs and later specs start warm. The generator build keeps the workspace target/. Co-Authored-By: Claude Fable 5 --- scripts/spec-compile.sh | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/spec-compile.sh b/scripts/spec-compile.sh index c02fa74..55134c1 100755 --- a/scripts/spec-compile.sh +++ b/scripts/spec-compile.sh @@ -16,6 +16,14 @@ # SPEC_COMPILE_LIMIT=N process only the first N alphabetically-sorted specs # SPEC_COMPILE_PARSE_ONLY=1 skip cargo check; only verify the generator # parses+emits without errors. Faster. +# SPEC_COMPILE_TARGET_DIR=path shared cargo target dir for the scratch +# crates (default tmp/spec-compile-target). +# Every scratch crate has an identical dep tree, +# so dependency artifacts (reqwest, chrono, …) +# compile once and are reused by all specs — and +# by later runs, since the dir survives this +# script's per-run cleanup. Wipe it to force a +# cold build. set -euo pipefail cd "$(dirname "$0")/.." @@ -34,6 +42,13 @@ ROOT="$WORKSPACE/tmp/spec-compile" rm -rf "$ROOT" mkdir -p "$ROOT" +# Shared target dir for all scratch-crate checks. Deliberately OUTSIDE $ROOT +# so it survives the rm -rf above and stays warm across runs. Only exported +# for the `cargo check` step — the generator build above must keep using the +# workspace target/. +SCRATCH_TARGET="${SPEC_COMPILE_TARGET_DIR:-$WORKSPACE/tmp/spec-compile-target}" +mkdir -p "$SCRATCH_TARGET" + # Discover specs. Sort for deterministic output. mapfile -t ALL_SPECS < <(find specs -maxdepth 1 -type f \( -name "*.yaml" -o -name "*.json" \) | sort) @@ -147,7 +162,7 @@ EOF # Cargo check step log="$dir/check.log" - if ! ( cd "$dir" && cargo check $OFFLINE ) >"$log" 2>&1; then + if ! ( cd "$dir" && CARGO_TARGET_DIR="$SCRATCH_TARGET" cargo check $OFFLINE ) >"$log" 2>&1; then err_count=$(grep -cE "^error" "$log" || true) echo "CHECK-FAIL ($err_count errs)" failed_check+=("$name") From a104389edd26ec4cdcac4aeed62e130c6c5c48df Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 12 Jul 2026 18:17:02 -0600 Subject: [PATCH 5/7] perf(spec-compile): check all scratch crates as one cargo workspace Replaces 55 sequential cargo-check invocations (each re-resolving deps and taking the target-dir lock alone) with a single workspace check: one resolution, one lock, --keep-going, and cargo parallelizes the per-crate checks across cores itself. Per-spec attribution happens via cheap cached `-p` re-checks only when the workspace pass fails. Empirical: 3-spec run = 10s wall including dep bootstrap into the shared target dir (previously minutes per spec). Co-Authored-By: Claude Fable 5 --- scripts/spec-compile.sh | 87 +++++++++++++++++++++++++++++------------ 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/scripts/spec-compile.sh b/scripts/spec-compile.sh index 55134c1..cc1e239 100755 --- a/scripts/spec-compile.sh +++ b/scripts/spec-compile.sh @@ -2,8 +2,11 @@ # Smoke-test that generated clients for every spec under specs/ compile cleanly. # # Auto-discovers specs/*.yaml and specs/*.json. Each spec produces a separate -# scratch crate; we run the `openapi-to-rust` generator into it and then -# `cargo check`. Any regression here means a real-world spec stops compiling. +# scratch crate; we run the `openapi-to-rust` generator into it, then check +# all scratch crates as ONE cargo workspace: a single dependency resolution, +# a single target-dir lock, and cargo schedules the per-crate checks across +# all cores itself. Any regression here means a real-world spec stops +# compiling. # # Usage: # scripts/spec-compile.sh # all specs in specs/ @@ -17,9 +20,8 @@ # SPEC_COMPILE_PARSE_ONLY=1 skip cargo check; only verify the generator # parses+emits without errors. Faster. # SPEC_COMPILE_TARGET_DIR=path shared cargo target dir for the scratch -# crates (default tmp/spec-compile-target). -# Every scratch crate has an identical dep tree, -# so dependency artifacts (reqwest, chrono, …) +# workspace (default tmp/spec-compile-target). +# Dependency artifacts (reqwest, chrono, …) # compile once and are reused by all specs — and # by later runs, since the dir survives this # script's per-run cleanup. Wipe it to force a @@ -42,9 +44,9 @@ ROOT="$WORKSPACE/tmp/spec-compile" rm -rf "$ROOT" mkdir -p "$ROOT" -# Shared target dir for all scratch-crate checks. Deliberately OUTSIDE $ROOT -# so it survives the rm -rf above and stays warm across runs. Only exported -# for the `cargo check` step — the generator build above must keep using the +# Shared target dir for the scratch workspace. Deliberately OUTSIDE $ROOT so +# it survives the rm -rf above and stays warm across runs. Only exported for +# the `cargo check` step — the generator build above must keep using the # workspace target/. SCRATCH_TARGET="${SPEC_COMPILE_TARGET_DIR:-$WORKSPACE/tmp/spec-compile-target}" mkdir -p "$SCRATCH_TARGET" @@ -78,10 +80,12 @@ fi echo "[spec-compile] running ${#SPECS[@]} spec(s)" echo +# ---- Phase 1: generate a scratch crate per spec ------------------------- passed=() failed_gen=() failed_check=() skipped=() +gen_ok=() for entry in "${SPECS[@]}"; do IFS='|' read -r name spec_path <<<"$entry" @@ -153,26 +157,57 @@ EOF continue fi - if [ "${SPEC_COMPILE_PARSE_ONLY:-}" = "1" ]; then - echo "GEN-OK" - passed+=("$name") - [ "${SPEC_COMPILE_KEEP:-}" != "1" ] && rm -rf "$dir" - continue - fi + echo "GEN-OK" + gen_ok+=("$name") +done - # Cargo check step - log="$dir/check.log" - if ! ( cd "$dir" && CARGO_TARGET_DIR="$SCRATCH_TARGET" cargo check $OFFLINE ) >"$log" 2>&1; then - err_count=$(grep -cE "^error" "$log" || true) - echo "CHECK-FAIL ($err_count errs)" - failed_check+=("$name") - continue +if [ "${SPEC_COMPILE_PARSE_ONLY:-}" = "1" ]; then + passed=("${gen_ok[@]}") + [ "${SPEC_COMPILE_KEEP:-}" != "1" ] && rm -rf "$ROOT" +elif [ ${#gen_ok[@]} -gt 0 ]; then + # ---- Phase 2: check everything as one workspace ------------------------ + { + echo "[workspace]" + echo "resolver = \"2\"" + echo "members = [" + for name in "${gen_ok[@]}"; do + echo " \"$name\"," + done + echo "]" + } >"$ROOT/Cargo.toml" + + echo + echo "[spec-compile] cargo check (workspace of ${#gen_ok[@]} crates)..." + ws_log="$ROOT/check.log" + if ( cd "$ROOT" && CARGO_TARGET_DIR="$SCRATCH_TARGET" cargo check --workspace --keep-going $OFFLINE ) >"$ws_log" 2>&1; then + passed=("${gen_ok[@]}") + for name in "${gen_ok[@]}"; do + printf "%-30s PASS\n" "$name" + done + [ "${SPEC_COMPILE_KEEP:-}" != "1" ] && rm -rf "$ROOT" + else + # Attribute failures per crate. Everything that compiles is already + # cached from the workspace pass, so these re-checks are cheap. Passing + # crates are cleaned up only after the loop — they must stay on disk + # while they're still members of the workspace being checked. + for name in "${gen_ok[@]}"; do + log="$ROOT/$name/check.log" + if ( cd "$ROOT" && CARGO_TARGET_DIR="$SCRATCH_TARGET" cargo check -p "spec-compile-$name" $OFFLINE ) >"$log" 2>&1; then + printf "%-30s PASS\n" "$name" + passed+=("$name") + else + err_count=$(grep -cE "^error" "$log" || true) + printf "%-30s CHECK-FAIL (%s errs)\n" "$name" "$err_count" + failed_check+=("$name") + fi + done + if [ "${SPEC_COMPILE_KEEP:-}" != "1" ]; then + for name in "${passed[@]}"; do + rm -rf "$ROOT/$name" + done + fi fi - - echo "PASS" - passed+=("$name") - [ "${SPEC_COMPILE_KEEP:-}" != "1" ] && rm -rf "$dir" -done +fi echo echo "[spec-compile] summary: ${#passed[@]} passed, ${#failed_gen[@]} gen-failed, ${#failed_check[@]} check-failed, ${#skipped[@]} skipped" From 09244d12d99d1e19830d05bbc64f7c4f4e27445c Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 12 Jul 2026 18:46:59 -0600 Subject: [PATCH 6/7] chore: gitignore spec-compile shared target dir; track anu in beads Co-Authored-By: Claude Fable 5 --- .beads/issues.jsonl | 2 +- .gitignore | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index cc1773e..e1912e2 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,7 +1,7 @@ {"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":"in_progress","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-12T23:35:21Z","started_at":"2026-07-12T23:22:49Z","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-anu","title":"Query param styles not yet generated: deepObject, form explode=false, exploded arrays","description":"Follow-up to GH #27 (openapi-generator-cv4). Client now supports form-exploded object query params (?color=red). Still on the string fallback: style=deepObject (?filter[color]=red — Stripe uses this heavily, 1600+ occurrences), form+explode=false objects (?filter=color,red,size,big), and typed exploded arrays (?tags=a\u0026tags=b currently a single string param). Each needs analyzer typing + client serialization.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:32:14Z","created_by":"James Lal","updated_at":"2026-07-12T23:44:58Z","started_at":"2026-07-12T23:44:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-anu","title":"Query param styles not yet generated: deepObject, form explode=false, exploded arrays","description":"Follow-up to GH #27 (openapi-generator-cv4). Client now supports form-exploded object query params (?color=red). Still on the string fallback: style=deepObject (?filter[color]=red — Stripe uses this heavily, 1600+ occurrences), form+explode=false objects (?filter=color,red,size,big), and typed exploded arrays (?tags=a\u0026tags=b currently a single string param). Each needs analyzer typing + client serialization.","notes":"Implemented in PR #28 (same branch as GH #27 work). All serialization modes done except deepObject arrays / space+pipeDelimited / arrays-of-objects which keep the string fallback by design. Close when PR merges.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:32:14Z","created_by":"James Lal","updated_at":"2026-07-13T00:46:34Z","started_at":"2026-07-12T23:44:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-cwd","title":"Config docs say [generator.types] but parser reads top-level [types] — documented block is silently ignored","description":"README (§'[generator.types] — typed-scalar strategy per format', also [generator.types.strategies]) and the doc comment on ConfigFile::types say the type-mapping block lives at [generator.types], but ConfigFile deserializes it from top-level [types]. A TOML file using the documented path is silently ignored (unknown fields aren't denied), so users think they enabled e.g. date = \"time\" but get chrono defaults. Found while testing GH #25. Fix: either move the field under GeneratorSection or fix README + doc comment, and consider deny_unknown_fields or a validation warning.","status":"open","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:47:50Z","created_by":"James Lal","updated_at":"2026-07-11T18:47:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-k07","title":"[bug] Query/path parameters ignore integer/number format (int32 -\u003e i64)","description":"analyze_parameter in src/analysis.rs hardcodes Integer =\u003e i64 and Number =\u003e f64, ignoring the schema format field. Body/schema properties correctly route through TypeMapper::integer_format/number_format, so int32 becomes i32 there, but the same format on a query/path parameter always yields i64. No type_mappings/strategies config can override it because analyze_parameter never calls TypeMapper. Fixes GitHub issue #23.","notes":"Fix in PR #24 (gpu-cli/openapi-to-rust), branch worktree-fix-i32-param-format. Root cause: analyze_parameter hardcoded Integer=\u003ei64 / Number=\u003ef64, bypassing TypeMapper. Now routes through integer_format/number_format. CI green (test/clippy/fmt/doc/spec-compile). Commented on GH issue #23. Awaiting review/merge.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T03:12:09Z","created_by":"James Lal","updated_at":"2026-07-11T03:29:45Z","started_at":"2026-07-11T03:12:21Z","closed_at":"2026-07-11T03:29:45Z","close_reason":"Fixed in PR #24, merged to main, released as v0.5.2 (crates.io + GitHub release). GH issue #23 commented.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-76h","title":"Add ByteStrategy::Base64UrlUnpadded for format: byte","description":"Add a new ByteStrategy variant emitting RFC 7515 §2 url-safe unpadded base64 in the inlined base64_serde helper. Needed by gpu-cli portal-schema work (JWK/DPoP thumbprints, sealing pubkeys) so format: byte fields can round-trip without falling back to plain String + spec-side documentation. Spec-global config knob, not per-field.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-16T17:58:36Z","created_by":"James Lal","updated_at":"2026-05-16T18:29:10Z","started_at":"2026-05-16T17:58:50Z","closed_at":"2026-05-16T18:29:10Z","close_reason":"Shipped on worktree-base64url-unpadded (commit afc8c88). 16 typed_scalars_test, 23 type_mapping unit tests, 51-binary cargo test suite all green; clippy --lib clean.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.gitignore b/.gitignore index a998d33..64902cb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ **/target/ # Build outputs from spec-compile.sh and ad-hoc generator runs. /tmp/spec-compile/ +/tmp/spec-compile-target/ /tmp/gen-anthropic/ /tmp/gen-openai/ /tmp/gen-cloudflare/ From 191cf005c66fdaca5381250666a0a782c5ff5f93 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 12 Jul 2026 19:02:31 -0600 Subject: [PATCH 7/7] chore: bump version to 0.6.0; finalize release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.6.0 releases the typed query parameter serialization from #27/#28 — a breaking change for regenerated clients, logged in the README's pre-1.0 breaking-changes section. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 15 +++++++++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb5ba88..0690b23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,7 +936,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openapi-to-rust" -version = "0.5.3" +version = "0.6.0" dependencies = [ "clap", "heck", diff --git a/Cargo.toml b/Cargo.toml index 859452c..cfecbfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openapi-to-rust" -version = "0.5.3" +version = "0.6.0" edition = "2024" rust-version = "1.85.0" authors = ["James Lal"] diff --git a/README.md b/README.md index aeb6e3d..52f184f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ We originally built this internally at [GPU CLI](https://gpu-cli.sh) to generate It currently compiles cleanly against **54 real-world specs** in `specs/` (Stripe, OpenAI, Anthropic, Cloudflare's 14k-schema spec, GitHub, Discord, Microsoft Graph, Spotify, Twilio, …), guarded by CI. +## What's new in 0.6 + +- **Typed query parameter serialization** ([#27](https://github.com/gpu-cli/openapi-to-rust/issues/27)) — object and array query params are generated per their OAS `style`/`explode`: form-exploded objects become struct arguments serialized as `?color=red&size=5`, explode=false objects comma-join, deepObject objects emit `?filter[color]=red`, and form arrays become `Vec` (repeated or comma-joined). **Breaking for regenerated clients** — the old `Option>` passthrough put a single opaque `name=` pair on the wire, which no server expecting the declared style could parse. See [Breaking changes (pre-1.0)](#breaking-changes-pre-10). + ## What's new in 0.5 - **Server codegen (Axum)** — opt-in `[server]` section emits a trait per tag, a status-code-typed response enum (with `IntoResponse`), an SSE-aware variant, and a `Router` factory. Pick operations one-by-one or `--all-tag`. Two end-to-end examples ship in `examples/server-{openai-responses,anthropic-messages}/`. @@ -558,7 +562,7 @@ usually because the previous output was wrong on the wire. Regenerating makes the compiler point at every affected call site; there is no silent behavior change without a signature change. -### 0.6.0 (unreleased) +### 0.6.0 Object- and array-schema **query parameters** are now serialized according to their OpenAPI `style`/`explode` (issue [#27](https://github.com/gpu-cli/openapi-to-rust/issues/27)). @@ -583,7 +587,14 @@ extraction (tracked separately). ## Release notes -### 0.5 (this release) +### 0.6 (this release) + +**Typed query parameter serialization** ([#27](https://github.com/gpu-cli/openapi-to-rust/issues/27)) +- Object and array query parameters are generated per their OAS `style`/`explode` instead of an opaque `Option>` passthrough: form-exploded objects (`?color=red&size=5`), explode=false objects (`?filter=color,red,size,5`), deepObject objects (`?filter[color]=red`), and `Vec` form arrays (repeated or comma-joined pairs, scalar or string-enum items). +- **Breaking for regenerated clients** — see [Breaking changes (pre-1.0)](#breaking-changes-pre-10) for the full signature table. +- `scripts/spec-compile.sh` checks all scratch crates as one cargo workspace against a shared persistent target dir — full-sweep verification dropped from hours to minutes. + +### 0.5 **Server codegen (Axum)** - `[server]` TOML section with selector grammar (operationId / `METHOD /path` / `tag:`).