From 0bdf1ebaa6e337d1a706be7edaa33d1fe2ed9aa4 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 26 Jul 2026 16:18:42 -0600 Subject: [PATCH 1/4] fix(server): preserve response and streaming contracts --- .beads/issues.jsonl | 8 +- .github/workflows/ci.yml | 33 +++ CHANGELOG.md | 20 ++ README.md | 11 +- examples/server-anthropic-messages/Cargo.toml | 2 + examples/server-anthropic-messages/README.md | 20 +- .../server-anthropic-messages/src/main.rs | 24 +- src/analysis.rs | 135 ++++++++- src/bin/openapi-to-rust.rs | 51 +++- src/openapi.rs | 26 ++ src/server/codegen.rs | 280 ++++++++++++++++-- tests/anthropic_sdk_compat_test.rs | 117 ++++++++ tests/cli_workflow_test.rs | 68 +++++ tests/http_error_test.rs | 1 + tests/openai_sdk_compat_test.rs | 56 ++-- tests/operation_generation_test.rs | 1 + .../anthropic_sdk_compat/requirements.txt | 1 + tests/python/anthropic_sdk_compat/smoke.py | 55 ++++ tests/python/openai_sdk_compat/smoke.py | 1 + tests/schema_extensions_test.rs | 126 ++++++++ tests/selective_client_test.rs | 4 +- tests/server_body_validation_test.rs | 4 +- tests/server_examples_test.rs | 34 +-- .../server_parameter_media_validation_test.rs | 4 +- tests/server_query_roundtrip_test.rs | 2 +- tests/server_response_semantics_test.rs | 259 ++++++++++++++++ tests/server_tag_collision_test.rs | 46 +++ 27 files changed, 1250 insertions(+), 139 deletions(-) create mode 100644 tests/anthropic_sdk_compat_test.rs create mode 100644 tests/python/anthropic_sdk_compat/requirements.txt create mode 100644 tests/python/anthropic_sdk_compat/smoke.py create mode 100644 tests/schema_extensions_test.rs create mode 100644 tests/server_response_semantics_test.rs create mode 100644 tests/server_tag_collision_test.rs diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 67b31ba..add95ec 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,6 @@ +{"id":"openapi-generator-in6.3","title":"Preserve OpenAPI server response semantics","description":"Server analysis/codegen ignores component Response refs and bodyless statuses, loses +json media types and SSE status codes, and collapses wildcard/default responses to arbitrary fixed codes. Model and emit the declared response semantics faithfully.","acceptance_criteria":"Referenced responses resolve; bodyless 201/202/204 variants are emitted; +json Content-Type is preserved; SSE uses its declared status; wildcard/default variants can return valid runtime statuses; focused generated/runtime tests cover each case.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:42Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:08Z","started_at":"2026-07-26T21:55:54Z","closed_at":"2026-07-26T22:15:08Z","close_reason":"Preserved reusable, bodyless, media-type, SSE-status, wildcard, and default response semantics with generated runtime coverage; full suite passes.","dependencies":[{"issue_id":"openapi-generator-in6.3","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-in6.2","title":"Gate Anthropic generated server with official Python SDK on PR","description":"PR CI runs the official OpenAI Python SDK against the generated OpenAI Axum server, but Anthropic has no equivalent coverage and ignored server example tests have no CI job.","acceptance_criteria":"PR CI pins and runs the official Anthropic Python SDK against an ephemeral generated Anthropic Axum server for unary and SSE messages; both server examples are regenerated and tested on PR; CI/docs no longer claim a missing examples job.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:41Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:07Z","started_at":"2026-07-26T21:55:53Z","closed_at":"2026-07-26T22:15:07Z","close_reason":"Added every-PR official Anthropic Python SDK unary/SSE gate, hardened both SDK harnesses, and verified both generated examples and both official clients end to end.","dependencies":[{"issue_id":"openapi-generator-in6.2","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:40Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-q8h","title":"Publish openapi-to-rust 0.9.1","description":"Cut the merged 0.9.1 crate release through the tag-triggered trusted publishing workflow, verify all release gates, crates.io publication, and GitHub release creation.","acceptance_criteria":"Tag v0.9.1 points at merged main; publish workflow passes; crates.io and GitHub expose v0.9.1.","notes":"Published v0.9.1 through tag-triggered GitHub Actions trusted publishing run 30221338490. Verify job passed version/tag check, fmt, clippy, all-feature tests, docs, and install smoke. Publish job authenticated via crates.io OIDC, cargo publish --locked passed, GitHub release created, and cargo search returns 0.9.1.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:32:05Z","created_by":"James Lal","updated_at":"2026-07-26T21:47:31Z","started_at":"2026-07-26T21:32:12Z","closed_at":"2026-07-26T21:47:31Z","close_reason":"v0.9.1 is live on crates.io and GitHub through the trusted publish workflow.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-cqf","title":"Fix schema-less client bodies and release 0.9.1","description":"Remote spec-compile regressed for Cloudflare, GitHub, and Lithic because schema-less JSON request bodies are rejected during shared operation analysis. Preserve client generation compatibility, keep validated Axum server generation fail-closed, bump patch metadata to 0.9.1, and publish through a PR.","acceptance_criteria":"Cloudflare, GitHub, and Lithic parse-only generation passes; schema-less bodies remain representable for clients; validated server generation rejects schema-less selected request content with an actionable error; 0.9.1 metadata and changelog are updated; relevant full gates pass.","notes":"Implemented a shared SchemaLess request-body marker: client and registry generation preserve historical no-body operations, while Axum server generation fails closed with operation/media context. Bumped Cargo metadata and changelog to 0.9.1. Verified focused tests 38/38; cloudflare/github/lithic generation 3/3; full corpus 54 pass + 1 Swagger 2.0 skip; Anthropic/OpenAI generated outputs compile; cargo test --all-features; clippy -D warnings; rustdoc -D warnings; fmt/diff checks; official openai-python 2.45.0 compatibility smoke.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T20:24:00Z","created_by":"James Lal","updated_at":"2026-07-26T20:35:14Z","started_at":"2026-07-26T20:24:09Z","closed_at":"2026-07-26T20:35:14Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-xov.9","title":"Always-run OpenAI generated client/server compatibility smoke suite","description":"Generate a selected Axum server and Reqwest client from the checked-in OpenAI API specification and exercise representative JSON RPC, path/query parameter, typed Problem Details, and SSE operations. Research whether the official OpenAI Python client offers a stable reusable mock/contract suite; keep the required gate hermetic and offline.","design":"Prefer a small selected-operation subset and existing checked-in spec. No external API calls, credentials, fixed ports, or downloads in the required test.","acceptance_criteria":"A hermetic always-run test generates both sides from specs/openai.yaml, starts an ephemeral server, and verifies representative basic RPC, query/path parameters, errors, and SSE; official-client reuse is either implemented as a stable gate or documented/filed as an optional follow-up with evidence.","notes":"Implemented an every-PR CI job using openai-python==2.45.0 with OpenAI(base_url=\u003cephemeral loopback\u003e, api_key=test, admin_api_key=test-admin, max_retries=0). Live final gate passed unary Responses, typed SSE deltas plus [DONE], input-items path/query, organization costs query, and same-spec generated Rust client compilation.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T17:48:44Z","created_by":"James Lal","updated_at":"2026-07-26T18:23:43Z","started_at":"2026-07-26T17:50:28Z","closed_at":"2026-07-26T18:23:43Z","close_reason":"Pinned official OpenAI SDK and generated Rust client/server compatibility gate passes in CI.","dependencies":[{"issue_id":"openapi-generator-xov.9","depends_on_id":"openapi-generator-xov","type":"parent-child","created_at":"2026-07-26T11:48:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-xov.7.2","title":"Phase 4: generated and hand-written client round trips validation","description":"Independently verify the acceptance criteria and validation commands in planning/axum-validation/04-roundtrip-matrix.md; add missing regression coverage where necessary.","acceptance_criteria":"All scoped validation commands exit 0 and evidence is recorded in issue notes.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T16:44:15Z","created_by":"James Lal","updated_at":"2026-07-26T17:51:13Z","started_at":"2026-07-26T17:51:10Z","closed_at":"2026-07-26T17:51:13Z","close_reason":"Live ephemeral generated client/server and independent reqwest matrix passes with typed Problem Details, full status taxonomy, redaction, deterministic ordering, and handler isolation.","dependencies":[{"issue_id":"openapi-generator-xov.7.2","depends_on_id":"openapi-generator-xov.7","type":"parent-child","created_at":"2026-07-26T10:44:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-xov.7.2","depends_on_id":"openapi-generator-xov.7.1","type":"blocks","created_at":"2026-07-26T10:44:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} @@ -34,6 +37,8 @@ {"id":"openapi-generator-cv4","title":"Support exploded query parameters (GH #27)","description":"GH issue 27: query params with style=form, explode=true (OAS defaults) and object schemas currently map to Option\u003cimpl AsRef\u003cstr\u003e\u003e and are sent as one opaque string. Per OAS/RFC6570 form-explode, each object property must become its own query pair (?color=red). Fix: analyzer synthesizes/resolves a typed struct for object query params with form+explode semantics; client generator emits req.query(\u0026struct) so reqwest/serde_urlencoded serializes properties as individual pairs.","notes":"Implemented on branch issue-27-exploded-query-params, draft PR https://github.com/gpu-cli/openapi-to-rust/pull/28. Close when PR merges. Follow-ups: openapi-generator-anu (deepObject/explode=false/arrays), openapi-generator-0jz (server side).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T23:22:24Z","created_by":"James Lal","updated_at":"2026-07-13T04:21:19Z","started_at":"2026-07-12T23:22:49Z","closed_at":"2026-07-13T04:21:19Z","close_reason":"Released in v0.6.0 (PR #28)","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-5q8","title":"DateStrategy::Time emits broken serde codec for time::Date/time::Time (GH #25)","description":"GitHub issue gpu-cli/openapi-to-rust#25: fields with format: date/time under DateStrategy::Time get #[serde(with = \"time::serde::iso8601\")], but that module only supports OffsetDateTime — generated code fails to compile. Fix: emit time::serde::format_description! helper codec modules (time_date_format / time_time_format) into generated code, and correct the time dep requirement features (serde alone doesn't even enable rfc3339).","notes":"Fixed in PR https://github.com/gpu-cli/openapi-to-rust/pull/26 (branch worktree-issue-25-time-date-serde). Close when PR merges.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:36:28Z","created_by":"James Lal","updated_at":"2026-07-11T18:57:02Z","started_at":"2026-07-11T18:36:45Z","closed_at":"2026-07-11T18:57:02Z","close_reason":"Fixed in PR #26, merged to main, released in v0.5.3","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-dpd","title":"Hybrid string-or-object discriminated unions deserialize-fail","description":"When an anyOf/oneOf union contains a discriminator AND a non-object branch (e.g. string-enum like ToolChoiceOptions), the generator emits a tagged enum that cannot deserialize the string form. Real-world hit: OpenAI ToolChoiceParam returns 'auto' in Response.tool_choice, but generated type is #[serde(tag=\"type\")] enum with no untagged String variant. Need to fall back to #[serde(untagged)] when the union mixes string/scalar branches with tagged-object branches, OR add a String fallback variant before the tagged variants.","notes":"Live repro: `ToolChoiceParam` from openai.yaml line 52518. anyOf has 8 branches; the first (`ToolChoiceOptions`) is a string-enum (\"none\"|\"auto\"|\"required\"), the rest are objects with discriminator propertyName=type. Generator emits `#[serde(tag=\"type\")] enum ToolChoiceParam { ToolChoiceOptions(ToolChoiceOptions), ... }` which cannot deserialize the string \"auto\" because serde tries to read a \"type\" field from a JSON string. Fix: when an anyOf/oneOf branch is a non-object schema (string/number/etc), the generator must emit `#[serde(untagged)]` with the scalar branch first OR add a String variant before the tagged variants. Hit on real OpenAI Responses API `Response.tool_choice` field.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:35Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:10Z","closed_at":"2026-05-11T00:12:46Z","close_reason":"Fixed in src/analysis.rs: (dpd) analyze_oneof_union now downgrades to untagged when any branch is non-object — verified live against OpenAI Response.tool_choice='auto' which now deserializes as ToolChoiceParam::ToolChoiceOptions(Auto). (bgo) merge_schema_into_properties now ORs in is_nullable_pattern() for allOf-merged props — verified live against OpenAI Response.incomplete_details which is now Option\u003cResponseIncompleteDetails\u003e and deserializes null cleanly. All 4 smoke tests (OpenAI+Anthropic, sync+stream) pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-in6.4","title":"Detect normalized server tag identifier collisions","description":"Distinct OpenAPI tags such as foo-bar and foo_bar normalize to identical Rust trait/router identifiers and generate duplicate items.","acceptance_criteria":"Server generation rejects or deterministically disambiguates colliding normalized tag identifiers with an actionable diagnostic; regression test covers colliding tags.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:43Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:08Z","started_at":"2026-07-26T21:55:54Z","closed_at":"2026-07-26T22:15:08Z","close_reason":"Added deterministic normalized tag collision diagnostics with unit and integration coverage; full suite passes.","dependencies":[{"issue_id":"openapi-generator-in6.4","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:42Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-in6.1","title":"Support YAML schema extension overlays","description":"README documents JSON/YAML schema_extensions, but analysis parses every overlay as JSON. Parse .yaml/.yml overlays consistently with specs and add merge/SSE regression coverage.","acceptance_criteria":"JSON and YAML overlays both deep-merge; a YAML text/event-stream overlay marks the operation streaming; malformed overlays report their file context.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:39Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:05Z","started_at":"2026-07-26T21:55:53Z","closed_at":"2026-07-26T22:15:05Z","close_reason":"Implemented JSON/YAML/YML schema extension parsing with path-rich errors; 4 focused tests and full all-features suite pass.","dependencies":[{"issue_id":"openapi-generator-in6.1","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-xov.8.2","title":"Phase 5: release docs and quality gates validation","description":"Independently verify the acceptance criteria and validation commands in planning/axum-validation/05-release-quality-gates.md; add missing regression coverage where necessary.","acceptance_criteria":"All scoped validation commands exit 0 and evidence is recorded in issue notes.","notes":"Validation evidence: cargo nextest run --all-features --no-fail-fast: 425/425 passed; cargo test --all-features including doctests passed; cargo clippy --all-features -- -D warnings passed; RUSTDOCFLAGS=-D warnings cargo doc --no-deps --all-features passed; generated dependency fragments compile; official openai-python 2.45.0 live base_url smoke passed; cargo fmt and git diff checks clean.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T16:44:18Z","created_by":"James Lal","updated_at":"2026-07-26T18:23:25Z","started_at":"2026-07-26T18:23:23Z","closed_at":"2026-07-26T18:23:25Z","close_reason":"All scoped validation and release gates passed.","dependencies":[{"issue_id":"openapi-generator-xov.8.2","depends_on_id":"openapi-generator-xov.8","type":"parent-child","created_at":"2026-07-26T10:44:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-xov.8.2","depends_on_id":"openapi-generator-xov.8.1","type":"blocks","created_at":"2026-07-26T10:44:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-xov.8.1","title":"Phase 5: release docs and quality gates implementation","description":"Implement the production changes defined in planning/axum-validation/05-release-quality-gates.md. Record a codebase research mini-plan in issue notes before editing.","acceptance_criteria":"The implementation portion of planning/axum-validation/05-release-quality-gates.md is complete with focused tests.","notes":"Implemented Axum 0.8 release docs/examples, RFC 9457 validation guide, exact offline dependency fragments, crate 0.9.0 metadata, and regression coverage. Focused generated Rust 2021 validation crates pass.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T16:44:17Z","created_by":"James Lal","updated_at":"2026-07-26T18:23:20Z","started_at":"2026-07-26T17:51:26Z","closed_at":"2026-07-26T18:23:20Z","close_reason":"Release documentation, examples, metadata, and focused tests implemented.","dependencies":[{"issue_id":"openapi-generator-xov.8.1","depends_on_id":"openapi-generator-xov.8","type":"parent-child","created_at":"2026-07-26T10:44:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"openapi-generator-xov.8","title":"Phase 5: release docs and quality gates","description":"Deliver the work defined in planning/axum-validation/05-release-quality-gates.md.","acceptance_criteria":"Documentation, examples, release metadata, exact dependencies, and full repository gates are complete.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T16:44:05Z","created_by":"James Lal","updated_at":"2026-07-26T18:23:39Z","started_at":"2026-07-26T17:51:25Z","closed_at":"2026-07-26T18:23:39Z","close_reason":"Release docs, examples, metadata, exact dependencies, and full quality gates complete.","dependencies":[{"issue_id":"openapi-generator-xov.8","depends_on_id":"openapi-generator-xov","type":"parent-child","created_at":"2026-07-26T10:44:04Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-xov.8","depends_on_id":"openapi-generator-xov.7","type":"blocks","created_at":"2026-07-26T10:44:23Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} @@ -68,13 +73,14 @@ {"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-mk3","title":"Add curated server spec compile matrix","description":"Evaluate and add a curated per-spec Axum server generation/compile matrix instead of selecting every operation from every corpus spec. Each row should name supported operation selectors and distinguish expected generator rejections from regressions.","acceptance_criteria":"A checked-in matrix covers multiple representative specs beyond the OpenAI and Anthropic runtime examples; CI generates and cargo-checks each isolated server crate against exact emitted dependencies; unsupported operations are explicitly excluded or recorded; runtime/official-SDK gates remain the semantic contract tests.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-26T22:18:23Z","created_by":"James Lal","updated_at":"2026-07-26T22:18:23Z","dependencies":[{"issue_id":"openapi-generator-mk3","depends_on_id":"openapi-generator-in6","type":"discovered-from","created_at":"2026-07-26T16:18:22Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-z4j","title":"Generated base64 codec helpers trigger dead_code warnings when unused","description":"Generated types.rs emits serialize/deserialize base64 codec helpers even when no field uses them, producing dead_code warnings in consuming crates (seen compiling anthropic.yml output). Add #[allow(dead_code)] or emit conditionally.","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-18T04:50:01Z","created_by":"James Lal","updated_at":"2026-07-18T04:50:01Z","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-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":"Approach: make server list/add analyze the full generator config including schema_extensions; isolate the Anthropic example crate from the root workspace; make server example tests use Cargo's current binary path; update example docs; add focused regression coverage. Audit server implementation and PR official-client CI for adjacent high-confidence defects before closing.","status":"closed","priority":3,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:54:29Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:26Z","started_at":"2026-07-26T21:52:00Z","closed_at":"2026-07-26T22:15:26Z","close_reason":"Reopened review findings and adjacent server audit issues are fixed: overlay-aware CLI, YAML overlays, reliable examples, official OpenAI/Anthropic SDK PR gates, faithful response semantics, and tag-collision diagnostics. All scoped and full all-features gates pass.","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.","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/workflows/ci.yml b/.github/workflows/ci.yml index 60c06e4..2f032d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,12 +29,17 @@ jobs: openai-sdk-compat: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: submodules: true - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + examples/server-openai-responses -> target - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -50,6 +55,34 @@ jobs: official_openai_python_sdk_matches_generated_axum_server -- --ignored --exact --nocapture + anthropic-sdk-compat: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + examples/server-anthropic-messages -> target + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: tests/python/anthropic_sdk_compat/requirements.txt + - name: Install pinned official Anthropic Python SDK + run: python -m pip install --requirement tests/python/anthropic_sdk_compat/requirements.txt + - name: Test official SDK against generated Axum server + env: + ANTHROPIC_COMPAT_PYTHON: python + run: >- + cargo test --test anthropic_sdk_compat_test + official_anthropic_python_sdk_matches_generated_axum_server -- + --ignored --exact --nocapture + clippy: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 140efd5..46ec6ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ 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 + +- Every-PR compatibility coverage for the generated Anthropic Messages server + through the pinned official Python SDK, including unary and SSE responses. + +### Fixed + +- Config-driven `server list` and `server add` now apply + `generator.schema_extensions`, so overlay-provided operations and SSE media + types match generation. +- Schema extensions accept the documented JSON, YAML, and YML formats with + path-rich parse errors. +- Generated server response enums retain reusable Response Object references, + bodyless status codes, vendor/problem JSON media types, SSE status codes, + and runtime status values for wildcard/default responses. +- Server example tests use Cargo's current integration-test binary instead of + a potentially stale hard-coded `target/debug` executable. + ## [0.9.1] - 2026-07-26 ### Fixed diff --git a/README.md b/README.md index 422e35a..5bbfe41 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ Two complete examples are in the repo: | `streaming.rs` | SSE streaming **client** with event parsing (when configured) | | `server/mod.rs` | Module re-exports for the server (when `[server]` is set) | | `server/api.rs` | `trait Api { async fn (&self, …) -> Response; }` per tag | -| `server/errors.rs` | `enum Response { Ok(T), BadRequest(E), …, OkStream(Sse<…>) }` with `IntoResponse` | +| `server/errors.rs` | Status-typed response enums with JSON, bodyless, wildcard/default, and status-correct SSE variants plus `IntoResponse` | | `server/router.rs` | Per-tag `Router` factory; combined `build_router<…>(…)` for multi-tag selections | | `server/validation.rs` | Offline JSON Schema validators and sanitized Problem Details rejections | | `mod.rs` | Module declarations + re-exports | @@ -665,10 +665,11 @@ The compile tiers are intentionally different: 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. -- Every pull request also starts the generated OpenAI Responses Axum example - on loopback and exercises it through the pinned official OpenAI Python SDK. - The compatibility gate covers unary and streaming Responses, input-item - path/query parameters, and the organization costs query. +- Every pull request also starts the generated OpenAI Responses and Anthropic + Messages Axum examples on loopback and exercises them through pinned official + Python SDKs. The compatibility gates cover unary and streaming responses, + with the OpenAI row also covering input-item path/query parameters and the + organization costs query. ## Examples diff --git a/examples/server-anthropic-messages/Cargo.toml b/examples/server-anthropic-messages/Cargo.toml index 1365be7..9c3e810 100644 --- a/examples/server-anthropic-messages/Cargo.toml +++ b/examples/server-anthropic-messages/Cargo.toml @@ -4,6 +4,8 @@ version = "0.0.0" edition = "2021" publish = false +[workspace] + [dependencies] async-trait = "0.1" axum = "0.8" diff --git a/examples/server-anthropic-messages/README.md b/examples/server-anthropic-messages/README.md index e188955..0d13b16 100644 --- a/examples/server-anthropic-messages/README.md +++ b/examples/server-anthropic-messages/README.md @@ -14,14 +14,18 @@ cargo run --manifest-path examples/server-anthropic-messages/Cargo.toml curl -s http://127.0.0.1:3001/v1/messages \ -H 'content-type: application/json' \ -d '{"model":"claude-x","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}' + +# Stream the response as server-sent events: +curl -N -s http://127.0.0.1:3001/v1/messages \ + -H 'content-type: application/json' \ + -d '{"model":"claude-x","max_tokens":50,"stream":true,"messages":[{"role":"user","content":"hi"}]}' ``` -**Streaming caveat.** Anthropic's published OpenAPI spec doesn't -declare `text/event-stream` on the 200 response, so the generator -can't emit an `OkStream` variant. Tracked as `openapi-generator-in6` -— the planned fix is a schema-extension overlay that adds the -streaming content type, after which this example will gain a -streaming branch with no manual changes. +Anthropic's published OpenAPI spec does not declare `text/event-stream` on the +200 response. The example config applies `sse-overlay.json` through +`generator.schema_extensions`, adding that response media type before analysis +so generation emits both `Ok(Message)` and `OkStream` response variants. -The integration test in `tests/server_examples_test.rs` guarantees -the example keeps building. +The integration test in `tests/server_examples_test.rs` regenerates and tests +the example. PR CI additionally calls the generated server with the pinned +official Anthropic Python SDK, covering both unary and streaming responses. diff --git a/examples/server-anthropic-messages/src/main.rs b/examples/server-anthropic-messages/src/main.rs index d6bcb42..2e9490a 100644 --- a/examples/server-anthropic-messages/src/main.rs +++ b/examples/server-anthropic-messages/src/main.rs @@ -32,6 +32,7 @@ use futures_util::stream; use gen::CreateMessageParams; use gen::server::{MessagesPostResponse, ServerApi, server_api_router, sse_response}; use std::convert::Infallible; +use std::io::Write; #[derive(Clone)] struct AppState; @@ -62,7 +63,7 @@ fn messages_unary() -> MessagesPostResponse { model: gen::Model::Custom("claude-demo".into()), role: gen::MessageRole::Assistant, stop_details: None, - stop_reason: None, + stop_reason: Some(gen::StopReason::EndTurn), stop_sequence: None, r#type: gen::MessageType::Message, usage: gen::Usage { @@ -71,7 +72,7 @@ fn messages_unary() -> MessagesPostResponse { cache_read_input_tokens: None, inference_geo: None, input_tokens: 0, - output_tokens: 0, + output_tokens: 2, server_tool_use: None, service_tier: None, }, @@ -86,7 +87,10 @@ fn messages_streaming() -> MessagesPostResponse { // subset; production code mirrors the full sequence from the // upstream model. let events = stream::iter(vec![ - sse_event("message_start", r#"{"type":"message_start","message":{"id":"msg_demo"}}"#), + sse_event( + "message_start", + r#"{"type":"message_start","message":{"id":"msg_demo","type":"message","role":"assistant","model":"claude-demo","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}"#, + ), sse_event( "content_block_start", r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, @@ -100,6 +104,10 @@ fn messages_streaming() -> MessagesPostResponse { r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}"#, ), sse_event("content_block_stop", r#"{"type":"content_block_stop","index":0}"#), + sse_event( + "message_delta", + r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}"#, + ), sse_event("message_stop", r#"{"type":"message_stop"}"#), ]); MessagesPostResponse::OkStream(sse_response(events)) @@ -112,8 +120,14 @@ fn sse_event(name: &str, data: &str) -> Result { #[tokio::main] async fn main() { let app = server_api_router(AppState); - let listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await.unwrap(); - println!("listening on http://{}", listener.local_addr().unwrap()); + let bind_address = + std::env::var("OPENAPI_SERVER_ADDR").unwrap_or_else(|_| "127.0.0.1:3001".to_string()); + let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap(); + println!( + "OPENAPI_SERVER_URL=http://{}", + listener.local_addr().unwrap() + ); + std::io::stdout().flush().unwrap(); axum::serve(listener, app).await.unwrap(); } diff --git a/src/analysis.rs b/src/analysis.rs index 4b383e6..9c62177 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -71,6 +71,10 @@ pub struct SchemaAnalysis { pub patterns: DetectedPatterns, /// OpenAPI operations and their request/response schemas pub operations: BTreeMap, + /// Complete response contracts by emitted operation ID and response key. + /// Unlike `OperationInfo::response_schemas`, this retains responses with + /// no body as well as their selected JSON media type and SSE declaration. + pub operation_responses: 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. @@ -98,6 +102,21 @@ pub struct SchemaAnalysis { pub validation_context: ValidationContext, } +/// Server-relevant semantics of one OpenAPI Response Object. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +pub struct OperationResponse { + /// Generated Rust body type for the preferred JSON-compatible content. + pub schema_name: Option, + /// Exact declared JSON-compatible media type selected for `schema_name`. + pub media_type: Option, + /// Whether this response also declares `text/event-stream` content. + pub supports_streaming: bool, + /// Whether the Response Object declared at least one content entry. + pub has_content: bool, + /// Declared response media types the server generator cannot emit. + pub unsupported_media_types: Vec, +} + #[derive(Debug, Clone, Default)] pub struct ValidationContext { pub openapi_version: String, @@ -647,13 +666,40 @@ pub fn merge_schema_extensions( Ok(result) } -/// Load an extension file and parse as JSON +/// Load an extension file and parse it into the JSON representation used by +/// the analyzer. YAML extensions follow the same conversion policy as YAML +/// OpenAPI documents; every other extension is parsed as JSON. fn load_extension_file(path: &Path) -> Result { let content = std::fs::read_to_string(path).map_err(|e| GeneratorError::FileError { message: format!("Failed to read file {}: {}", path.display(), e), })?; - serde_json::from_str(&content).map_err(GeneratorError::ParseError) + let is_yaml = path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml") + }); + + if is_yaml { + crate::spec_source::yaml_to_json_value(&content).map_err(|error| { + GeneratorError::FileError { + message: format!( + "Failed to parse schema extension {} as YAML: {}", + path.display(), + error + ), + } + }) + } else { + serde_json::from_str(&content).map_err(|error| GeneratorError::FileError { + message: format!( + "Failed to parse schema extension {} as JSON: {}", + path.display(), + error + ), + }) + } } /// Merge JSON objects with explicit replacement support @@ -866,6 +912,7 @@ pub struct SchemaAnalyzer { openapi_spec: Value, current_schema_name: Option, component_parameters: BTreeMap, + component_responses: BTreeMap, /// Single chokepoint for `(openapi_type, format)` → Rust-type /// decisions (Q2.0). Defaulted when the analyzer is built without a /// config; threaded from `GeneratorConfig.types` via @@ -895,6 +942,12 @@ impl SchemaAnalyzer { .and_then(|c| c.parameters.as_ref()) .cloned() .unwrap_or_default(); + let component_responses = spec + .components + .as_ref() + .and_then(|c| c.responses.as_ref()) + .cloned() + .unwrap_or_default(); Ok(Self { schemas, @@ -902,6 +955,7 @@ impl SchemaAnalyzer { openapi_spec, current_schema_name: None, component_parameters, + component_responses, type_mapper, }) } @@ -1050,6 +1104,7 @@ impl SchemaAnalyzer { type_mappings: BTreeMap::new(), }, operations: BTreeMap::new(), + operation_responses: BTreeMap::new(), operation_id_aliases: BTreeMap::new(), used_type_features: crate::type_mapping::UsedFeatures::default(), enum_extensions: BTreeMap::new(), @@ -4335,7 +4390,7 @@ impl SchemaAnalyzer { raw_operation_id.clone() }; - let op_info = self.analyze_single_operation( + let (op_info, responses) = self.analyze_single_operation( &operation_id, method, path, @@ -4349,6 +4404,9 @@ impl SchemaAnalyzer { .or_default() .push(operation_id.clone()); canonical_operation_ids.insert(Self::canonical_operation_id(&operation_id)); + analysis + .operation_responses + .insert(operation_id.clone(), responses); analysis.operations.insert(operation_id, op_info); } Ok(()) @@ -4407,7 +4465,7 @@ impl SchemaAnalyzer { operation: &crate::openapi::Operation, path_item_parameters: Option<&Vec>, _analysis: &mut SchemaAnalysis, - ) -> Result { + ) -> Result<(OperationInfo, BTreeMap)> { let raw_path_item = self .openapi_spec .get("paths") @@ -4436,6 +4494,7 @@ impl SchemaAnalyzer { stream_parameter: None, // Will be determined by StreamingConfig, not spec tags: operation.tags.clone().unwrap_or_default(), }; + let mut operation_responses = BTreeMap::new(); // Extract request body schema with content-type awareness if let Some(request_body) = &operation.request_body { @@ -4508,24 +4567,38 @@ impl SchemaAnalyzer { // Extract response schemas if let Some(responses) = &operation.responses { for (status_code, response) in responses { + let response = self.resolve_response(response)?; // T15: SSE auto-detection. If any response declares // `text/event-stream`, mark the operation as streaming. The // user can still override via config; here we lift the spec // signal so a `stream: true` parameter and an event-stream // content type produce a streaming variant by default. - if let Some(content) = response.content.as_ref() { - if content.keys().any(|ct| ct.starts_with("text/event-stream")) { - op_info.supports_streaming = true; - } + let supports_streaming = response.content.as_ref().is_some_and(|content| { + content + .keys() + .any(|ct| crate::openapi::is_event_stream_media_type(ct)) + }); + if supports_streaming { + op_info.supports_streaming = true; } - if let Some(schema) = response.json_schema() { + let mut response_info = OperationResponse { + supports_streaming, + has_content: response + .content + .as_ref() + .is_some_and(|content| !content.is_empty()), + ..Default::default() + }; + if let Some((media_type, schema)) = response.json_content() { if let Some(schema_ref) = schema.reference() { // Named schema reference if let Some(schema_name) = self.extract_schema_name(schema_ref) { op_info .response_schemas .insert(status_code.clone(), schema_name.to_string()); + response_info.schema_name = Some(schema_name.to_string()); + response_info.media_type = Some(media_type.to_string()); } } else { // Inline schema - generate a synthetic type name and analyze it @@ -4538,9 +4611,24 @@ impl SchemaAnalyzer { op_info .response_schemas - .insert(status_code.clone(), synthetic_name); + .insert(status_code.clone(), synthetic_name.clone()); + response_info.schema_name = Some(synthetic_name); + response_info.media_type = Some(media_type.to_string()); } } + response_info.unsupported_media_types = response + .content + .as_ref() + .into_iter() + .flat_map(|content| content.iter()) + .filter(|(media_type, content)| { + !crate::openapi::is_event_stream_media_type(media_type) + && (!crate::openapi::is_json_media_type(media_type) + || content.schema.is_none()) + }) + .map(|(media_type, _)| media_type.clone()) + .collect(); + operation_responses.insert(status_code.clone(), response_info); } } @@ -4679,7 +4767,32 @@ impl SchemaAnalyzer { p.rust_ident = Some(chosen); } - Ok(op_info) + Ok((op_info, operation_responses)) + } + + /// Resolve a local reusable Response Object, following chains and + /// rejecting missing, external, or cyclic references explicitly. + fn resolve_response( + &self, + response: &crate::openapi::Response, + ) -> Result { + let mut current = response; + let mut visited = HashSet::new(); + while let Some(reference) = current.reference.as_deref() { + let token = reference + .strip_prefix("#/components/responses/") + .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))?; + let name = token.replace("~1", "/").replace("~0", "~"); + if !visited.insert(name.clone()) { + return Err(GeneratorError::CircularDependency(format!( + "response reference {reference}" + ))); + } + current = self.component_responses.get(&name).ok_or_else(|| { + GeneratorError::UnresolvedReference(format!("response {reference}")) + })?; + } + Ok(current.clone()) } /// Generate a type name for an inline response schema. diff --git a/src/bin/openapi-to-rust.rs b/src/bin/openapi-to-rust.rs index c515132..6a6e54c 100644 --- a/src/bin/openapi-to-rust.rs +++ b/src/bin/openapi-to-rust.rs @@ -635,12 +635,14 @@ fn starter_module_name(source: &str) -> String { } } -fn resolve_spec_path( +fn resolve_server_spec( spec: Option, config: &std::path::Path, -) -> Result> { +) -> Result<(PathBuf, Vec), Box> { match spec { - Some(p) => Ok(p), + // An explicit spec is intentionally analyzed as-is. Schema extensions + // belong to config mode and must not affect a caller that bypasses it. + Some(path) => Ok((path, Vec::new())), None => { let cf = ConfigFile::load(config).map_err(|e| { format!( @@ -649,18 +651,24 @@ fn resolve_spec_path( e ) })?; - Ok(cf.into_generator_config().spec_path) + let generator = cf.into_generator_config(); + Ok((generator.spec_path, generator.schema_extensions)) } } } fn load_analysis( spec_path: &std::path::Path, + schema_extensions: &[PathBuf], ) -> Result> { 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)?; + let mut analyzer = if schema_extensions.is_empty() { + SchemaAnalyzer::new(spec_value)? + } else { + SchemaAnalyzer::new_with_extensions(spec_value, schema_extensions)? + }; Ok(analyzer.analyze()?) } @@ -672,8 +680,8 @@ fn run_server_list( grep: Option, json: bool, ) -> Result<(), Box> { - let spec_path = resolve_spec_path(spec, &config)?; - let analysis = load_analysis(&spec_path)?; + let (spec_path, schema_extensions) = resolve_server_spec(spec, &config)?; + let analysis = load_analysis(&spec_path, &schema_extensions)?; let index = OperationIndex::from_analysis(&analysis); let filter = ListFilter { tag, method, grep }; @@ -695,8 +703,8 @@ fn run_server_add( dry_run: bool, regenerate: bool, ) -> Result<(), Box> { - let spec_path = resolve_spec_path(spec, &config)?; - let analysis = load_analysis(&spec_path)?; + let (spec_path, schema_extensions) = resolve_server_spec(spec, &config)?; + let analysis = load_analysis(&spec_path, &schema_extensions)?; let index = OperationIndex::from_analysis(&analysis); // Determine which selectors to add. --all-tag expands; otherwise @@ -894,7 +902,30 @@ fn print_server_hint( eprintln!(" let app = {router_fn}(AppState {{ /* … */ }});"); eprintln!(); if op.supports_streaming { - eprintln!(" For streaming, return `{response_ty}::OkStream(sse_response(your_stream))`."); + let (stream_variant, runtime_status) = analysis + .operation_responses + .get(&op.operation_id) + .and_then(|responses| { + responses + .iter() + .find(|(_, response)| response.supports_streaming) + }) + .map(|(status, _)| { + ( + format!( + "{}Stream", + openapi_to_rust::server::codegen::status_variant_name(status) + ), + status == "default" || status.ends_with("XX"), + ) + }) + .unwrap_or_else(|| ("OkStream".to_string(), false)); + let stream_args = if runtime_status { + "status_code, sse_response(your_stream)" + } else { + "sse_response(your_stream)" + }; + eprintln!(" For streaming, return `{response_ty}::{stream_variant}({stream_args})`."); } } diff --git a/src/openapi.rs b/src/openapi.rs index 0e6820e..140589f 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -1034,6 +1034,19 @@ pub fn is_form_urlencoded_media_type(ct: &str) -> bool { essence == "application/x-www-form-urlencoded" } +/// Returns true only for the `text/event-stream` media type essence. +/// +/// Media type names are ASCII-case-insensitive and parameters do not change +/// the essence, so values such as `Text/Event-Stream; charset=utf-8` match, +/// while similarly prefixed subtypes such as `text/event-streaming` do not. +pub fn is_event_stream_media_type(ct: &str) -> bool { + ct.split(';') + .next() + .unwrap_or(ct) + .trim() + .eq_ignore_ascii_case("text/event-stream") +} + fn find_json_content(content: &BTreeMap) -> Option<(&str, &MediaType)> { if let Some(mt) = content.get("application/json") { return Some(("application/json", mt)); @@ -1110,6 +1123,19 @@ impl Response { .and_then(find_json_content) .and_then(|(_, media_type)| media_type.schema.as_ref()) } + + /// Get the preferred JSON-compatible media type and its schema. + pub fn json_content(&self) -> Option<(&str, &Schema)> { + self.content + .as_ref() + .and_then(find_json_content) + .and_then(|(content_type, media_type)| { + media_type + .schema + .as_ref() + .map(|schema| (content_type, schema)) + }) + } } /// OpenAPI Media Type Object diff --git a/src/server/codegen.rs b/src/server/codegen.rs index 74a7e23..2c1a3d7 100644 --- a/src/server/codegen.rs +++ b/src/server/codegen.rs @@ -8,8 +8,8 @@ //! Router wiring, extractors, and SSE response variants are P5. use crate::analysis::{ - ObjectAdditionalProperties, OperationInfo, ParameterInfo, QuerySerialization, - RequestBodyContent, SchemaAnalysis, SchemaType, + ObjectAdditionalProperties, OperationInfo, OperationResponse, ParameterInfo, + QuerySerialization, RequestBodyContent, SchemaAnalysis, SchemaType, }; use crate::config::ServerSection; use crate::generator::{CodeGenerator, GeneratedFile, GeneratorConfig}; @@ -193,6 +193,14 @@ pub enum ServerCodegenError { "cannot generate exact Axum routes for custom HTTP methods on `{path}` across multiple primary tags ({tags}); Axum 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 server for distinct primary tags `{first_tag}` and `{second_tag}`: both normalize to Rust trait identifier `{identifier}` (and the same router factory name). Rename one tag in the OpenAPI document or a schema overlay, or select the operations in separate generated servers" + )] + TagIdentifierCollision { + first_tag: String, + second_tag: String, + identifier: String, + }, #[error("cannot generate Axum route for `{path}`: {reason}")] InvalidRoutePath { path: String, reason: String }, #[error( @@ -230,6 +238,14 @@ pub enum ServerCodegenError { media_type: String, reason: String, }, + #[error( + "cannot generate response for `{operation_id}` status `{status}`: unsupported media content {media_types}" + )] + UnsupportedResponseContent { + operation_id: String, + status: String, + media_types: String, + }, } pub struct ServerCodegen<'a> { @@ -315,9 +331,11 @@ impl<'a> ServerCodegen<'a> { }) }) .collect::>()?; + validate_tag_identifier_collisions(&ops)?; validate_custom_method_route_groups(&ops)?; self.validate_query_parameters(&ops)?; self.validate_supported_server_inputs(&ops)?; + self.validate_supported_server_outputs(&ops)?; if !self.server.validation.enabled && ops.iter().any(|operation| { operation @@ -568,6 +586,41 @@ impl<'a> ServerCodegen<'a> { Ok(()) } + fn validate_supported_server_outputs( + &self, + operations: &[&OperationInfo], + ) -> Result<(), ServerCodegenError> { + for operation in operations { + if let Some(responses) = self + .analysis + .operation_responses + .get(&operation.operation_id) + { + for (status, response) in responses { + // A Response Object may advertise multiple representations. + // Generation is viable whenever at least one JSON or SSE + // representation can be emitted; unsupported alternatives + // do not invalidate that supported path. + if response.has_content + && response.schema_name.is_none() + && !response.supports_streaming + { + return Err(ServerCodegenError::UnsupportedResponseContent { + operation_id: operation.operation_id.clone(), + status: status.clone(), + media_types: if response.unsupported_media_types.is_empty() { + "(schema-less content)".to_string() + } else { + response.unsupported_media_types.join(", ") + }, + }); + } + } + } + } + Ok(()) + } + fn parameter_ident(&self, parameter: &ParameterInfo) -> syn::Ident { let generator = CodeGenerator::new(self.config.clone()); CodeGenerator::to_field_ident(&generator.param_ident_str(parameter)) @@ -2110,29 +2163,133 @@ impl<'a> ServerCodegen<'a> { let mut variants: Vec = Vec::new(); let mut arms: Vec = Vec::new(); - for (status, schema_name) in &op.response_schemas { - let variant = format_ident!("{}", status_variant_name(status)); - let body_ty = parse_type(schema_name); - variants.push(quote! { #variant(#body_ty) }); + // Analyses produced before complete response metadata existed (and a + // few unit tests that construct OperationInfo directly) still expose + // `response_schemas`. Keep that compatibility path while treating the + // complete response map as authoritative for real specs. + let fallback_responses; + let responses = if let Some(responses) = self + .analysis + .operation_responses + .get(&op.operation_id) + .filter(|responses| !responses.is_empty()) + { + responses + } else { + fallback_responses = op + .response_schemas + .iter() + .map(|(status, schema_name)| { + ( + status.clone(), + OperationResponse { + schema_name: Some(schema_name.clone()), + media_type: Some("application/json".to_string()), + supports_streaming: false, + has_content: true, + unsupported_media_types: Vec::new(), + }, + ) + }) + .collect::>(); + &fallback_responses + }; + + for (status, response) in responses { + let base_name = status_variant_name(status); + let variant = format_ident!("{}", base_name); + let runtime_status = response_uses_runtime_status(status); let status_expr = status_token(status); - arms.push(quote! { - Self::#variant(body) => (#status_expr, Json(body)).into_response() - }); - } + let status_guard = runtime_status_guard(status, quote! { status }); + + if let Some(schema_name) = &response.schema_name { + let body_ty = parse_type(schema_name); + let media_type = response.media_type.as_deref().unwrap_or("application/json"); + if runtime_status { + variants.push(quote! { #variant(StatusCode, #body_ty) }); + arms.push(quote! { + Self::#variant(status, body) => { + if !(#status_guard) { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + let mut response = (status, Json(body)).into_response(); + let Ok(content_type) = ::axum::http::HeaderValue::from_bytes(#media_type.as_bytes()) else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + response.headers_mut().insert( + ::axum::http::header::CONTENT_TYPE, + content_type, + ); + response + } + }); + } else { + variants.push(quote! { #variant(#body_ty) }); + arms.push(quote! { + Self::#variant(body) => { + let mut response = (#status_expr, Json(body)).into_response(); + let Ok(content_type) = ::axum::http::HeaderValue::from_bytes(#media_type.as_bytes()) else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + response.headers_mut().insert( + ::axum::http::header::CONTENT_TYPE, + content_type, + ); + response + } + }); + } + } else if !response.has_content { + if runtime_status { + variants.push(quote! { #variant(StatusCode) }); + arms.push(quote! { + Self::#variant(status) => { + if #status_guard { + status.into_response() + } else { + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } + }); + } else { + variants.push(quote! { #variant }); + arms.push(quote! { + Self::#variant => #status_expr.into_response() + }); + } + } - // SSE: when the operation declares text/event-stream on any - // response, add a streaming sibling variant. The user picks - // it when their request had `stream: true` (or whatever the - // streaming trigger is). Variant payload is a fully built - // `axum::Sse` so the user controls keep-alive, retry interval, - // etc. - if op.supports_streaming { - variants.push(quote! { - OkStream(::axum::response::sse::Sse) - }); - arms.push(quote! { - Self::OkStream(sse) => sse.into_response() - }); + // The stream variant belongs to the response which declared SSE, + // so it carries that response's status instead of implicitly 200. + if response.supports_streaming { + let stream_variant = format_ident!("{}Stream", base_name); + if runtime_status { + variants.push(quote! { + #stream_variant(StatusCode, ::axum::response::sse::Sse) + }); + arms.push(quote! { + Self::#stream_variant(status, sse) => { + if !(#status_guard) { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + let mut response = sse.into_response(); + *response.status_mut() = status; + response + } + }); + } else { + variants.push(quote! { + #stream_variant(::axum::response::sse::Sse) + }); + arms.push(quote! { + Self::#stream_variant(sse) => { + let mut response = sse.into_response(); + *response.status_mut() = #status_expr; + response + } + }); + } + } } // Fallback: if no response variants were declared we still need @@ -2339,6 +2496,26 @@ fn primary_tag(op: &OperationInfo) -> String { op.tags.first().cloned().unwrap_or_else(|| "Server".into()) } +/// Reject distinct raw primary tags which would emit the same Rust items. +/// Sorting the raw tags first keeps the selected pair and diagnostic stable +/// even when selectors are reordered in configuration. +fn validate_tag_identifier_collisions(ops: &[&OperationInfo]) -> Result<(), ServerCodegenError> { + let raw_tags: std::collections::BTreeSet = + ops.iter().map(|operation| primary_tag(operation)).collect(); + let mut raw_by_identifier: BTreeMap = BTreeMap::new(); + for raw_tag in raw_tags { + let identifier = trait_ident_for_tag(&raw_tag).to_string(); + if let Some(first_tag) = raw_by_identifier.insert(identifier.clone(), raw_tag.clone()) { + return Err(ServerCodegenError::TagIdentifierCollision { + first_tag, + second_tag: raw_tag, + identifier, + }); + } + } + Ok(()) +} + 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 { @@ -2370,7 +2547,8 @@ fn trait_ident_for_tag(tag: &str) -> syn::Ident { /// Convert a status code (or `default`, or wildcard `4XX`) to a /// variant identifier. -fn status_variant_name(status: &str) -> String { +/// Rust response-enum variant name for an OpenAPI response key. +pub fn status_variant_name(status: &str) -> String { match status { "200" => "Ok".into(), "201" => "Created".into(), @@ -2391,6 +2569,7 @@ fn status_variant_name(status: &str) -> String { "502" => "BadGateway".into(), "503" => "ServiceUnavailable".into(), "default" => "Default".into(), + "1XX" => "Informational".into(), "2XX" => "Success".into(), "3XX" => "Redirection".into(), "4XX" => "ClientError".into(), @@ -2399,6 +2578,25 @@ fn status_variant_name(status: &str) -> String { } } +fn response_uses_runtime_status(status: &str) -> bool { + status == "default" || matches!(status.as_bytes(), [b'1'..=b'5', b'X' | b'x', b'X' | b'x']) +} + +fn runtime_status_guard(status: &str, value: TokenStream) -> TokenStream { + if status == "default" { + quote! { true } + } else { + let class = u16::from( + status + .as_bytes() + .first() + .map(|digit| digit - b'0') + .unwrap_or_default(), + ); + quote! { #value.as_u16() / 100 == #class } + } +} + /// Emit a StatusCode expression for a status string. Numeric codes use /// the named constants where possible; wildcard ranges and `default` /// pick a representative code (the lowest in-range). @@ -2423,10 +2621,11 @@ fn status_token(status: &str) -> TokenStream { "502" => quote! { StatusCode::BAD_GATEWAY }, "503" => quote! { StatusCode::SERVICE_UNAVAILABLE }, "default" => quote! { StatusCode::INTERNAL_SERVER_ERROR }, - "2XX" => quote! { StatusCode::OK }, - "3XX" => quote! { StatusCode::MOVED_PERMANENTLY }, - "4XX" => quote! { StatusCode::BAD_REQUEST }, - "5XX" => quote! { StatusCode::INTERNAL_SERVER_ERROR }, + // Range/default responses carry a runtime StatusCode in their enum + // variant and never reach this fixed-status helper. + "1XX" | "2XX" | "3XX" | "4XX" | "5XX" => { + quote! { StatusCode::INTERNAL_SERVER_ERROR } + } // Specific numeric codes not in our table — fall back to // StatusCode::from_u16. Codegen ensures a panic-free path by // unwrapping on a value that must parse (we already @@ -2484,6 +2683,31 @@ mod tests { assert_eq!(id.to_string(), "ServerApi"); } + #[test] + fn colliding_raw_tags_are_rejected_in_stable_order() { + let first = OperationInfo { + operation_id: "first".into(), + tags: vec!["foo_bar".into()], + ..Default::default() + }; + let second = OperationInfo { + operation_id: "second".into(), + tags: vec!["foo-bar".into()], + ..Default::default() + }; + let error = validate_tag_identifier_collisions(&[&first, &second]).unwrap_err(); + assert!(matches!( + error, + ServerCodegenError::TagIdentifierCollision { + first_tag, + second_tag, + identifier, + } if first_tag == "foo-bar" + && second_tag == "foo_bar" + && identifier == "FooBarApi" + )); + } + #[test] fn custom_methods_on_one_path_share_an_exact_dispatcher() { let dispatcher = format_ident!("cache_custom_method_dispatch"); diff --git a/tests/anthropic_sdk_compat_test.rs b/tests/anthropic_sdk_compat_test.rs new file mode 100644 index 0000000..1d21469 --- /dev/null +++ b/tests/anthropic_sdk_compat_test.rs @@ -0,0 +1,117 @@ +//! Official Anthropic Python SDK compatibility smoke test. +//! +//! This is ignored for normal local `cargo test` because it requires the +//! dependencies in `tests/python/anthropic_sdk_compat/requirements.txt`. CI +//! runs it on every pull request with the SDK pinned to that file's version. + +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn generator_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_openapi-to-rust")) +} + +fn regenerate(example_dir: &Path) { + let output = Command::new(generator_binary()) + .current_dir(example_dir) + .args(["generate", "--config"]) + .arg(example_dir.join("openapi-to-rust.toml")) + .output() + .expect("failed to run openapi-to-rust generate"); + assert!( + output.status.success(), + "Anthropic example generation failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +fn build_and_test_example(example_dir: &Path) { + for action in ["test", "build"] { + let output = Command::new(env!("CARGO")) + .current_dir(example_dir) + .env("CARGO_TARGET_DIR", example_dir.join("target")) + .arg(action) + .output() + .unwrap_or_else(|error| panic!("failed to run cargo {action}: {error}")); + assert!( + output.status.success(), + "Anthropic server example cargo {action} failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } +} + +struct Server(Child); + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn start_server(example_dir: &Path) -> (Server, String) { + let binary = example_dir.join("target").join("debug").join(format!( + "server-anthropic-messages{}", + std::env::consts::EXE_SUFFIX + )); + let mut child = Command::new(&binary) + .env("OPENAPI_SERVER_ADDR", "127.0.0.1:0") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap_or_else(|error| panic!("failed to start {}: {error}", binary.display())); + let stdout = child.stdout.take().expect("server stdout was not piped"); + let server = Server(child); + + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let mut line = String::new(); + let result = BufReader::new(stdout).read_line(&mut line).map(|_| line); + let _ = sender.send(result); + }); + let line = receiver + .recv_timeout(Duration::from_secs(30)) + .expect("generated server did not announce its address within 30 seconds") + .expect("failed to read generated server address"); + let base_url = line + .trim() + .strip_prefix("OPENAPI_SERVER_URL=") + .unwrap_or_else(|| panic!("unexpected generated server output: {line:?}")) + .to_owned(); + (server, base_url) +} + +#[test] +#[ignore = "requires the pinned Python dependencies in tests/python/anthropic_sdk_compat"] +fn official_anthropic_python_sdk_matches_generated_axum_server() { + let example_dir = repo_root().join("examples/server-anthropic-messages"); + regenerate(&example_dir); + build_and_test_example(&example_dir); + let (_server, base_url) = start_server(&example_dir); + + let python = std::env::var_os("ANTHROPIC_COMPAT_PYTHON").unwrap_or_else(|| "python3".into()); + let output = Command::new(python) + .current_dir(repo_root()) + .arg("tests/python/anthropic_sdk_compat/smoke.py") + .arg(base_url) + .output() + .expect("failed to run official Anthropic Python SDK smoke script"); + assert!( + output.status.success(), + "official Anthropic Python SDK compatibility smoke failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + print!("{}", String::from_utf8_lossy(&output.stdout)); +} diff --git a/tests/cli_workflow_test.rs b/tests/cli_workflow_test.rs index 93616f5..dc8e2bb 100644 --- a/tests/cli_workflow_test.rs +++ b/tests/cli_workflow_test.rs @@ -216,6 +216,74 @@ fn init_creates_a_safe_config_consumed_by_bare_generate() { ); } +#[test] +fn server_commands_apply_configured_schema_extensions_but_explicit_spec_is_raw() { + let temp = TempDir::new().unwrap(); + std::fs::write(temp.path().join("api.yaml"), SPEC).unwrap(); + std::fs::write( + temp.path().join("sse-overlay.json"), + r#"{ + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "content": { + "text/event-stream": { + "schema": { "type": "object" } + } + } + } + } + } + } + } +}"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("openapi-to-rust.toml"), + r#"[generator] +spec_path = "api.yaml" +output_dir = "src/gen" +module_name = "api" +schema_extensions = ["sse-overlay.json"] + +[features] +enable_async_client = false + +[server] +framework = "axum" +operations = [] +"#, + ) + .unwrap(); + + let configured_list = run(temp.path(), &["server", "list"]); + assert_success(&configured_list); + assert!( + String::from_utf8_lossy(&configured_list.stdout).contains("/pets [SSE]"), + "configured server list did not apply the SSE overlay:\n{}", + String::from_utf8_lossy(&configured_list.stdout) + ); + + let configured_add = run(temp.path(), &["server", "add", "listPets", "--dry-run"]); + assert_success(&configured_add); + assert!( + String::from_utf8_lossy(&configured_add.stdout).contains("Streaming: yes"), + "configured server add did not apply the SSE overlay:\n{}", + String::from_utf8_lossy(&configured_add.stdout) + ); + + let explicit_list = run(temp.path(), &["server", "list", "--spec", "api.yaml"]); + assert_success(&explicit_list); + assert!( + !String::from_utf8_lossy(&explicit_list.stdout).contains("[SSE]"), + "an explicit --spec unexpectedly applied config extensions:\n{}", + String::from_utf8_lossy(&explicit_list.stdout) + ); +} + fn serve_once( body: &'static str, declared_length: Option, diff --git a/tests/http_error_test.rs b/tests/http_error_test.rs index 951f41a..480f456 100644 --- a/tests/http_error_test.rs +++ b/tests/http_error_test.rs @@ -336,6 +336,7 @@ fn test_generated_error_code() { type_mappings: BTreeMap::new(), }, operations: BTreeMap::new(), + operation_responses: BTreeMap::new(), operation_id_aliases: BTreeMap::new(), used_type_features: Default::default(), enum_extensions: BTreeMap::new(), diff --git a/tests/openai_sdk_compat_test.rs b/tests/openai_sdk_compat_test.rs index c499f45..dc43913 100644 --- a/tests/openai_sdk_compat_test.rs +++ b/tests/openai_sdk_compat_test.rs @@ -14,31 +14,12 @@ fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } -fn target_dir() -> PathBuf { - match std::env::var_os("CARGO_TARGET_DIR") { - Some(path) if Path::new(&path).is_absolute() => PathBuf::from(path), - Some(path) => repo_root().join(path), - None => repo_root().join("target"), - } -} - -fn workspace_binary() -> PathBuf { - target_dir() - .join("debug") - .join(format!("openapi-to-rust{}", std::env::consts::EXE_SUFFIX)) -} - -fn ensure_generator_built() { - let status = Command::new(env!("CARGO")) - .current_dir(repo_root()) - .args(["build", "--bin", "openapi-to-rust"]) - .status() - .expect("failed to spawn cargo build for openapi-to-rust"); - assert!(status.success(), "failed to build openapi-to-rust"); +fn generator_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_openapi-to-rust")) } fn regenerate(example_dir: &Path) { - let output = Command::new(workspace_binary()) + let output = Command::new(generator_binary()) .current_dir(example_dir) .args(["generate", "--config"]) .arg(example_dir.join("openapi-to-rust.toml")) @@ -52,19 +33,21 @@ fn regenerate(example_dir: &Path) { ); } -fn build_example(example_dir: &Path) { - let output = Command::new(env!("CARGO")) - .current_dir(example_dir) - .env("CARGO_TARGET_DIR", example_dir.join("target")) - .arg("build") - .output() - .expect("failed to build generated OpenAI server example"); - assert!( - output.status.success(), - "OpenAI server example build failed:\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); +fn build_and_test_example(example_dir: &Path) { + for action in ["test", "build"] { + let output = Command::new(env!("CARGO")) + .current_dir(example_dir) + .env("CARGO_TARGET_DIR", example_dir.join("target")) + .arg(action) + .output() + .unwrap_or_else(|error| panic!("failed to run cargo {action}: {error}")); + assert!( + output.status.success(), + "OpenAI server example cargo {action} failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } } struct Server(Child); @@ -111,10 +94,9 @@ fn start_server(example_dir: &Path) -> (Server, String) { #[test] #[ignore = "requires the pinned Python dependencies in tests/python/openai_sdk_compat"] fn official_openai_python_sdk_matches_generated_axum_server() { - ensure_generator_built(); let example_dir = repo_root().join("examples/server-openai-responses"); regenerate(&example_dir); - build_example(&example_dir); + build_and_test_example(&example_dir); let (_server, base_url) = start_server(&example_dir); let python = std::env::var_os("OPENAI_COMPAT_PYTHON").unwrap_or_else(|| "python3".into()); diff --git a/tests/operation_generation_test.rs b/tests/operation_generation_test.rs index 21097dd..632317c 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_responses: BTreeMap::new(), operation_id_aliases: BTreeMap::new(), used_type_features: Default::default(), enum_extensions: BTreeMap::new(), diff --git a/tests/python/anthropic_sdk_compat/requirements.txt b/tests/python/anthropic_sdk_compat/requirements.txt new file mode 100644 index 0000000..97a63e6 --- /dev/null +++ b/tests/python/anthropic_sdk_compat/requirements.txt @@ -0,0 +1 @@ +anthropic==0.120.0 diff --git a/tests/python/anthropic_sdk_compat/smoke.py b/tests/python/anthropic_sdk_compat/smoke.py new file mode 100644 index 0000000..847bc47 --- /dev/null +++ b/tests/python/anthropic_sdk_compat/smoke.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Exercise the generated Axum server through the pinned official Anthropic SDK.""" + +from __future__ import annotations + +import sys + +import anthropic +from anthropic import Anthropic + + +EXPECTED_ANTHROPIC_VERSION = "0.120.0" + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit(f"usage: {sys.argv[0]} BASE_URL") + if anthropic.__version__ != EXPECTED_ANTHROPIC_VERSION: + raise AssertionError( + f"expected anthropic=={EXPECTED_ANTHROPIC_VERSION}, got {anthropic.__version__}" + ) + + client = Anthropic( + base_url=sys.argv[1].rstrip("/") + "/", + api_key="test", + max_retries=0, + timeout=30.0, + ) + request = { + "model": "claude-demo", + "max_tokens": 16, + "messages": [{"role": "user", "content": "sdk compatibility"}], + } + + unary = client.messages.create(**request) + assert unary.id == "msg_demo" + assert unary.model == "claude-demo" + assert unary.role == "assistant" + assert unary.stop_reason == "end_turn" + assert unary.content[0].type == "text" + assert unary.content[0].text == "hello (unary)" + + with client.messages.stream(**request) as stream: + assert "".join(stream.text_stream) == "hello world" + final = stream.get_final_message() + assert final.id == "msg_demo" + assert final.stop_reason == "end_turn" + assert final.content[0].type == "text" + assert final.content[0].text == "hello world" + + print(f"anthropic {anthropic.__version__} compatibility smoke passed") + + +if __name__ == "__main__": + main() diff --git a/tests/python/openai_sdk_compat/smoke.py b/tests/python/openai_sdk_compat/smoke.py index 8100a35..72415a3 100644 --- a/tests/python/openai_sdk_compat/smoke.py +++ b/tests/python/openai_sdk_compat/smoke.py @@ -25,6 +25,7 @@ def main() -> None: api_key="test", admin_api_key="test-admin", max_retries=0, + timeout=30.0, ) unary = client.responses.create(model="gpt-4.1-mini", input="sdk unary") diff --git a/tests/schema_extensions_test.rs b/tests/schema_extensions_test.rs new file mode 100644 index 0000000..93af5da --- /dev/null +++ b/tests/schema_extensions_test.rs @@ -0,0 +1,126 @@ +use openapi_to_rust::{SchemaAnalyzer, merge_schema_extensions}; + +fn write_overlay(directory: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf { + let path = directory.join(name); + std::fs::write(&path, content).unwrap(); + path +} + +#[test] +fn json_schema_extension_deep_merges_objects_and_arrays() { + let directory = tempfile::tempdir().unwrap(); + let overlay = write_overlay( + directory.path(), + "overlay.json", + r#"{ + "root": { + "nested": { "from_json": true }, + "items": ["json"] + } + }"#, + ); + let base = serde_json::json!({ + "root": { + "kept": true, + "nested": { "from_base": true }, + "items": ["base"] + } + }); + + let merged = merge_schema_extensions(base, &[overlay]).unwrap(); + + assert_eq!(merged["root"]["kept"], true); + assert_eq!(merged["root"]["nested"]["from_base"], true); + assert_eq!(merged["root"]["nested"]["from_json"], true); + assert_eq!(merged["root"]["items"], serde_json::json!(["base", "json"])); +} + +#[test] +fn yaml_schema_extension_deep_merges_objects_and_arrays() { + let directory = tempfile::tempdir().unwrap(); + let overlay = write_overlay( + directory.path(), + "overlay.yaml", + r#"root: + nested: + from_yaml: true + items: + - yaml +"#, + ); + let base = serde_json::json!({ + "root": { + "kept": true, + "nested": { "from_base": true }, + "items": ["base"] + } + }); + + let merged = merge_schema_extensions(base, &[overlay]).unwrap(); + + assert_eq!(merged["root"]["kept"], true); + assert_eq!(merged["root"]["nested"]["from_base"], true); + assert_eq!(merged["root"]["nested"]["from_yaml"], true); + assert_eq!(merged["root"]["items"], serde_json::json!(["base", "yaml"])); +} + +#[test] +fn yaml_sse_overlay_marks_operation_as_streaming() { + let directory = tempfile::tempdir().unwrap(); + let overlay = write_overlay( + directory.path(), + "sse-overlay.yml", + r#"paths: + /messages: + post: + responses: + '200': + content: + text/event-stream: + schema: + type: string +"#, + ); + let spec = serde_json::json!({ + "openapi": "3.0.3", + "info": { "title": "overlay streaming", "version": "1" }, + "paths": { + "/messages": { + "post": { + "operationId": "createMessage", + "responses": { + "200": { + "description": "message", + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + } + } + } + } + } + }); + + let mut analyzer = SchemaAnalyzer::new_with_extensions(spec, &[overlay]).unwrap(); + let analysis = analyzer.analyze().unwrap(); + let operation = analysis.operations.get("createMessage").unwrap(); + + assert!(operation.supports_streaming); +} + +#[test] +fn malformed_overlay_error_names_the_extension_path() { + let directory = tempfile::tempdir().unwrap(); + let overlay = write_overlay(directory.path(), "broken.yaml", "paths: [\n"); + + let error = merge_schema_extensions(serde_json::json!({}), &[&overlay]).unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains(&overlay.display().to_string()), + "{message}" + ); + assert!(message.contains("as YAML"), "{message}"); +} diff --git a/tests/selective_client_test.rs b/tests/selective_client_test.rs index 61f641d..bcd6c8f 100644 --- a/tests/selective_client_test.rs +++ b/tests/selective_client_test.rs @@ -479,11 +479,11 @@ mod tests { #[async_trait::async_trait] impl ThingsApi for Api { async fn query_things(&self) -> QueryThingsResponse { - QueryThingsResponse::Empty + QueryThingsResponse::NoContent } async fn purge_things(&self) -> PurgeThingsResponse { - PurgeThingsResponse::Empty + PurgeThingsResponse::NoContent } } diff --git a/tests/server_body_validation_test.rs b/tests/server_body_validation_test.rs index 7139caa..d0d4b4d 100644 --- a/tests/server_body_validation_test.rs +++ b/tests/server_body_validation_test.rs @@ -239,12 +239,12 @@ mod tests { impl ServerApi for Api { async fn create_payload(&self, _body: Payload) -> CreatePayloadResponse { self.calls.fetch_add(1, Ordering::SeqCst); - CreatePayloadResponse::Empty + CreatePayloadResponse::NoContent } async fn maybe_payload(&self, _body: Option) -> MaybePayloadResponse { self.calls.fetch_add(1, Ordering::SeqCst); - MaybePayloadResponse::Empty + MaybePayloadResponse::NoContent } } diff --git a/tests/server_examples_test.rs b/tests/server_examples_test.rs index 1e76d07..acf9d34 100644 --- a/tests/server_examples_test.rs +++ b/tests/server_examples_test.rs @@ -1,15 +1,15 @@ //! End-to-end smoke test for both server-codegen examples. //! //! Steps per example: -//! 1. Build the workspace `openapi-to-rust` binary (once). -//! 2. Run it against the example's TOML config to populate +//! 1. Run the Cargo-provided `openapi-to-rust` test binary against the +//! example's TOML config to populate //! `examples//src/gen/`. -//! 3. Run `cargo build` against the example crate and assert exit 0. +//! 2. Run `cargo test` against the example crate and assert exit 0. //! //! These tests are gated behind `--ignored` by default because they -//! take ~30s each (transitive `cargo build` of generated types). Run -//! with `cargo test --test server_examples_test -- --ignored` or in -//! CI with the `examples` job. +//! compile generated types and their transitive dependencies. Run with +//! `cargo test --test server_examples_test -- --ignored`. PR CI exercises +//! each example through its official Python SDK compatibility job. use std::path::{Path, PathBuf}; use std::process::Command; @@ -20,25 +20,7 @@ fn repo_root() -> PathBuf { } fn workspace_binary() -> PathBuf { - // Use the same target/debug dir cargo built into. CARGO_TARGET_DIR - // overrides this if set, but the default suffices for local + CI. - repo_root() - .join("target") - .join("debug") - .join("openapi-to-rust") -} - -fn ensure_binary_built() { - let bin = workspace_binary(); - if bin.exists() { - return; - } - let status = Command::new(env!("CARGO")) - .current_dir(repo_root()) - .args(["build", "--bin", "openapi-to-rust"]) - .status() - .expect("failed to spawn cargo build"); - assert!(status.success(), "cargo build of openapi-to-rust failed"); + PathBuf::from(env!("CARGO_BIN_EXE_openapi-to-rust")) } fn regenerate(example_dir: &Path) { @@ -81,7 +63,6 @@ fn cargo_test(example_dir: &Path) { #[test] #[ignore] fn openai_responses_example_builds() { - ensure_binary_built(); let dir = repo_root().join("examples").join("server-openai-responses"); regenerate(&dir); cargo_test(&dir); @@ -90,7 +71,6 @@ fn openai_responses_example_builds() { #[test] #[ignore] fn anthropic_messages_example_builds() { - ensure_binary_built(); let dir = repo_root() .join("examples") .join("server-anthropic-messages"); diff --git a/tests/server_parameter_media_validation_test.rs b/tests/server_parameter_media_validation_test.rs index 721b5ff..286e883 100644 --- a/tests/server_parameter_media_validation_test.rs +++ b/tests/server_parameter_media_validation_test.rs @@ -126,14 +126,14 @@ mod tests { async fn check_item(&self, item_id: i64, limit: i64, mode: Option, x_level: i64, _1_mode: Option, session: String) -> CheckItemResponse { assert_eq!((item_id, limit, mode, x_level, _1_mode, session.as_str()), (2, 3, None, 4, None, "safe")); self.0.fetch_add(1, Ordering::SeqCst); - CheckItemResponse::Empty + CheckItemResponse::NoContent } async fn submit_form(&self, body: FormPayload) -> SubmitFormResponse { assert_eq!(body.name, "valid"); assert_eq!(body.count, 2); self.0.fetch_add(1, Ordering::SeqCst); - SubmitFormResponse::Empty + SubmitFormResponse::NoContent } } diff --git a/tests/server_query_roundtrip_test.rs b/tests/server_query_roundtrip_test.rs index 112c31c..4525404 100644 --- a/tests/server_query_roundtrip_test.rs +++ b/tests/server_query_roundtrip_test.rs @@ -231,7 +231,7 @@ mod tests { "scores": scores, })) .unwrap(); - QueryRoundTripResponse::Empty + QueryRoundTripResponse::NoContent } } diff --git a/tests/server_response_semantics_test.rs b/tests/server_response_semantics_test.rs new file mode 100644 index 0000000..42c465a --- /dev/null +++ b/tests/server_response_semantics_test.rs @@ -0,0 +1,259 @@ +use openapi_to_rust::config::{ServerSection, ServerValidationSection}; +use openapi_to_rust::server::codegen::ServerCodegen; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::process::Command; + +fn response_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "response semantics", "version": "1.0.0" }, + "paths": { + "/things": { "post": { + "operationId": "createThing", + "tags": ["Things"], + "responses": { + "201": { "$ref": "#/components/responses/CreatedAlias" }, + "202": { "description": "accepted" }, + "204": { "description": "no content" }, + "2XX": { "description": "other success", "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Thing" } } + }}, + "default": { "description": "other response", "content": { + "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } + }} + } + }}, + "/stream": { "get": { + "operationId": "streamThing", + "tags": ["Things"], + "responses": { + "202": { "description": "stream accepted", "content": { + "Text/Event-Stream; charset=utf-8": {} + }} + } + }}, + "/not-stream": { "get": { + "operationId": "notStream", + "responses": { + "200": { "description": "not SSE", "content": { + "text/event-streaming": {} + }} + } + }} + }, + "components": { + "schemas": { + "Thing": { "type": "string" }, + "Problem": { "type": "string" } + }, + "responses": { + "CreatedAlias": { "$ref": "#/components/responses/Created" }, + "Created": { "description": "created", "content": { + "application/vnd.example+json": { + "schema": { "$ref": "#/components/schemas/Thing" } + } + }} + } + } + }) +} + +#[test] +fn analysis_retains_resolved_bodyless_media_and_exact_sse_responses() { + let analysis = SchemaAnalyzer::new(response_spec()) + .unwrap() + .analyze() + .unwrap(); + let create = &analysis.operation_responses["createThing"]; + assert_eq!(create["201"].schema_name.as_deref(), Some("Thing")); + assert_eq!( + create["201"].media_type.as_deref(), + Some("application/vnd.example+json") + ); + assert!(create["202"].schema_name.is_none()); + assert!(create["204"].schema_name.is_none()); + assert!(analysis.operation_responses["streamThing"]["202"].supports_streaming); + assert!(!analysis.operation_responses["notStream"]["200"].supports_streaming); +} + +#[test] +fn generated_responses_preserve_status_ranges_media_and_sse_status() { + 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 analysis = SchemaAnalyzer::new(response_spec()) + .unwrap() + .analyze() + .unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["createThing".into(), "streamThing".into()], + prune_models: true, + validation: ServerValidationSection { + enabled: false, + ..Default::default() + }, + }; + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "responses".into(), + enable_async_client: false, + server: Some(server), + ..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; + +#[cfg(test)] +mod tests { + use super::generated::server::*; + use axum::{http::{header::CONTENT_TYPE, StatusCode}, response::IntoResponse}; + + #[test] + fn response_contract_is_preserved() { + let created = CreateThingResponse::Created("created".to_string()).into_response(); + assert_eq!(created.status(), StatusCode::CREATED); + assert_eq!(created.headers()[CONTENT_TYPE], "application/vnd.example+json"); + + assert_eq!( + CreateThingResponse::Accepted.into_response().status(), + StatusCode::ACCEPTED, + ); + assert_eq!( + CreateThingResponse::NoContent.into_response().status(), + StatusCode::NO_CONTENT, + ); + assert_eq!( + CreateThingResponse::Success(StatusCode::MULTI_STATUS, "multi".to_string()) + .into_response() + .status(), + StatusCode::MULTI_STATUS, + ); + assert_eq!( + CreateThingResponse::Success(StatusCode::BAD_REQUEST, "wrong".to_string()) + .into_response() + .status(), + StatusCode::INTERNAL_SERVER_ERROR, + ); + let default = CreateThingResponse::Default( + StatusCode::IM_A_TEAPOT, + "problem".to_string(), + ).into_response(); + assert_eq!(default.status(), StatusCode::IM_A_TEAPOT); + assert_eq!(default.headers()[CONTENT_TYPE], "application/problem+json"); + + let stream = futures_util::stream::empty::< + Result + >(); + let streamed = StreamThingResponse::AcceptedStream(sse_response(stream)).into_response(); + assert_eq!(streamed.status(), StatusCode::ACCEPTED); + assert_eq!(streamed.headers()[CONTENT_TYPE], "text/event-stream"); + } +} +"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + r#"[package] +name = "server-response-semantics" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1" +axum = "0.8" +futures-core = "0.3" +futures-util = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +url = "2" +serde_urlencoded = "0.7" +"#, + ) + .unwrap(); + + let output = Command::new("cargo") + .arg("test") + .arg("--quiet") + .current_dir(temp.path()) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target/server-response-semantics-smoke"), + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "generated response server failed its runtime test:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +#[test] +fn unsupported_response_content_fails_instead_of_becoming_a_unit_variant() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "unsupported response", "version": "1.0.0" }, + "paths": { "/report": { "get": { + "operationId": "getReport", + "responses": { "200": { "description": "report", "content": { + "text/plain": { "schema": { "type": "string" } } + }}} + }}} + }); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["getReport".into()], + prune_models: false, + validation: Default::default(), + }; + 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("getReport"), "{error}"); + assert!(error.contains("text/plain"), "{error}"); +} + +#[test] +fn supported_json_representation_allows_unsupported_alternatives() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "mixed response", "version": "1.0.0" }, + "paths": { "/report": { "get": { + "operationId": "getReport", + "responses": { "200": { "description": "report", "content": { + "application/json": { "schema": { "type": "string" } }, + "application/xml": { "schema": { "type": "string" } } + }}} + }}} + }); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["getReport".into()], + prune_models: false, + validation: Default::default(), + }; + let config = GeneratorConfig { + enable_async_client: false, + server: Some(server.clone()), + ..Default::default() + }; + ServerCodegen::new(&config, &analysis, &server) + .generate() + .expect("the supported JSON representation should be generated"); +} diff --git a/tests/server_tag_collision_test.rs b/tests/server_tag_collision_test.rs new file mode 100644 index 0000000..a03ddae --- /dev/null +++ b/tests/server_tag_collision_test.rs @@ -0,0 +1,46 @@ +use openapi_to_rust::config::ServerSection; +use openapi_to_rust::server::codegen::ServerCodegen; +use openapi_to_rust::{GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; + +#[test] +fn server_generation_reports_colliding_raw_tags_and_rust_identifier() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "tag collisions", "version": "1.0.0" }, + "paths": { + "/hyphen": { "get": { + "operationId": "getHyphen", + "tags": ["foo-bar"], + "responses": { "204": { "description": "ok" } } + }}, + "/underscore": { "get": { + "operationId": "getUnderscore", + "tags": ["foo_bar"], + "responses": { "204": { "description": "ok" } } + }} + } + }); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + // Reverse lexical order to verify the diagnostic remains stable. + operations: vec!["getUnderscore".into(), "getHyphen".into()], + prune_models: false, + validation: Default::default(), + }; + 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("foo-bar"), "{error}"); + assert!(error.contains("foo_bar"), "{error}"); + assert!(error.contains("FooBarApi"), "{error}"); + assert!(error.contains("Rename one tag"), "{error}"); +} From 2f41d50fad86bd45ebcc263bd4bf598330825241 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 26 Jul 2026 16:22:43 -0600 Subject: [PATCH 2/4] chore: prepare 0.10.0 --- .beads/issues.jsonl | 2 +- CHANGELOG.md | 13 +++++++++++-- Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index add95ec..4f87f1e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -80,7 +80,7 @@ {"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":"Approach: make server list/add analyze the full generator config including schema_extensions; isolate the Anthropic example crate from the root workspace; make server example tests use Cargo's current binary path; update example docs; add focused regression coverage. Audit server implementation and PR official-client CI for adjacent high-confidence defects before closing.","status":"closed","priority":3,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:54:29Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:26Z","started_at":"2026-07-26T21:52:00Z","closed_at":"2026-07-26T22:15:26Z","close_reason":"Reopened review findings and adjacent server audit issues are fixed: overlay-aware CLI, YAML overlays, reliable examples, official OpenAI/Anthropic SDK PR gates, faithful response semantics, and tag-collision diagnostics. All scoped and full all-features gates pass.","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":"Approach: make server list/add analyze the full generator config including schema_extensions; isolate the Anthropic example crate from the root workspace; make server example tests use Cargo's current binary path; update example docs; add focused regression coverage. Audit server implementation and PR official-client CI for adjacent high-confidence defects before closing.\nRelease target: 0.10.0 because corrected response status/SSE variants and wildcard/default payloads change regenerated public server APIs under the documented pre-1.0 policy.","status":"closed","priority":3,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:54:29Z","created_by":"James Lal","updated_at":"2026-07-26T22:22:11Z","started_at":"2026-07-26T21:52:00Z","closed_at":"2026-07-26T22:15:26Z","close_reason":"Reopened review findings and adjacent server audit issues are fixed: overlay-aware CLI, YAML overlays, reliable examples, official OpenAI/Anthropic SDK PR gates, faithful response semantics, and tag-collision diagnostics. All scoped and full all-features gates pass.","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.","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/CHANGELOG.md b/CHANGELOG.md index 46ec6ec..a30e763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,19 @@ 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 +## [0.10.0] - 2026-07-26 ### Added - Every-PR compatibility coverage for the generated Anthropic Messages server through the pinned official Python SDK, including unary and SSE responses. +### Changed + +- Regenerated server response enums now use the declared status in bodyless and + SSE variant names and require a runtime status for wildcard/default variants. + This is a source-breaking correction for existing server trait implementations. + ### Fixed - Config-driven `server list` and `server add` now apply @@ -21,6 +27,8 @@ when correcting output that was wrong or incomplete on the wire. - Generated server response enums retain reusable Response Object references, bodyless status codes, vendor/problem JSON media types, SSE status codes, and runtime status values for wildcard/default responses. +- Server generation rejects response sets that contain only unsupported media + types and reports normalized Rust identifier collisions between distinct tags. - Server example tests use Cargo's current integration-test binary instead of a potentially stale hard-coded `target/debug` executable. @@ -172,7 +180,8 @@ when correcting output that was wrong or incomplete on the wire. 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.9.1...HEAD +[Unreleased]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.10.0...HEAD +[0.10.0]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.9.1...v0.10.0 [0.9.1]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.9.0...v0.9.1 [0.9.0]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.7.0...v0.8.0 diff --git a/Cargo.lock b/Cargo.lock index 9c66c3d..bb6d742 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -992,7 +992,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openapi-to-rust" -version = "0.9.1" +version = "0.10.0" dependencies = [ "clap", "heck", diff --git a/Cargo.toml b/Cargo.toml index ebe5cce..c7c4741 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["tmp"] [package] name = "openapi-to-rust" -version = "0.9.1" +version = "0.10.0" edition = "2024" rust-version = "1.88.0" autobins = false From 32fcb89eb2864e3b8a70eb271b264ce8a55283a9 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 26 Jul 2026 16:40:29 -0600 Subject: [PATCH 3/4] ci: cache scratch compilation targets --- .beads/issues.jsonl | 1 + .github/workflows/ci.yml | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4f87f1e..10f0679 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -37,6 +37,7 @@ {"id":"openapi-generator-cv4","title":"Support exploded query parameters (GH #27)","description":"GH issue 27: query params with style=form, explode=true (OAS defaults) and object schemas currently map to Option\u003cimpl AsRef\u003cstr\u003e\u003e and are sent as one opaque string. Per OAS/RFC6570 form-explode, each object property must become its own query pair (?color=red). Fix: analyzer synthesizes/resolves a typed struct for object query params with form+explode semantics; client generator emits req.query(\u0026struct) so reqwest/serde_urlencoded serializes properties as individual pairs.","notes":"Implemented on branch issue-27-exploded-query-params, draft PR https://github.com/gpu-cli/openapi-to-rust/pull/28. Close when PR merges. Follow-ups: openapi-generator-anu (deepObject/explode=false/arrays), openapi-generator-0jz (server side).","status":"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-tk1","title":"Cache CI scratch compilation targets","description":"Route install-smoke and spec-compile Cargo target directories into paths retained by the existing rust-cache steps so hosted runners reuse compiled dependencies across runs.","acceptance_criteria":"PR and scheduled spec compile jobs use a stable cached scratch target; install-smoke uses a stable cached install target; temporary packaging and generated fixture cleanup remain isolated; workflow syntax and relevant smoke commands pass.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T22:35:49Z","created_by":"James Lal","updated_at":"2026-07-26T22:40:16Z","started_at":"2026-07-26T22:35:53Z","closed_at":"2026-07-26T22:40:16Z","close_reason":"CI now routes install-smoke and both spec-compile scratch targets through absolute paths under the rust-cache-managed root target tree. actionlint, targeted Anthropic spec compile (offline), and install smoke pass.","dependencies":[{"issue_id":"openapi-generator-tk1","depends_on_id":"openapi-generator-in6","type":"discovered-from","created_at":"2026-07-26T16:35:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-in6.4","title":"Detect normalized server tag identifier collisions","description":"Distinct OpenAPI tags such as foo-bar and foo_bar normalize to identical Rust trait/router identifiers and generate duplicate items.","acceptance_criteria":"Server generation rejects or deterministically disambiguates colliding normalized tag identifiers with an actionable diagnostic; regression test covers colliding tags.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:43Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:08Z","started_at":"2026-07-26T21:55:54Z","closed_at":"2026-07-26T22:15:08Z","close_reason":"Added deterministic normalized tag collision diagnostics with unit and integration coverage; full suite passes.","dependencies":[{"issue_id":"openapi-generator-in6.4","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:42Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-in6.1","title":"Support YAML schema extension overlays","description":"README documents JSON/YAML schema_extensions, but analysis parses every overlay as JSON. Parse .yaml/.yml overlays consistently with specs and add merge/SSE regression coverage.","acceptance_criteria":"JSON and YAML overlays both deep-merge; a YAML text/event-stream overlay marks the operation streaming; malformed overlays report their file context.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:39Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:05Z","started_at":"2026-07-26T21:55:53Z","closed_at":"2026-07-26T22:15:05Z","close_reason":"Implemented JSON/YAML/YML schema extension parsing with path-rich errors; 4 focused tests and full all-features suite pass.","dependencies":[{"issue_id":"openapi-generator-in6.1","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-xov.8.2","title":"Phase 5: release docs and quality gates validation","description":"Independently verify the acceptance criteria and validation commands in planning/axum-validation/05-release-quality-gates.md; add missing regression coverage where necessary.","acceptance_criteria":"All scoped validation commands exit 0 and evidence is recorded in issue notes.","notes":"Validation evidence: cargo nextest run --all-features --no-fail-fast: 425/425 passed; cargo test --all-features including doctests passed; cargo clippy --all-features -- -D warnings passed; RUSTDOCFLAGS=-D warnings cargo doc --no-deps --all-features passed; generated dependency fragments compile; official openai-python 2.45.0 live base_url smoke passed; cargo fmt and git diff checks clean.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T16:44:18Z","created_by":"James Lal","updated_at":"2026-07-26T18:23:25Z","started_at":"2026-07-26T18:23:23Z","closed_at":"2026-07-26T18:23:25Z","close_reason":"All scoped validation and release gates passed.","dependencies":[{"issue_id":"openapi-generator-xov.8.2","depends_on_id":"openapi-generator-xov.8","type":"parent-child","created_at":"2026-07-26T10:44:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-xov.8.2","depends_on_id":"openapi-generator-xov.8.1","type":"blocks","created_at":"2026-07-26T10:44:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f032d0..73c0f2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,10 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: scripts/install-smoke.sh + env: + # Keep cargo-install artifacts inside the target tree restored by + # rust-cache; the script still isolates and removes its package root. + CARGO_TARGET_DIR: ${{ github.workspace }}/target/install-smoke msrv: runs-on: ubuntu-latest @@ -136,6 +140,10 @@ jobs: spec-compile: if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest + env: + # Share scratch-crate dependencies across runs through rust-cache's + # root-workspace target directory. + SPEC_COMPILE_TARGET_DIR: ${{ github.workspace }}/target/spec-compile steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -147,6 +155,8 @@ jobs: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 240 + env: + SPEC_COMPILE_TARGET_DIR: ${{ github.workspace }}/target/spec-compile steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable From 618fc1318f22fc3017a54dd35045a059c2f00b74 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 26 Jul 2026 16:55:02 -0600 Subject: [PATCH 4/4] fix: resolve local response references by pointer --- .beads/issues.jsonl | 1 + CHANGELOG.md | 6 +- src/analysis.rs | 64 ++++++++----- tests/server_response_semantics_test.rs | 114 ++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 23 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 10f0679..a9a58a7 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"id":"openapi-generator-1ux","title":"Resolve local response references by JSON pointer","description":"The strict response semantics resolver introduced for 0.10.0 only accepts refs under components.responses. PagerDuty stores a structurally valid Response Object under components.requestBodies and references it from an operation response, causing full corpus generation to fail.","acceptance_criteria":"Local response refs resolve through their actual JSON pointer and deserialize as Response Objects regardless of component namespace; missing, external, structurally incompatible, and cyclic refs fail with actionable errors; PagerDuty parse-only generation and focused response tests pass.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T22:45:49Z","created_by":"James Lal","updated_at":"2026-07-26T22:54:46Z","started_at":"2026-07-26T22:45:55Z","closed_at":"2026-07-26T22:54:46Z","close_reason":"Response refs now resolve through validated local JSON pointers with actionable missing/external/incompatible/cycle errors. PagerDuty generate+check passes, all 54 supported specs generate, and full all-features tests plus clippy pass.","dependencies":[{"issue_id":"openapi-generator-1ux","depends_on_id":"openapi-generator-in6","type":"discovered-from","created_at":"2026-07-26T16:45:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-in6.3","title":"Preserve OpenAPI server response semantics","description":"Server analysis/codegen ignores component Response refs and bodyless statuses, loses +json media types and SSE status codes, and collapses wildcard/default responses to arbitrary fixed codes. Model and emit the declared response semantics faithfully.","acceptance_criteria":"Referenced responses resolve; bodyless 201/202/204 variants are emitted; +json Content-Type is preserved; SSE uses its declared status; wildcard/default variants can return valid runtime statuses; focused generated/runtime tests cover each case.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:42Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:08Z","started_at":"2026-07-26T21:55:54Z","closed_at":"2026-07-26T22:15:08Z","close_reason":"Preserved reusable, bodyless, media-type, SSE-status, wildcard, and default response semantics with generated runtime coverage; full suite passes.","dependencies":[{"issue_id":"openapi-generator-in6.3","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-in6.2","title":"Gate Anthropic generated server with official Python SDK on PR","description":"PR CI runs the official OpenAI Python SDK against the generated OpenAI Axum server, but Anthropic has no equivalent coverage and ignored server example tests have no CI job.","acceptance_criteria":"PR CI pins and runs the official Anthropic Python SDK against an ephemeral generated Anthropic Axum server for unary and SSE messages; both server examples are regenerated and tested on PR; CI/docs no longer claim a missing examples job.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:55:41Z","created_by":"James Lal","updated_at":"2026-07-26T22:15:07Z","started_at":"2026-07-26T21:55:53Z","closed_at":"2026-07-26T22:15:07Z","close_reason":"Added every-PR official Anthropic Python SDK unary/SSE gate, hardened both SDK harnesses, and verified both generated examples and both official clients end to end.","dependencies":[{"issue_id":"openapi-generator-in6.2","depends_on_id":"openapi-generator-in6","type":"parent-child","created_at":"2026-07-26T15:55:40Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-q8h","title":"Publish openapi-to-rust 0.9.1","description":"Cut the merged 0.9.1 crate release through the tag-triggered trusted publishing workflow, verify all release gates, crates.io publication, and GitHub release creation.","acceptance_criteria":"Tag v0.9.1 points at merged main; publish workflow passes; crates.io and GitHub expose v0.9.1.","notes":"Published v0.9.1 through tag-triggered GitHub Actions trusted publishing run 30221338490. Verify job passed version/tag check, fmt, clippy, all-feature tests, docs, and install smoke. Publish job authenticated via crates.io OIDC, cargo publish --locked passed, GitHub release created, and cargo search returns 0.9.1.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T21:32:05Z","created_by":"James Lal","updated_at":"2026-07-26T21:47:31Z","started_at":"2026-07-26T21:32:12Z","closed_at":"2026-07-26T21:47:31Z","close_reason":"v0.9.1 is live on crates.io and GitHub through the trusted publish workflow.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/CHANGELOG.md b/CHANGELOG.md index a30e763..0845b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,10 @@ when correcting output that was wrong or incomplete on the wire. - Schema extensions accept the documented JSON, YAML, and YML formats with path-rich parse errors. - Generated server response enums retain reusable Response Object references, - bodyless status codes, vendor/problem JSON media types, SSE status codes, - and runtime status values for wildcard/default responses. + including structurally compatible local refs stored outside + `components.responses`, plus bodyless status codes, vendor/problem JSON media + types, SSE status codes, and runtime status values for wildcard/default + responses. - Server generation rejects response sets that contain only unsupported media types and reports normalized Rust identifier collisions between distinct tags. - Server example tests use Cargo's current integration-test binary instead of diff --git a/src/analysis.rs b/src/analysis.rs index 9c62177..8e40e69 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -912,7 +912,6 @@ pub struct SchemaAnalyzer { openapi_spec: Value, current_schema_name: Option, component_parameters: BTreeMap, - component_responses: BTreeMap, /// Single chokepoint for `(openapi_type, format)` → Rust-type /// decisions (Q2.0). Defaulted when the analyzer is built without a /// config; threaded from `GeneratorConfig.types` via @@ -942,20 +941,12 @@ impl SchemaAnalyzer { .and_then(|c| c.parameters.as_ref()) .cloned() .unwrap_or_default(); - let component_responses = spec - .components - .as_ref() - .and_then(|c| c.responses.as_ref()) - .cloned() - .unwrap_or_default(); - Ok(Self { schemas, resolved_cache: BTreeMap::new(), openapi_spec, current_schema_name: None, component_parameters, - component_responses, type_mapper, }) } @@ -4770,29 +4761,60 @@ impl SchemaAnalyzer { Ok((op_info, operation_responses)) } - /// Resolve a local reusable Response Object, following chains and - /// rejecting missing, external, or cyclic references explicitly. + /// Resolve a local reusable Response Object through its JSON Pointer. + /// + /// Real-world documents occasionally store a structurally valid Response + /// Object under the wrong Components map. Resolving the pointer itself + /// preserves compatibility with those documents while still validating + /// that the target can be interpreted as a Response Object. fn resolve_response( &self, response: &crate::openapi::Response, ) -> Result { - let mut current = response; + let mut current = response.clone(); let mut visited = HashSet::new(); - while let Some(reference) = current.reference.as_deref() { - let token = reference - .strip_prefix("#/components/responses/") - .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))?; - let name = token.replace("~1", "/").replace("~0", "~"); - if !visited.insert(name.clone()) { + while let Some(reference) = current.reference.clone() { + if !visited.insert(reference.clone()) { return Err(GeneratorError::CircularDependency(format!( "response reference {reference}" ))); } - current = self.component_responses.get(&name).ok_or_else(|| { - GeneratorError::UnresolvedReference(format!("response {reference}")) + + let pointer = reference.strip_prefix('#').ok_or_else(|| { + GeneratorError::UnresolvedReference(format!( + "external response reference `{reference}` is not supported" + )) + })?; + if !pointer.is_empty() && !pointer.starts_with('/') { + return Err(GeneratorError::UnresolvedReference(format!( + "response reference `{reference}` is not a local JSON Pointer" + ))); + } + let value = self.openapi_spec.pointer(pointer).ok_or_else(|| { + GeneratorError::UnresolvedReference(format!( + "response reference `{reference}` does not exist" + )) + })?; + let object = value.as_object().ok_or_else(|| { + GeneratorError::InvalidSchema(format!( + "response reference `{reference}` must target an object" + )) + })?; + if !["$ref", "description", "headers", "content", "links"] + .iter() + .any(|field| object.contains_key(*field)) + { + return Err(GeneratorError::InvalidSchema(format!( + "response reference `{reference}` does not target a structurally compatible OpenAPI Response Object" + ))); + } + current = serde_json::from_value(value.clone()).map_err(|error| { + GeneratorError::InvalidSchema(format!( + "response reference `{reference}` is not a valid OpenAPI Response Object: {error}" + )) })?; } - Ok(current.clone()) + Ok(current) } /// Generate a type name for an inline response schema. diff --git a/tests/server_response_semantics_test.rs b/tests/server_response_semantics_test.rs index 42c465a..93dbae0 100644 --- a/tests/server_response_semantics_test.rs +++ b/tests/server_response_semantics_test.rs @@ -77,6 +77,120 @@ fn analysis_retains_resolved_bodyless_media_and_exact_sse_responses() { assert!(!analysis.operation_responses["notStream"]["200"].supports_streaming); } +#[test] +fn analysis_resolves_structurally_valid_response_from_any_local_pointer() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "misplaced response", "version": "1.0.0" }, + "paths": { "/things": { "get": { + "operationId": "getThing", + "responses": { + "200": { "$ref": "#/components/responses/MisplacedAlias" } + } + }}}, + "components": { + "schemas": { + "Thing": { "type": "string" } + }, + "responses": { + "MisplacedAlias": { + "$ref": "#/components/requestBodies/MisplacedResponse" + } + }, + "requestBodies": { + "MisplacedResponse": { + "description": "stored under the wrong component map", + "content": { + "application/vnd.example+json": { + "schema": { "$ref": "#/components/schemas/Thing" } + } + } + } + } + } + }); + + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let response = &analysis.operation_responses["getThing"]["200"]; + assert_eq!(response.schema_name.as_deref(), Some("Thing")); + assert_eq!( + response.media_type.as_deref(), + Some("application/vnd.example+json") + ); +} + +#[test] +fn invalid_response_references_fail_with_actionable_errors() { + let cases = [ + ( + "missing", + "#/components/responses/Missing", + json!({}), + "does not exist", + ), + ( + "external", + "https://example.invalid/responses.json#/Success", + json!({}), + "external response reference", + ), + ( + "incompatible", + "#/components/schemas/NotAResponse", + json!({ + "schemas": { + "NotAResponse": { "type": "string" } + } + }), + "structurally compatible OpenAPI Response Object", + ), + ]; + + for (name, reference, components, expected) in cases { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": name, "version": "1.0.0" }, + "paths": { "/things": { "get": { + "operationId": "getThing", + "responses": { "200": { "$ref": reference } } + }}}, + "components": components + }); + let error = SchemaAnalyzer::new(spec) + .unwrap() + .analyze() + .unwrap_err() + .to_string(); + assert!(error.contains(expected), "{name}: {error}"); + } +} + +#[test] +fn cyclic_response_reference_chain_is_rejected() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "cyclic responses", "version": "1.0.0" }, + "paths": { "/things": { "get": { + "operationId": "getThing", + "responses": { + "200": { "$ref": "#/components/responses/A" } + } + }}}, + "components": { "responses": { + "A": { "$ref": "#/components/responses/B" }, + "B": { "$ref": "#/components/responses/A" } + }} + }); + + let error = SchemaAnalyzer::new(spec) + .unwrap() + .analyze() + .unwrap_err() + .to_string(); + assert!(error.contains("Circular dependency"), "{error}"); + assert!(error.contains("response reference"), "{error}"); +} + #[test] fn generated_responses_preserve_status_ranges_media_and_sse_status() { let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));