diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c11b00c..e1912e2 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.","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.","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} @@ -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/.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/ 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 22652ab..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}/`. @@ -551,9 +555,46 @@ 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 + +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) +### 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:`). diff --git a/scripts/spec-compile.sh b/scripts/spec-compile.sh index c02fa74..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/ @@ -16,6 +19,13 @@ # 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 +# 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 +# cold build. set -euo pipefail cd "$(dirname "$0")/.." @@ -34,6 +44,13 @@ ROOT="$WORKSPACE/tmp/spec-compile" rm -rf "$ROOT" mkdir -p "$ROOT" +# 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" + # Discover specs. Sort for deterministic output. mapfile -t ALL_SPECS < <(find specs -maxdepth 1 -type f \( -name "*.yaml" -o -name "*.json" \) | sort) @@ -63,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" @@ -138,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 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" diff --git a/src/analysis.rs b/src/analysis.rs index d8370fa..aacff26 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -391,6 +391,52 @@ pub struct ParameterInfo { /// Empty/none = use sanitize from `name`. #[serde(skip_serializing_if = "Option::is_none")] pub rust_ident: Option, + /// 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 { @@ -4325,7 +4371,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 +4389,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 +4448,7 @@ impl SchemaAnalyzer { description: None, enum_values: None, rust_ident: None, + query_serialization: None, }); } @@ -4510,9 +4560,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 +4584,7 @@ impl SchemaAnalyzer { } fn analyze_parameter( - &self, + &mut self, param: &crate::openapi::Parameter, operation_id: &str, ) -> Result> { @@ -4546,20 +4597,79 @@ impl SchemaAnalyzer { let mut rust_type = "String".to_string(); let mut schema_ref = None; let mut enum_values: Option> = None; + 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). - // 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 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 object_serialization.is_some() + && self.referenced_schema_is_object(name) + { + schema_ref = Some(name.to_string()); + query_serialization = object_serialization.clone(); } } + } 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); + 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` @@ -4604,6 +4714,65 @@ impl SchemaAnalyzer { description: param.description.clone(), enum_values, rust_ident: None, + 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). + 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..f26df12 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -769,8 +769,14 @@ impl CodeGenerator { } let mut param_building = 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); @@ -778,6 +784,150 @@ impl CodeGenerator { // 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; + } + 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 => {} + } + if param.required { // Required parameters: always add if Self::param_uses_as_ref_str(param) { @@ -807,15 +957,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 all serialize on `req` directly 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 + #(#req_appends)* } } @@ -1009,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 new file mode 100644 index 0000000..d0996ee --- /dev/null +++ b/tests/exploded_query_params_test.rs @@ -0,0 +1,431 @@ +//! 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 (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 +//! `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_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", + "explode": false, + "schema": inline_object_schema() + }))); + + assert!( + 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("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_serializes_bracketed_keys() { + // deepObject: `?filter[color]=red&filter[size]=5`. + 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 < 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}" + ); +} + +/// 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 `?`" + ); +} + +/// 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!({ + "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}" + ); +}