From 6ac786e415e6286df8d984b341ea299009bd0ef1 Mon Sep 17 00:00:00 2001 From: James Lal Date: Sun, 13 Sep 2026 23:16:42 -0600 Subject: [PATCH 1/4] fix: compile generated clients under wasm32 (#74) The generated bounded response reader called `reqwest::Response::chunk()`, which exists only on reqwest's native backend. reqwest's wasm backend exposes `json`/`text`/`bytes`/ `bytes_stream` but no `chunk`, so every generated client failed under `trunk serve` with `no method named chunk found for struct Response`. Buffer through `bytes_stream()` instead, which is available on both targets behind reqwest's `stream` feature (already requested by the generated dependency fragment). This moves emitted output for every spec that generates a client, and the fragment gains `futures-util`. Add `generated_wasm_client_test`, which compiles a generated client for wasm32 on CI; the `test` job installs the target so it cannot silently skip. Update the scratch manifests in tests that hardcode dependencies to match the new fragment. Verified against the real OpenAI and Anthropic clients: both compile for wasm32, and a wasm-bindgen app around each completed live requests (`list_models`, `create_chat_completion`, `messages_post`) against an OpenAI/Anthropic-compatible backend. The opt-in SSE runtime and opt-in retry middleware remain non-wasm32 and are tracked separately. --- .github/workflows/ci.yml | 5 + .gitignore | 1 + CHANGELOG.md | 20 ++ README.md | 5 + src/client_generator.rs | 14 +- tests/client_response_body_limit_test.rs | 5 + tests/corpus-manifest.txt | 222 +++++++++++----------- tests/generated_wasm_client_test.rs | 206 ++++++++++++++++++++ tests/multi_response_client_test.rs | 3 +- tests/operation_builder_test.rs | 3 +- tests/server_query_roundtrip_test.rs | 3 +- tests/server_validation_roundtrip_test.rs | 3 +- 12 files changed, 373 insertions(+), 117 deletions(-) create mode 100644 tests/generated_wasm_client_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81f9e08..ad91f67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,11 @@ jobs: with: submodules: true - uses: dtolnay/rust-toolchain@stable + with: + # `generated_wasm_client_test` compiles a generated client for + # wasm32-unknown-unknown; without the target it skips, which would + # let issue #74 regress silently. + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@nextest - run: cargo nextest run --profile ci --all-features diff --git a/.gitignore b/.gitignore index a0b4f76..e66a204 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /tmp/gen-openai/ /tmp/gen-cloudflare/ /tmp/gen-groq/ +/tmp/issue74/ *.swp *.swo *~ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e3ee25..0d31233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ when correcting output that was wrong or incomplete on the wire. ## [Unreleased] +### Fixed + +#### Generated Rust API + +- Generated HTTP clients now compile for `wasm32-unknown-unknown`. The bounded + response reader used `reqwest::Response::chunk()`, which exists only on + reqwest's native backend; under `trunk serve`/WASM every generated client + failed with `no method named chunk found for struct Response`. The reader now + buffers through `bytes_stream()`, which is available on both targets behind + reqwest's `stream` feature, and the emitted `REQUIRED_DEPS.toml` gains + `futures-util`. The generated code and dependency fragment change for every + spec that emits a client; regenerate and re-merge the fragment. See issue #74. + +### Added + +- `generated_wasm_client_test` compiles a generated client for + `wasm32-unknown-unknown` on CI, which is the only automated check that + catches the regression above. The `test` job installs the wasm32 target so + the test cannot silently skip. + ## [0.16.0] - 2026-09-08 ### Breaking changes diff --git a/README.md b/README.md index 3808d5d..0b0284c 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,11 @@ fixed-length body crosses the limit, the call returns exceed it. Successful SSE responses remain streaming; only SSE error responses are buffered under the same cap. +Generated HTTP clients compile for `wasm32-unknown-unknown` as well as native +targets. The opt-in SSE runtime (`enable_sse_client` with +`[[streaming.endpoints]]`) and the opt-in retry middleware +(`[http_client.retry]`) are not wasm32-compatible. + ## What the generated types look like A tour of patterns the generator emits, from real outputs. diff --git a/src/client_generator.rs b/src/client_generator.rs index 20a1d04..5e87c79 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -260,12 +260,22 @@ impl CodeGenerator { max_response_body_bytes: usize, } + // Buffer a response body while enforcing `limit`, using + // `bytes_stream()` rather than `Response::chunk()`: `chunk()` is + // native-only in reqwest and does not exist on the wasm32 backend, + // so it broke every generated client under `trunk serve` + // (openapi-generator-xhz, issue #74). `bytes_stream()` is available + // on both targets behind reqwest's `stream` feature. async fn __read_bounded_response_body( - mut response: reqwest::Response, + response: reqwest::Response, limit: usize, ) -> Result, HttpError> { + use futures_util::StreamExt; + let mut body = Vec::new(); - while let Some(chunk) = response.chunk().await.map_err(HttpError::Network)? { + let mut stream = std::pin::pin!(response.bytes_stream()); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(HttpError::Network)?; let next_len = body.len().checked_add(chunk.len()); if next_len.is_none_or(|next_len| next_len > limit) { return Err(HttpError::ResponseTooLarge { limit }); diff --git a/tests/client_response_body_limit_test.rs b/tests/client_response_body_limit_test.rs index 8fda654..3abaef1 100644 --- a/tests/client_response_body_limit_test.rs +++ b/tests/client_response_body_limit_test.rs @@ -131,6 +131,11 @@ fn generated_clients_bound_chunked_responses_without_content_length() { assert!(client.content.contains("with_max_response_body_bytes")); assert!(client.content.contains("checked_add(chunk.len())")); assert!(!client.content.contains("response.bytes().await")); + // The bounded reader must go through `bytes_stream()`, not + // `Response::chunk()`: the latter is native-only in reqwest and breaks + // every generated client under wasm32 (issue #74). + assert!(client.content.contains("bytes_stream()")); + assert!(!client.content.contains(".chunk()")); let streaming = result .files diff --git a/tests/corpus-manifest.txt b/tests/corpus-manifest.txt index 6370860..7a44d6c 100644 --- a/tests/corpus-manifest.txt +++ b/tests/corpus-manifest.txt @@ -10,229 +10,229 @@ # version bump alone never touches this file. # # columns: path bytes lines sha256[0:16] -anthropic/REQUIRED_DEPS.toml 648 15 f82dfebf99e5b65c -anthropic/client.rs 230956 6016 b41d4815c287f252 +anthropic/REQUIRED_DEPS.toml 669 16 a7f60b37a7334009 +anthropic/client.rs 231071 6019 5e61b6d56d71c190 anthropic/mod.rs 444 17 5214bdbc37b918db anthropic/types.rs 4151195 85587 fccedf56680b4aa4 -arcade/REQUIRED_DEPS.toml 507 12 755f2be80b920e95 -arcade/client.rs 213669 5708 7f57e226351b99af +arcade/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d +arcade/client.rs 213784 5711 5b3e2de35fcec738 arcade/mod.rs 438 17 b55cb3acf5fdbd02 arcade/types.rs 105169 2609 7717f649b6d59880 -asana/REQUIRED_DEPS.toml 678 15 a15aa2931bb0b740 -asana/client.rs 1947690 49119 a5b6d639f5c9178b +asana/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a +asana/client.rs 1947805 49122 5c448775f672432b asana/mod.rs 436 17 2390079a5e4e9f74 asana/types.rs 612843 13322 8c9551b8dfd04f6e -box/REQUIRED_DEPS.toml 750 17 7638684f0c95ceda -box/client.rs 1634574 42616 c12317d2dea3581c +box/REQUIRED_DEPS.toml 771 18 5dbdfe553bf9dbeb +box/client.rs 1634689 42619 1af7286b76706f61 box/mod.rs 432 17 b40589a066c4b98b box/types.rs 1166877 30496 6c34d244c87ec882 -browserbase/REQUIRED_DEPS.toml 678 15 a15aa2931bb0b740 -browserbase/client.rs 89147 2445 bde73f2cb70fe9cd +browserbase/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a +browserbase/client.rs 89262 2448 5889d5faaecdea5f browserbase/mod.rs 448 17 7e721aa1bb802e26 browserbase/types.rs 50190 1258 8a533e945c0a1773 -cal-com/REQUIRED_DEPS.toml 604 14 b19d0c69b16a5189 -cal-com/client.rs 1280907 33369 a52eacf23bf7c7b4 +cal-com/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee +cal-com/client.rs 1281022 33372 557d0f7f541c4d26 cal-com/mod.rs 440 17 05fb649563c82805 cal-com/types.rs 1395931 35605 63c9dea82070f9f4 -cartesia/REQUIRED_DEPS.toml 648 15 f82dfebf99e5b65c -cartesia/client.rs 250631 6881 410e1e08a70dc65b +cartesia/REQUIRED_DEPS.toml 669 16 a7f60b37a7334009 +cartesia/client.rs 250746 6884 b0055cd12698d427 cartesia/mod.rs 442 17 3c5001b7641597a2 cartesia/types.rs 210349 5830 ecd4ce55e9bc8b1c -cerebras/REQUIRED_DEPS.toml 507 12 755f2be80b920e95 -cerebras/client.rs 41434 1109 81d193006d41b33f +cerebras/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d +cerebras/client.rs 41549 1112 2412d7e10c4deb73 cerebras/mod.rs 442 17 71f9d1b20905ad16 cerebras/types.rs 225828 5803 740d8cc143b1af41 -circleci/REQUIRED_DEPS.toml 605 14 12f8c3bf73140bbb -circleci/client.rs 610871 15847 f36db0cb68de4927 +circleci/REQUIRED_DEPS.toml 626 15 082a47f05075776b +circleci/client.rs 610986 15850 37c84ac5ec04028b circleci/mod.rs 442 17 7a8b5691342a9d62 circleci/types.rs 364570 9619 5dd0e36a1e3ca912 -cloudflare/REQUIRED_DEPS.toml 766 18 e1f1cd474716a2a2 -cloudflare/client.rs 13273871 347584 0a255ad312ac38e3 +cloudflare/REQUIRED_DEPS.toml 787 19 09cccc57db93029b +cloudflare/client.rs 13273986 347587 7aab352c5f1cc4f6 cloudflare/mod.rs 446 17 ce0e733f646147da cloudflare/types.rs 16273278 378321 231c0f88c435b00c -coda/REQUIRED_DEPS.toml 651 15 438a743a40eeab6d -coda/client.rs 847577 21833 a2d28305b44bc24a +coda/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 +coda/client.rs 847692 21836 2d23862bfc0441d3 coda/mod.rs 434 17 4e8b99caec52b623 coda/types.rs 1990457 41947 788c94a3949d60b9 -coingecko/REQUIRED_DEPS.toml 558 13 91fa33352b5a78a2 -coingecko/client.rs 446879 12322 249bf7ea8de2ab6d +coingecko/REQUIRED_DEPS.toml 579 14 9277be96334b3809 +coingecko/client.rs 446994 12325 acceac4d0311c48d coingecko/mod.rs 444 17 0059623eabdef914 coingecko/types.rs 273846 6498 1a5bef1d4bcea737 -datadog-v2/REQUIRED_DEPS.toml 679 15 4ea57404e188eeed -datadog-v2/client.rs 5195138 134568 afec246e2b500c5a +datadog-v2/REQUIRED_DEPS.toml 700 16 e697f0e5afafb53b +datadog-v2/client.rs 5195253 134571 5a762162882dc603 datadog-v2/mod.rs 446 17 56e45841c9753096 datadog-v2/types.rs 3346743 85109 6233c943a806f68b -digitalocean/REQUIRED_DEPS.toml 715 17 9da205e740fd7edc -digitalocean/client.rs 3795427 97729 d361fff00f8197ad +digitalocean/REQUIRED_DEPS.toml 736 18 21727a31b033ca36 +digitalocean/client.rs 3795542 97732 0b73de52eb6426cf digitalocean/mod.rs 450 17 3b99e8d4d3161f03 digitalocean/types.rs 1492411 35105 8d22f01598c85c65 -discord/REQUIRED_DEPS.toml 678 15 a15aa2931bb0b740 -discord/client.rs 1051676 27491 fefa3ef8d4280fc6 +discord/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a +discord/client.rs 1051791 27494 53240372d9aa872b discord/mod.rs 440 17 1d9f7eee6c834b1c discord/types.rs 1272025 32755 de44694314113c14 -gcore/REQUIRED_DEPS.toml 725 16 8b991cf496fc1fdf -gcore/client.rs 5111457 134937 79c203ec0a7e722f +gcore/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 +gcore/client.rs 5111572 134940 841b98a2c23ff598 gcore/mod.rs 436 17 d5c05d8821f23bcb gcore/types.rs 5862313 136588 532f95a64ec49f61 -github/REQUIRED_DEPS.toml 604 14 b19d0c69b16a5189 -github/client.rs 5743782 145446 bcf2c833960a7f35 +github/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee +github/client.rs 5743897 145449 0c8c194850179046 github/mod.rs 438 17 ac4d40f51fb06aa3 github/types.rs 7702391 207387 0019503f70f38bce -gitpod/REQUIRED_DEPS.toml 715 17 9da205e740fd7edc -gitpod/client.rs 1590920 42783 bf19ad8cd1aa3dc4 +gitpod/REQUIRED_DEPS.toml 736 18 21727a31b033ca36 +gitpod/client.rs 1591035 42786 9e006b8683cf2cce gitpod/mod.rs 438 17 516880570ce5294f gitpod/types.rs 849831 19869 c2ab58c21a1573e6 -google-calendar/REQUIRED_DEPS.toml 507 12 755f2be80b920e95 -google-calendar/client.rs 155887 4143 d81a31ab413e7693 +google-calendar/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d +google-calendar/client.rs 156002 4146 5507c7736aaf713e google-calendar/mod.rs 456 17 c31832b05087624c google-calendar/types.rs 65472 1002 d25796f627e27a14 -google-drive/REQUIRED_DEPS.toml 507 12 755f2be80b920e95 -google-drive/client.rs 239596 6127 48ca0b9ffd7ec4e4 +google-drive/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d +google-drive/client.rs 239711 6130 ae423d4cefa81b37 google-drive/mod.rs 450 17 fdb9dcacd2e8d704 google-drive/types.rs 128460 1946 75bee84db99d2863 -google-gmail/REQUIRED_DEPS.toml 507 12 755f2be80b920e95 -google-gmail/client.rs 295232 7578 72063b949b3bd3f5 +google-gmail/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d +google-gmail/client.rs 295347 7581 c85727584eb0395a google-gmail/mod.rs 450 17 ce127037493417e3 google-gmail/types.rs 65481 1177 aef0efe2b930b0dd -google-tasks/REQUIRED_DEPS.toml 507 12 755f2be80b920e95 -google-tasks/client.rs 61196 1627 0745ff11d3c4ca10 +google-tasks/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d +google-tasks/client.rs 61311 1630 f28aecf83e56256d google-tasks/mod.rs 450 17 d25368315b206134 google-tasks/types.rs 10259 190 27998c94ecf7453b -google-youtube/REQUIRED_DEPS.toml 507 12 755f2be80b920e95 -google-youtube/client.rs 345735 9193 cf322900deede5f7 +google-youtube/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d +google-youtube/client.rs 345850 9196 a7721ff373c9636a google-youtube/mod.rs 454 17 dc6e7f7cef938c3e google-youtube/types.rs 397395 9292 4b91fb45be6db00a -grafana/REQUIRED_DEPS.toml 604 14 b19d0c69b16a5189 -grafana/client.rs 1731914 44985 a507d06d534cfacd +grafana/REQUIRED_DEPS.toml 625 15 0e0871569054a9ee +grafana/client.rs 1732029 44988 b21b1d7e1034dda1 grafana/mod.rs 440 17 7b59b00331edb605 grafana/types.rs 391878 9248 e82c5cd4fb60daf2 -groq/REQUIRED_DEPS.toml 609 14 069dc9aaa57c8032 -groq/client.rs 92291 2480 3215c7bc387bf9b6 +groq/REQUIRED_DEPS.toml 630 15 7935666e65c0812e +groq/client.rs 92406 2483 d5928b065aa9994d groq/mod.rs 434 17 72fd6d54577d5597 groq/types.rs 372951 9387 e3c08712828ff708 -imagekit/REQUIRED_DEPS.toml 652 15 305aa2fd565f9a65 -imagekit/client.rs 335746 8647 c0b0022abd1e7a39 +imagekit/REQUIRED_DEPS.toml 673 16 b888661e2699f921 +imagekit/client.rs 335861 8650 9c69f4ef8efa79bd imagekit/mod.rs 442 17 40484a363f5391c3 imagekit/types.rs 717309 15820 b158f06cd64572b6 -increase/REQUIRED_DEPS.toml 632 14 4543dfdd58abc04d -increase/client.rs 1284127 32560 9f5cb7fe0691a1a6 +increase/REQUIRED_DEPS.toml 653 15 715b930adfa76491 +increase/client.rs 1284242 32563 07d535545ac049fc increase/mod.rs 442 17 f6131a8548853a65 increase/types.rs 2544062 56883 63883f4dff81d804 -knocklabs/REQUIRED_DEPS.toml 651 15 438a743a40eeab6d -knocklabs/client.rs 476400 12528 57939677a9b33387 +knocklabs/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 +knocklabs/client.rs 476515 12531 83d28a7a3cd9fe00 knocklabs/mod.rs 444 17 7822049ab73a5ed0 knocklabs/types.rs 259668 6425 e15eeacb10637dc6 -langsmith/REQUIRED_DEPS.toml 725 16 8b991cf496fc1fdf -langsmith/client.rs 2201343 57228 5c2e8348ef0f4778 +langsmith/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 +langsmith/client.rs 2201458 57231 afa9d3fc9aadb2ab langsmith/mod.rs 444 17 eb5007d340d34a8e langsmith/types.rs 1161995 32610 fbf8b66781fcdab7 -launchdarkly/REQUIRED_DEPS.toml 679 15 4ea57404e188eeed -launchdarkly/client.rs 2523843 64222 cae8aa7cdceab366 +launchdarkly/REQUIRED_DEPS.toml 700 16 e697f0e5afafb53b +launchdarkly/client.rs 2523958 64225 7531d5cd5b144114 launchdarkly/mod.rs 450 17 d4ed5579304ccd39 launchdarkly/types.rs 735300 18316 37aac538ad06edd7 -letta/REQUIRED_DEPS.toml 725 16 8b991cf496fc1fdf -letta/client.rs 1413641 38038 223005e4fc5da7ca +letta/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 +letta/client.rs 1413756 38041 7d7998bf850bd64e letta/mod.rs 436 17 5cc3226a7c317d2a letta/types.rs 6858612 139380 79ac34e92dacd5fb -lithic/REQUIRED_DEPS.toml 667 16 e22e920265ec7964 -lithic/client.rs 1351761 35353 cc521651073da9b7 +lithic/REQUIRED_DEPS.toml 688 17 9c8fe0ad1f161983 +lithic/client.rs 1351876 35356 60aebed1624e10a4 lithic/mod.rs 438 17 eba70abf1f172ff1 lithic/types.rs 1612473 39122 146638e5d90ddfa5 -luma/REQUIRED_DEPS.toml 651 15 438a743a40eeab6d -luma/client.rs 59403 1612 009906caac2964f9 +luma/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 +luma/client.rs 59518 1615 5f1fe1b8e87ef5ea luma/mod.rs 434 17 d85018b8cdd8f625 luma/types.rs 75620 2116 b98bae10c054ef3e -meta-llama/REQUIRED_DEPS.toml 609 14 069dc9aaa57c8032 -meta-llama/client.rs 45875 1230 69263f435a321bd5 +meta-llama/REQUIRED_DEPS.toml 630 15 7935666e65c0812e +meta-llama/client.rs 45990 1233 bafec8b0572aae0d meta-llama/mod.rs 446 17 6e485725d643b7c2 meta-llama/types.rs 97541 2408 2bd9da2ef77022e0 -microsoft-graph/REQUIRED_DEPS.toml 653 15 4a261fde9be44018 -microsoft-graph/client.rs 104447058 2552569 f6b639240330267a +microsoft-graph/REQUIRED_DEPS.toml 674 16 e864ec8321bc90dc +microsoft-graph/client.rs 104447173 2552572 d70926e24f6f6016 microsoft-graph/mod.rs 456 17 b99d7781b4460b5d microsoft-graph/types.rs 14189944 351429 700bb06df11c5302 -modern-treasury/REQUIRED_DEPS.toml 725 16 8b991cf496fc1fdf -modern-treasury/client.rs 955510 25670 71bef9ab6151f78d +modern-treasury/REQUIRED_DEPS.toml 746 17 7f467fcbfe949464 +modern-treasury/client.rs 955625 25673 fcda40112812f7c6 modern-treasury/mod.rs 456 17 5f336a50419a709b modern-treasury/types.rs 995584 26633 ce34002029faedaa -openai/REQUIRED_DEPS.toml 627 14 d48ef79f2f03af3a -openai/client.rs 1045741 28436 e11c4799e5ca95d2 +openai/REQUIRED_DEPS.toml 648 15 a3db003e7f3781e2 +openai/client.rs 1045856 28439 318fe79140e5b91a openai/mod.rs 438 17 59c0207149335235 openai/types.rs 6310615 142383 a39f47ec0b9c4cd0 opencode/REQUIRED_DEPS.toml 576 14 9228e95d38f5078e -opencode/client.rs 943247 25092 68cf5b4201ff50cf +opencode/client.rs 943362 25095 d0a641e0a3bb0d27 opencode/mod.rs 442 17 98c109eb94b95dbf opencode/types.rs 6695017 143213 ef443d0415e39591 -pagerduty/REQUIRED_DEPS.toml 651 15 438a743a40eeab6d -pagerduty/client.rs 2168383 58184 d2ee8b2e1fd33cbd +pagerduty/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 +pagerduty/client.rs 2168498 58187 0a0bf3f7f6db74a0 pagerduty/mod.rs 444 17 2edbc04dfe892a50 pagerduty/types.rs 1696912 40730 4db4dd8ac98e4d1f -perplexity/REQUIRED_DEPS.toml 554 13 a6164504984d80b3 -perplexity/client.rs 52982 1401 b0a83d486acfd8bc +perplexity/REQUIRED_DEPS.toml 575 14 1315fbc31599edf0 +perplexity/client.rs 53097 1404 2d19a33e7b5739ec perplexity/mod.rs 446 17 1f9103fe36426530 perplexity/types.rs 530449 12140 0456b118c51b5e00 -resend/REQUIRED_DEPS.toml 653 15 4a261fde9be44018 -resend/client.rs 269122 7292 001f79622d7afd71 +resend/REQUIRED_DEPS.toml 674 16 e864ec8321bc90dc +resend/client.rs 269237 7295 e31a053ecd85187f resend/mod.rs 438 17 f7a8a77315259e44 resend/types.rs 133568 3604 b889f478d7735cfc -retell/REQUIRED_DEPS.toml 507 12 755f2be80b920e95 -retell/client.rs 484265 12604 c3b8c0fc3b474148 +retell/REQUIRED_DEPS.toml 528 13 d6f1ac00e2426e4d +retell/client.rs 484380 12607 680baab3afcc5495 retell/mod.rs 438 17 e5b430243537a3be retell/types.rs 1225853 32915 436678419f67417f -runway/REQUIRED_DEPS.toml 651 15 438a743a40eeab6d -runway/client.rs 193884 5307 e81d3bb173f62912 +runway/REQUIRED_DEPS.toml 672 16 9e666f39c7301c02 +runway/client.rs 193999 5310 a6305fb6adc0b2e2 runway/mod.rs 438 17 67ec5d69926d7742 runway/types.rs 963739 22398 587121e3f343ec8c -sentry/REQUIRED_DEPS.toml 678 15 a15aa2931bb0b740 -sentry/client.rs 967361 25033 146a1b22d0eee589 +sentry/REQUIRED_DEPS.toml 699 16 4ad61bc1ea5c636a +sentry/client.rs 967476 25036 b45ce40a92781f4a sentry/mod.rs 438 17 2bdd2d5164ca444f sentry/types.rs 2369613 66073 c9cbb5b2d1d6cbef -snyk/REQUIRED_DEPS.toml 667 16 e22e920265ec7964 -snyk/client.rs 1858782 48182 7326789a98545962 +snyk/REQUIRED_DEPS.toml 688 17 9c8fe0ad1f161983 +snyk/client.rs 1858897 48185 59bf01d1ea33bcf7 snyk/mod.rs 434 17 eec62ea6f01f40f6 snyk/types.rs 2717805 59426 5866664d242459a9 -spotify/REQUIRED_DEPS.toml 558 13 91fa33352b5a78a2 -spotify/client.rs 563386 14794 1e64690d1e855ae8 +spotify/REQUIRED_DEPS.toml 579 14 9277be96334b3809 +spotify/client.rs 563501 14797 7303567c6cedfe91 spotify/mod.rs 440 17 954184fb7fd84586 spotify/types.rs 261914 6374 ec3db89e1975c7b0 storyden/REQUIRED_DEPS.toml 698 17 569479aa8197c912 -storyden/client.rs 967591 25555 3f5848b0ea037c1a +storyden/client.rs 967706 25558 410cd3614dbf2385 storyden/mod.rs 442 17 ff7a510276d50e59 storyden/types.rs 884799 20120 38b50906e06a668d -stripe/REQUIRED_DEPS.toml 580 14 d04fcff74e41419c -stripe/client.rs 2892850 75103 49c4b8e2723364dd +stripe/REQUIRED_DEPS.toml 601 15 de8d722a8a5cb828 +stripe/client.rs 2892965 75106 7ccd0ae049071746 stripe/mod.rs 438 17 cafa363545854cd3 stripe/types.rs 10112467 248486 fb430bcdf91b98f7 -supabase/REQUIRED_DEPS.toml 676 16 f4c727cabf80fb53 -supabase/client.rs 602482 16285 b2936923215ac3ac +supabase/REQUIRED_DEPS.toml 697 17 f2fa59b2eb1837a1 +supabase/client.rs 602597 16288 dbc12101b4ae678e supabase/mod.rs 442 17 331ec75d430bc0a8 supabase/types.rs 476436 13390 306a5b647643e04d -telnyx/REQUIRED_DEPS.toml 766 18 e1f1cd474716a2a2 -telnyx/client.rs 5179949 136395 757c1aec497e4d5e +telnyx/REQUIRED_DEPS.toml 787 19 09cccc57db93029b +telnyx/client.rs 5180064 136398 704fd2528e41469b telnyx/mod.rs 438 17 f3a44d93d532e621 telnyx/types.rs 4496512 107989 7d19b35d8678c506 -terminal-shop/REQUIRED_DEPS.toml 508 13 95d5dce08c1f99a8 -terminal-shop/client.rs 208005 5558 daf63a66100730d7 +terminal-shop/REQUIRED_DEPS.toml 529 14 241dda1403a4b0ba +terminal-shop/client.rs 208120 5561 e896ba15ff57c29a terminal-shop/mod.rs 452 17 7014abd4e66e4e4e terminal-shop/types.rs 51936 1629 12f624a0585e437b -together/REQUIRED_DEPS.toml 741 17 ded5e2bd1cd63603 -together/client.rs 449000 11997 e2d4b2a703e5fc53 +together/REQUIRED_DEPS.toml 762 18 2357560c3a71c3c6 +together/client.rs 449115 12000 d6550b6874f460af together/mod.rs 442 17 69fe71a3ff6db06e together/types.rs 565362 14607 c07c3f2fcae7972a -twilio/REQUIRED_DEPS.toml 629 15 7e3af3d5d13dfd94 -twilio/client.rs 843971 22153 fccd4e5e7fcf3dc3 +twilio/REQUIRED_DEPS.toml 650 16 79c204ac31d0efe3 +twilio/client.rs 844086 22156 b1ce00b1b35715a7 twilio/mod.rs 438 17 1e85ee5c08c46793 twilio/types.rs 895681 20820 37d493aaebf60a09 -val-town/REQUIRED_DEPS.toml 699 16 a8b7f9dfad5ffd4c -val-town/client.rs 154262 4122 7fd7731be44ffd36 +val-town/REQUIRED_DEPS.toml 720 17 4d0f659a2898dcf4 +val-town/client.rs 154377 4125 26128619cc8f2924 val-town/mod.rs 442 17 8a07cd572c0c8638 val-town/types.rs 68086 1978 78ac613c49efff35 -vercel/REQUIRED_DEPS.toml 652 15 305aa2fd565f9a65 -vercel/client.rs 1465481 38564 22213443ba598e84 +vercel/REQUIRED_DEPS.toml 673 16 b888661e2699f921 +vercel/client.rs 1465596 38567 cc6cfcff17bc5097 vercel/mod.rs 438 17 6e4cb11d3c843a23 vercel/types.rs 12196079 315218 93755a3f454f3470 -writer/REQUIRED_DEPS.toml 699 16 a8b7f9dfad5ffd4c -writer/client.rs 141267 3797 75e8b378af67f185 +writer/REQUIRED_DEPS.toml 720 17 4d0f659a2898dcf4 +writer/client.rs 141382 3800 79eabc8c4a13ad16 writer/mod.rs 438 17 0435bf1552e493d1 writer/types.rs 182479 4509 36b85cce121b9439 # -# specs: 56 files: 224 bytes: 313747115 +# specs: 56 files: 224 bytes: 313754689 diff --git a/tests/generated_wasm_client_test.rs b/tests/generated_wasm_client_test.rs new file mode 100644 index 0000000..7f99fe2 --- /dev/null +++ b/tests/generated_wasm_client_test.rs @@ -0,0 +1,206 @@ +//! Regression gate: generated clients must compile for `wasm32-unknown-unknown`. +//! +//! The generated HTTP client buffers response bodies through +//! `__read_bounded_response_body`. It originally used `reqwest::Response::chunk()`, +//! which is native-only: reqwest's wasm backend exposes `json`/`text`/`bytes`/ +//! `bytes_stream` but not `chunk`. Every generated client therefore failed to +//! build under `trunk serve` with `no method named chunk found for struct +//! Response` (issue #74). +//! +//! This test generates a client that exercises the buffered success, buffered +//! error, binary, and auto-detected SSE paths, then `cargo check`s the exact +//! `REQUIRED_DEPS.toml` output for wasm32. It is the only automated check that +//! would have caught the regression, so it belongs on CI. +//! +//! The opt-in SSE runtime (`enable_sse_client` + `[[streaming.endpoints]]`) is +//! intentionally out of scope: it builds `Send` futures and `Pin>`, which cannot satisfy wasm32's single-threaded `fetch`. +//! That is tracked separately. +//! +//! The `wasm32-unknown-unknown` target must be installed. The test skips (with +//! a printed notice) when it is absent so it cannot fail a machine that simply +//! lacks the target; CI installs it explicitly. +//! +//! `cargo check` for wasm32 still resolves reqwest's wasm backend and the +//! `stream` feature, so a genuinely bad generated call is a compile error here +//! rather than a silent pass. + +use openapi_to_rust::http_config::HttpClientConfig; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; +use std::collections::HashMap; +use std::process::Command; + +/// A spec touching every place the bounded buffering helper is emitted: +/// buffered JSON, buffered text, buffered binary, an error body, and an +/// auto-detected `text/event-stream` response. +fn wasm_client_spec() -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "wasm client", "version": "1.0.0" }, + "paths": { + "/json": { "get": { + "operationId": "getJson", + "responses": { + "200": { "description": "ok", "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/Thing" } + }}}, + "400": { "description": "err", "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/Thing" } + }}} + } + }}, + "/text": { "get": { + "operationId": "getText", + "responses": { + "200": { "description": "ok", "content": { "text/plain": { + "schema": { "type": "string" } + }}} + } + }}, + "/binary": { "get": { + "operationId": "getBinary", + "responses": { + "200": { "description": "ok", "content": { "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + }}} + } + }}, + "/events": { "get": { + "operationId": "streamEvents", + "responses": { + "200": { "description": "events", "content": { "text/event-stream": { + "schema": { "$ref": "#/components/schemas/Thing" } + }}} + } + }} + }, + "components": { "schemas": { + "Thing": { + "type": "object", + "required": ["id"], + "properties": { "id": { "type": "string" }, "name": { "type": "string" } } + } + }} + }) +} + +/// True when the wasm32 standard library is present in the active sysroot. +/// `rustup target list --installed` is the authoritative source, but this +/// avoids assuming the toolchain is rustup-managed. +fn wasm32_target_installed() -> bool { + let output = Command::new("rustc") + .args([ + "--print", + "target-libdir", + "--target", + "wasm32-unknown-unknown", + ]) + .output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + return false; + } + let libdir = String::from_utf8_lossy(&output.stdout); + // A missing target yields the path but not the `lib` directory. + std::path::Path::new(libdir.trim()).is_dir() +} + +#[test] +fn generated_client_compiles_for_wasm32() { + if !wasm32_target_installed() { + eprintln!( + "skipping: wasm32-unknown-unknown target not installed \ + (run `rustup target add wasm32-unknown-unknown`)" + ); + return; + } + + // Default configuration: `tracing_enabled` is true by default and pulls in + // reqwest-tracing, so the real default stack is what gets checked. + // + // Two opt-in stacks are deliberately out of scope and tracked separately, + // because neither is wasm32-compatible for reasons outside this helper: + // * the SSE runtime (`enable_sse_client` + `[[streaming.endpoints]]`) + // builds `Send` futures and `Pin>`; + // * retry (`[http_client.retry]`) pulls `retry-policies` -> `rand` -> + // `getrandom 0.4`, which rejects wasm32 without `wasm_js`. + let temp = tempfile::TempDir::new().unwrap(); + let output_dir = temp.path().join("src/generated"); + + let mut analysis = SchemaAnalyzer::new(wasm_client_spec()) + .unwrap() + .analyze() + .unwrap(); + let generator = CodeGenerator::new(GeneratorConfig { + output_dir: output_dir.clone(), + module_name: "generated".into(), + enable_async_client: true, + // No `[streaming]` config: this exercises the plain client plus the + // auto-detected SSE operation, whose error path also buffers through + // the helper. + enable_sse_client: false, + tracing_enabled: true, + http_client_config: Some(HttpClientConfig { + base_url: None, + timeout_seconds: None, + max_response_body_bytes: Some(8), + default_headers: HashMap::new(), + }), + ..Default::default() + }); + let result = generator.generate_all(&mut analysis).unwrap(); + generator.write_files(&result).unwrap(); + + // The generated client module is mounted at src/generated; the crate root + // is just a re-export shim. `dead_code` is expected in a spec-covering + // compile check. + std::fs::write( + temp.path().join("src/lib.rs"), + "#![allow(dead_code, unused_imports)]\npub mod generated;\n", + ) + .unwrap(); + + let dependencies = std::fs::read_to_string(output_dir.join("REQUIRED_DEPS.toml")) + .expect("generated REQUIRED_DEPS.toml"); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + "[workspace]\n\n\ + [package]\n\ + name = \"wasm-client-check\"\n\ + version = \"0.0.0\"\n\ + edition = \"2024\"\n\ + publish = false\n\n\ + {dependencies}" + ), + ) + .unwrap(); + + // Keep the check cheap: reuse one workspace target dir across runs so + // reqwest's wasm backend is compiled once. The scratch crate has no lock + // file, so allow the resolver to reach the index on a cold cache rather + // than passing `--offline`. + let manifest_dir = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/generated-wasm32-client"); + let output = Command::new("cargo") + .args([ + "check", + "--lib", + "--quiet", + "--target", + "wasm32-unknown-unknown", + ]) + .current_dir(temp.path()) + .env("CARGO_TARGET_DIR", manifest_dir) + .output() + .expect("cargo check runs"); + + assert!( + output.status.success(), + "generated client failed to compile for wasm32-unknown-unknown (issue #74):\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/multi_response_client_test.rs b/tests/multi_response_client_test.rs index 7499e00..4c1b6e3 100644 --- a/tests/multi_response_client_test.rs +++ b/tests/multi_response_client_test.rs @@ -22,7 +22,8 @@ edition = "2021" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -reqwest = { version = "0.13", features = ["json", "multipart"] } +futures-util = "0.3" +reqwest = { version = "0.13", features = ["json", "multipart", "stream"] } reqwest-middleware = { version = "0.5", features = ["multipart", "query"] } thiserror = "2.0" tokio = { version = "1.0", features = ["full"] } diff --git a/tests/operation_builder_test.rs b/tests/operation_builder_test.rs index 9bea3d0..7e9ca5c 100644 --- a/tests/operation_builder_test.rs +++ b/tests/operation_builder_test.rs @@ -331,7 +331,8 @@ edition = "2024" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" -reqwest = { version = "0.13", features = ["json", "multipart"] } +futures-util = "0.3" +reqwest = { version = "0.13", features = ["json", "multipart", "stream"] } reqwest-middleware = { version = "0.5", features = ["multipart", "query"] } "#, ) diff --git a/tests/server_query_roundtrip_test.rs b/tests/server_query_roundtrip_test.rs index feb95d4..310ba83 100644 --- a/tests/server_query_roundtrip_test.rs +++ b/tests/server_query_roundtrip_test.rs @@ -316,12 +316,13 @@ axum = "0.8" jsonschema = { version = "0.49", default-features = false } mime = "0.3" http-body-util = "0.1" -reqwest = { version = "0.13", features = ["json", "multipart"] } +reqwest = { version = "0.13", features = ["json", "multipart", "stream"] } reqwest-middleware = { version = "0.5", features = ["multipart", "query"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" thiserror = "2" +futures-util = "0.3" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "time"] } url = "2" "#, diff --git a/tests/server_validation_roundtrip_test.rs b/tests/server_validation_roundtrip_test.rs index a95de77..71743a4 100644 --- a/tests/server_validation_roundtrip_test.rs +++ b/tests/server_validation_roundtrip_test.rs @@ -131,8 +131,9 @@ axum = { version = "0.8", default-features = false, features = ["http1", "json", http-body-util = "0.1" jsonschema = { version = "0.49", default-features = false } mime = "0.3" -reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "stream"] } reqwest-middleware = { version = "0.5", features = ["query"] } +futures-util = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" From 33a3993fe3fb063044a5c84e932cad5db65dcb5b Mon Sep 17 00:00:00 2001 From: James Lal Date: Mon, 14 Sep 2026 00:12:24 -0600 Subject: [PATCH 2/4] feat: compile opt-in SSE and retry generated code under wasm32 (#74) Follow-up to the default-client fix. The opt-in SSE runtime and retry middleware still failed on wasm32: * SSE emitted `Pin>` and bare `#[async_trait]` (which implies `Send` futures). reqwest's wasm `fetch` body is `!Send`, so no amount of bounding makes those signatures work. Emit a cfg-split `BoxSseStream` alias and `#[cfg_attr(..., async_trait(?Send))]`: native keeps `Send` streams and the public API is unchanged there, wasm is single-threaded. The reconnect state holds the body stream (rather than a `Response`) and no longer calls the native-only `Response::chunk()`. * retry pulls `retry-policies` -> `rand` -> `getrandom 0.4`, which `compile_error!`s on wasm32 without the `wasm_js` feature. Emit a target-scoped `getrandom` dependency when retry is configured. * SSE timers need `futures-timer/wasm-bindgen` on wasm32, also emitted target-scoped. `DepRequirement` gains `target`, rendered under `[target.'cfg(target_arch = "wasm32")'.dependencies]`. The native dependency set is unchanged. `generated_wasm_client_test` now covers default, retry, SSE, and SSE+retry on both wasm32 and native. Verified the generated OpenAI client with SSE and retry enabled compiles for native and wasm32 with zero errors, and the live SSE transport test still passes end to end. --- CHANGELOG.md | 34 +++-- README.md | 8 +- examples/server-openai-responses/Cargo.toml | 2 +- src/generator.rs | 108 ++++++++------ src/type_mapping.rs | 62 +++++++- tests/client_response_body_limit_test.rs | 2 + tests/generated_wasm_client_test.rs | 151 +++++++++++++------- tests/generation_requirements_test.rs | 74 +++++++++- tests/live_sse_backend_test.rs | 11 +- tests/non_json_response_test.rs | 2 + 10 files changed, 337 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d31233..444743e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,20 +10,34 @@ when correcting output that was wrong or incomplete on the wire. #### Generated Rust API -- Generated HTTP clients now compile for `wasm32-unknown-unknown`. The bounded - response reader used `reqwest::Response::chunk()`, which exists only on - reqwest's native backend; under `trunk serve`/WASM every generated client - failed with `no method named chunk found for struct Response`. The reader now - buffers through `bytes_stream()`, which is available on both targets behind - reqwest's `stream` feature, and the emitted `REQUIRED_DEPS.toml` gains - `futures-util`. The generated code and dependency fragment change for every - spec that emits a client; regenerate and re-merge the fragment. See issue #74. +- Generated HTTP clients now compile for `wasm32-unknown-unknown`, including + the opt-in SSE runtime and retry middleware. Previously: + - the bounded response reader used `reqwest::Response::chunk()`, which exists + only on reqwest's native backend; under `trunk serve`/WASM every generated + client failed with `no method named chunk found for struct Response`. The + reader now buffers through `bytes_stream()`, available on both targets + behind reqwest's `stream` feature. + - the SSE runtime's `Pin>` signatures and + `#[async_trait]` (which implies `Send` futures) cannot be satisfied by the + browser `fetch` body. It now emits a cfg-split `BoxSseStream` alias and + `#[cfg_attr(..., async_trait(?Send))]`: native streams stay `Send`, wasm + streams are single-threaded. + - retry pulls `retry-policies`→`rand`→`getrandom 0.4`, which rejects wasm32 + without `wasm_js`. + + The emitted `REQUIRED_DEPS.toml` gains `futures-util` and a + `[target.'cfg(target_arch = "wasm32")'.dependencies]` table carrying + `futures-timer/wasm-bindgen` (SSE timers) and `getrandom/wasm_js` (retry). + The native dependency set is unchanged. The generated code and dependency + fragment change for every spec that emits a client; regenerate and re-merge + the fragment. See issue #74. ### Added - `generated_wasm_client_test` compiles a generated client for - `wasm32-unknown-unknown` on CI, which is the only automated check that - catches the regression above. The `test` job installs the wasm32 target so + `wasm32-unknown-unknown` on CI, covering default, retry, SSE, and SSE+retry + configurations on both wasm32 and native. It is the only automated check that + catches the regressions above. The `test` job installs the wasm32 target so the test cannot silently skip. ## [0.16.0] - 2026-09-08 diff --git a/README.md b/README.md index 0b0284c..07dbbe2 100644 --- a/README.md +++ b/README.md @@ -303,9 +303,13 @@ exceed it. Successful SSE responses remain streaming; only SSE error responses are buffered under the same cap. Generated HTTP clients compile for `wasm32-unknown-unknown` as well as native -targets. The opt-in SSE runtime (`enable_sse_client` with +targets, including the opt-in SSE runtime (`enable_sse_client` with `[[streaming.endpoints]]`) and the opt-in retry middleware -(`[http_client.retry]`) are not wasm32-compatible. +(`[http_client.retry]`). On wasm32 the generated SSE stream is single-threaded +(no `Send` bound) because the browser `fetch` body is not `Send`; native +streams keep `Send`. The emitted dependency fragment scopes the wasm-only +`futures-timer/wasm-bindgen` and `getrandom/wasm_js` features to +`cfg(target_arch = "wasm32")`, so the native dependency set is unchanged. ## What the generated types look like diff --git a/examples/server-openai-responses/Cargo.toml b/examples/server-openai-responses/Cargo.toml index 5874ab6..c0b3109 100644 --- a/examples/server-openai-responses/Cargo.toml +++ b/examples/server-openai-responses/Cargo.toml @@ -12,7 +12,7 @@ axum = "0.8" http-body-util = "0.1" jsonschema = { version = "0.49", default-features = false } mime = "0.3" -reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "stream"] } reqwest-middleware = { version = "0.5", features = ["query"] } reqwest-tracing = "0.7" serde = { version = "1", features = ["derive"] } diff --git a/src/generator.rs b/src/generator.rs index e8f89b0..cdab4c6 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1114,12 +1114,12 @@ impl CodeGenerator { if streaming_config.generate_client { if streaming_config.reconnection_config.is_some() { client_code.extend(quote! { - use super::sse::{SseClient, SseReconnectOptions}; + use super::sse::{BoxSseStream, SseClient, SseReconnectOptions}; pub use super::sse::StreamingError; }); } else { client_code.extend(quote! { - use super::sse::SseClient; + use super::sse::{BoxSseStream, SseClient}; pub use super::sse::StreamingError; }); } @@ -5064,7 +5064,7 @@ impl CodeGenerator { async fn #method_name( &self, #(#param_defs),* - ) -> Result> + Send>>, Self::Error>; + ) -> Result>, Self::Error>; } } HttpMethod::Post => { @@ -5084,14 +5084,15 @@ impl CodeGenerator { async fn #method_name( &self, request: #request_type_ident, - ) -> Result> + Send>>, Self::Error>; + ) -> Result>, Self::Error>; } } }; Ok(quote! { /// Streaming client trait for this endpoint - #[async_trait] + #[cfg_attr(not(target_arch = "wasm32"), async_trait)] + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] pub trait #trait_name { type Error: std::error::Error + Send + Sync + 'static; @@ -5423,7 +5424,8 @@ impl CodeGenerator { let instrument_skip = quote! { #[instrument(skip(self), name = "streaming_get_request")] }; Ok(quote! { - #[async_trait] + #[cfg_attr(not(target_arch = "wasm32"), async_trait)] + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl #trait_name for #client_name { type Error = StreamingError; @@ -5431,7 +5433,7 @@ impl CodeGenerator { async fn #method_name( &self, #(#param_defs),* - ) -> Result> + Send>>, Self::Error> { + ) -> Result>, Self::Error> { debug!("Starting streaming GET request"); let mut headers = HeaderMap::new(); @@ -5514,7 +5516,8 @@ impl CodeGenerator { }; Ok(quote! { - #[async_trait] + #[cfg_attr(not(target_arch = "wasm32"), async_trait)] + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl #trait_name for #client_name { type Error = StreamingError; @@ -5522,7 +5525,7 @@ impl CodeGenerator { async fn #method_name( &self, request: #request_type_ident, - ) -> Result> + Send>>, Self::Error> { + ) -> Result>, Self::Error> { debug!("Starting streaming POST request"); #stream_setup @@ -5567,6 +5570,19 @@ impl CodeGenerator { use std::time::Duration; use tracing::debug; + /// Boxed stream of SSE events. + /// + /// `Send` on native targets so callers can move the stream between + /// tasks. On `wasm32` the reqwest response wraps a JS `fetch` body + /// (holding `Rc>` and a wasm-bindgen closure) and cannot + /// be `Send`, so the bound is omitted and the stream stays on the + /// single wasm thread. The public signatures do not change on + /// native targets. + #[cfg(not(target_arch = "wasm32"))] + pub type BoxSseStream = Pin + Send>>; + #[cfg(target_arch = "wasm32")] + pub type BoxSseStream = Pin>>; + #error_types /// Reusable transport client for generated SSE operations. @@ -5613,7 +5629,7 @@ impl CodeGenerator { pub async fn stream( &self, request_builder: reqwest::RequestBuilder, - ) -> Result> + Send>>, StreamingError> + ) -> Result>, StreamingError> where T: serde::de::DeserializeOwned + Send + 'static, { @@ -5635,7 +5651,7 @@ impl CodeGenerator { pub async fn stream_raw( &self, request_builder: reqwest::RequestBuilder, - ) -> Result, StreamingError>> + Send>>, StreamingError> { + ) -> Result, StreamingError>>, StreamingError> { parse_sse_raw_stream_with_limit(request_builder, self.max_error_body_bytes).await } @@ -5643,7 +5659,7 @@ impl CodeGenerator { pub async fn stream_json( &self, request_builder: reqwest::RequestBuilder, - ) -> Result, StreamingError>> + Send>>, StreamingError> + ) -> Result, StreamingError>>, StreamingError> where T: serde::de::DeserializeOwned + Send + 'static, { @@ -5654,7 +5670,7 @@ impl CodeGenerator { pub async fn stream_raw_reconnecting( &self, request_builder: reqwest::RequestBuilder, - ) -> Result, StreamingError>> + Send>>, StreamingError> { + ) -> Result, StreamingError>>, StreamingError> { parse_sse_raw_reconnecting_with_limit( request_builder, self.max_error_body_bytes, @@ -5666,7 +5682,7 @@ impl CodeGenerator { pub async fn stream_json_reconnecting( &self, request_builder: reqwest::RequestBuilder, - ) -> Result, StreamingError>> + Send>>, StreamingError> + ) -> Result, StreamingError>>, StreamingError> where T: serde::de::DeserializeOwned + Send + 'static, { @@ -5699,11 +5715,15 @@ impl CodeGenerator { pub const DEFAULT_MAX_SSE_ERROR_BODY_BYTES: usize = 8 * 1024 * 1024; async fn __read_bounded_streaming_error_body( - mut response: reqwest::Response, + response: reqwest::Response, limit: usize, ) -> Result, StreamingError> { let mut body = Vec::new(); - while let Some(chunk) = response.chunk().await? { + // `bytes_stream()` rather than `Response::chunk()`: `chunk()` is + // native-only in reqwest and does not exist on wasm32. + let mut chunks = response.bytes_stream(); + while let Some(chunk) = chunks.next().await { + let chunk = chunk?; let next_len = body.len().checked_add(chunk.len()); if next_len.is_none_or(|next_len| next_len > limit) { return Err(StreamingError::ResponseTooLarge { limit }); @@ -5942,7 +5962,7 @@ impl CodeGenerator { /// Parse an SSE response without an external EventSource wrapper. pub async fn parse_sse_stream( request_builder: reqwest::RequestBuilder - ) -> Result> + Send>>, StreamingError> + ) -> Result>, StreamingError> where T: serde::de::DeserializeOwned + Send + 'static, { @@ -6005,26 +6025,26 @@ impl CodeGenerator { fn __raw_response_stream( response: reqwest::Response, - ) -> Pin, StreamingError>> + Send>> { + ) -> BoxSseStream, StreamingError>> { let stream = futures_util::stream::unfold( ( - response, + response.bytes_stream(), __SseDecoder::default(), std::collections::VecDeque::, StreamingError>>::new(), false, ), - |(mut response, mut decoder, mut pending, mut done)| async move { + |(mut chunks, mut decoder, mut pending, mut done)| async move { loop { if let Some(item) = pending.pop_front() { - return Some((item, (response, decoder, pending, done))); + return Some((item, (chunks, decoder, pending, done))); } if done { debug!("SSE stream completed normally"); return None; } - match response.chunk().await { - Ok(Some(chunk)) => { + match chunks.next().await { + Some(Ok(chunk)) => { for event in decoder.feed(&chunk) { let is_done = event .as_ref() @@ -6036,11 +6056,11 @@ impl CodeGenerator { } } } - Err(error) => { + Some(Err(error)) => { done = true; pending.push_back(Err(error.into())); } - Ok(None) => { + None => { done = true; for event in decoder.finish() { pending.push_back(event); @@ -6057,7 +6077,7 @@ impl CodeGenerator { async fn parse_sse_raw_stream_with_limit( request_builder: reqwest::RequestBuilder, max_response_body_bytes: usize, - ) -> Result, StreamingError>> + Send>>, StreamingError> { + ) -> Result, StreamingError>>, StreamingError> { Ok(match __open_sse_response(request_builder, max_response_body_bytes).await { Ok(response) => __raw_response_stream(response), Err(error) => Box::pin(futures_util::stream::once(async move { Err(error.error) })), @@ -6065,8 +6085,8 @@ impl CodeGenerator { } fn __json_event_stream( - raw: Pin, StreamingError>> + Send>>, - ) -> Pin, StreamingError>> + Send>> + raw: BoxSseStream, StreamingError>>, + ) -> BoxSseStream, StreamingError>> where T: serde::de::DeserializeOwned + Send + 'static, { @@ -6081,7 +6101,7 @@ impl CodeGenerator { async fn parse_sse_json_events_with_limit( request_builder: reqwest::RequestBuilder, max_response_body_bytes: usize, - ) -> Result, StreamingError>> + Send>>, StreamingError> + ) -> Result, StreamingError>>, StreamingError> where T: serde::de::DeserializeOwned + Send + 'static, { @@ -6093,7 +6113,7 @@ impl CodeGenerator { async fn parse_sse_json_stream_with_limit( request_builder: reqwest::RequestBuilder, max_response_body_bytes: usize, - ) -> Result> + Send>>, StreamingError> + ) -> Result>, StreamingError> where T: serde::de::DeserializeOwned + Send + 'static, { @@ -6103,7 +6123,7 @@ impl CodeGenerator { struct __ReconnectState { request: reqwest::RequestBuilder, - response: Option, + body: Option>>, decoder: __SseDecoder, pending: std::collections::VecDeque, StreamingError>>, options: SseReconnectOptions, @@ -6117,7 +6137,7 @@ impl CodeGenerator { request_builder: reqwest::RequestBuilder, max_response_body_bytes: usize, options: SseReconnectOptions, - ) -> Result, StreamingError>> + Send>>, StreamingError> { + ) -> Result, StreamingError>>, StreamingError> { if request_builder.try_clone().is_none() { return Err(StreamingError::Connection( "SSE reconnection requires a cloneable request body".to_string(), @@ -6127,7 +6147,7 @@ impl CodeGenerator { let stream = futures_util::stream::unfold( __ReconnectState { request: request_builder, - response: None, + body: None, decoder: __SseDecoder::default(), pending: std::collections::VecDeque::new(), options, @@ -6145,7 +6165,7 @@ impl CodeGenerator { return None; } - if state.response.is_none() { + if state.body.is_none() { if state.wait_before_open { let delay = state.options.delay( state.attempts.saturating_sub(1), @@ -6161,7 +6181,7 @@ impl CodeGenerator { request = request.header("Last-Event-ID", last_event_id); } match __open_sse_response(request, state.max_response_body_bytes).await { - Ok(response) => state.response = Some(response), + Ok(response) => state.body = Some(Box::pin(response.bytes_stream())), Err(error) if error.retryable && state.attempts < state.options.max_retries => { state.attempts += 1; state.wait_before_open = true; @@ -6175,9 +6195,9 @@ impl CodeGenerator { } } - let next = state.response.as_mut().expect("response opened").chunk().await; + let next = state.body.as_mut().expect("response opened").next().await; match next { - Ok(Some(chunk)) => { + Some(Ok(chunk)) => { let events = state.decoder.feed(&chunk); if !events.is_empty() { state.attempts = 0; @@ -6189,12 +6209,12 @@ impl CodeGenerator { state.pending.push_back(event); if is_done { state.done = true; - state.response = None; + state.body = None; break; } } } - Ok(None) => { + None => { let events = state.decoder.finish(); if !events.is_empty() { state.attempts = 0; @@ -6209,7 +6229,7 @@ impl CodeGenerator { break; } } - state.response = None; + state.body = None; state.decoder.reset_for_reconnect(); if !state.done { if state.attempts < state.options.max_retries { @@ -6220,8 +6240,8 @@ impl CodeGenerator { } } } - Err(error) => { - state.response = None; + Some(Err(error)) => { + state.body = None; state.decoder.reset_for_reconnect(); if state.attempts < state.options.max_retries { state.attempts += 1; @@ -6242,7 +6262,7 @@ impl CodeGenerator { request_builder: reqwest::RequestBuilder, max_response_body_bytes: usize, options: SseReconnectOptions, - ) -> Result, StreamingError>> + Send>>, StreamingError> + ) -> Result, StreamingError>>, StreamingError> where T: serde::de::DeserializeOwned + Send + 'static, { @@ -6259,7 +6279,7 @@ impl CodeGenerator { request_builder: reqwest::RequestBuilder, max_response_body_bytes: usize, options: SseReconnectOptions, - ) -> Result> + Send>>, StreamingError> + ) -> Result>, StreamingError> where T: serde::de::DeserializeOwned + Send + 'static, { diff --git a/src/type_mapping.rs b/src/type_mapping.rs index 4486f6a..f7d72a4 100644 --- a/src/type_mapping.rs +++ b/src/type_mapping.rs @@ -32,6 +32,10 @@ use serde::{Deserialize, Serialize}; use crate::openapi::{SchemaDetails, SchemaType as OpenApiSchemaType}; +/// Cargo target-cfg predicate for wasm32-unknown-unknown. Emitted target-scoped +/// dependencies use this so only web wasm builds pick up the extra features. +pub const WASM_TARGET_CFG: &str = "cfg(target_arch = \"wasm32\")"; + /// Result of mapping an OpenAPI `(type, format)` pair to a Rust type. #[derive(Debug, Clone)] pub struct MappedType { @@ -149,6 +153,11 @@ pub struct DepRequirement { pub features: Vec<&'static str>, pub default_features: bool, pub optional: bool, + /// When set, this requirement is emitted under a + /// `[target.''.dependencies]` table instead of `[dependencies]`. + /// Used for wasm-only needs (e.g. `getrandom/wasm_js`) that must not + /// change the native dependency set. + pub target: Option<&'static str>, } impl DepRequirement { @@ -159,6 +168,7 @@ impl DepRequirement { features: Vec::new(), default_features: true, optional: false, + target: None, } } @@ -179,6 +189,13 @@ impl DepRequirement { self } + /// Scope this requirement to the given target cfg predicate, e.g. + /// `cfg(target_arch = "wasm32")`. + pub fn for_target(mut self, cfg: &'static str) -> Self { + self.target = Some(cfg); + self + } + /// Render as a single TOML `[dependencies]` line. Picks the /// most compact form that still expresses the required features. pub fn to_toml_line(&self) -> String { @@ -223,10 +240,24 @@ pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option { \n\ [dependencies]\n", ); - for dep in deps { + for dep in deps.iter().filter(|dep| dep.target.is_none()) { out.push_str(&dep.to_toml_line()); out.push('\n'); } + // Target-scoped requirements are grouped by cfg. `merge_dep_requirements` + // already sorts by crate name, and there is currently a single target, so + // grouping directly yields a stable order. + for target in deps + .iter() + .filter_map(|dep| dep.target) + .collect::>() + { + out.push_str(&format!("\n[target.'{target}'.dependencies]\n")); + for dep in deps.iter().filter(|dep| dep.target == Some(target)) { + out.push_str(&dep.to_toml_line()); + out.push('\n'); + } + } if deps.iter().any(|dep| dep.crate_name == "specta") { out.push_str("\n[features]\nspecta = [\"dep:specta\"]\n"); } @@ -239,12 +270,16 @@ pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option { pub fn merge_dep_requirements( requirements: impl IntoIterator, ) -> Vec { - let mut merged: std::collections::BTreeMap<&'static str, DepRequirement> = - std::collections::BTreeMap::new(); + // Target-scoped requirements are keyed separately from global ones: a + // global `getrandom` and a wasm-only `getrandom` are distinct entries. + let mut merged: std::collections::BTreeMap< + (&'static str, Option<&'static str>), + DepRequirement, + > = std::collections::BTreeMap::new(); for mut dependency in requirements { dependency.features.sort_unstable(); dependency.features.dedup(); - match merged.get_mut(dependency.crate_name) { + match merged.get_mut(&(dependency.crate_name, dependency.target)) { Some(existing) => { debug_assert_eq!(existing.version, dependency.version); existing.default_features |= dependency.default_features; @@ -254,7 +289,7 @@ pub fn merge_dep_requirements( existing.features.dedup(); } None => { - merged.insert(dependency.crate_name, dependency); + merged.insert((dependency.crate_name, dependency.target), dependency); } } } @@ -373,6 +408,15 @@ pub fn collect_generated_dep_requirements<'a>( } else { dependency.without_default_features() }); + // `reqwest-retry` -> `retry-policies` -> `rand` -> `getrandom 0.4`, + // which refuses wasm32 without the web-Crypto backend. Scope the + // enabling dependency to wasm32 so the native dependency set (and + // non-web wasm builds) are untouched. + dependencies.push( + DepRequirement::new("getrandom", "0.4") + .with_features(&["wasm_js"]) + .for_target(WASM_TARGET_CFG), + ); } if uses("reqwest_tracing::") { dependencies.push(DepRequirement::new("reqwest-tracing", "0.7")); @@ -388,6 +432,14 @@ pub fn collect_generated_dep_requirements<'a>( } if uses("futures_timer::") { dependencies.push(DepRequirement::new("futures-timer", "3")); + // On wasm32 `futures-timer`'s `Delay` needs the `wasm-bindgen` backend, + // which is not in its default feature set. Scope it to wasm32 so the + // native build is unchanged. + dependencies.push( + DepRequirement::new("futures-timer", "3") + .with_features(&["wasm-bindgen"]) + .for_target(WASM_TARGET_CFG), + ); } if uses("futures_core::") { dependencies.push(DepRequirement::new("futures-core", "0.3")); diff --git a/tests/client_response_body_limit_test.rs b/tests/client_response_body_limit_test.rs index 3abaef1..d0b0d4a 100644 --- a/tests/client_response_body_limit_test.rs +++ b/tests/client_response_body_limit_test.rs @@ -374,6 +374,8 @@ edition = "2024" publish = false {dependencies} + +[dev-dependencies] tokio = {{ version = "1", features = ["io-util", "macros", "net", "rt-multi-thread"] }} "# ), diff --git a/tests/generated_wasm_client_test.rs b/tests/generated_wasm_client_test.rs index 7f99fe2..674a0e4 100644 --- a/tests/generated_wasm_client_test.rs +++ b/tests/generated_wasm_client_test.rs @@ -8,25 +8,27 @@ //! Response` (issue #74). //! //! This test generates a client that exercises the buffered success, buffered -//! error, binary, and auto-detected SSE paths, then `cargo check`s the exact -//! `REQUIRED_DEPS.toml` output for wasm32. It is the only automated check that -//! would have caught the regression, so it belongs on CI. +//! error, binary, and auto-detected SSE paths, plus the two opt-in stacks that +//! needed target-scoped handling: //! -//! The opt-in SSE runtime (`enable_sse_client` + `[[streaming.endpoints]]`) is -//! intentionally out of scope: it builds `Send` futures and `Pin>`, which cannot satisfy wasm32's single-threaded `fetch`. -//! That is tracked separately. +//! * retry (`[http_client.retry]`) pulls `getrandom 0.4`, which rejects wasm32 +//! unless the `wasm_js` feature is enabled; the emitted fragment now scopes +//! that feature to `cfg(target_arch = "wasm32")`. +//! * the SSE runtime (`enable_sse_client` + `[[streaming.endpoints]]`) emits a +//! cfg-split `BoxSseStream` alias and `#[cfg_attr(...)] async_trait`, so +//! native keeps `Send` streams and wasm drops the bound. //! -//! The `wasm32-unknown-unknown` target must be installed. The test skips (with -//! a printed notice) when it is absent so it cannot fail a machine that simply -//! lacks the target; CI installs it explicitly. +//! All three then `cargo check` for wasm32 (and native) from the exact emitted +//! `REQUIRED_DEPS.toml`. This is the only automated check that would have caught +//! the original regression, so it belongs on CI. //! -//! `cargo check` for wasm32 still resolves reqwest's wasm backend and the -//! `stream` feature, so a genuinely bad generated call is a compile error here -//! rather than a silent pass. +//! The `wasm32-unknown-unknown` target must be installed. It skips (with a +//! printed notice) when it is absent so it cannot fail a machine that simply +//! lacks the target; CI installs it explicitly. use openapi_to_rust::http_config::HttpClientConfig; -use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use openapi_to_rust::streaming::{HttpMethod, StreamingConfig, StreamingEndpoint}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, RetryConfig, SchemaAnalyzer}; use serde_json::json; use std::collections::HashMap; use std::process::Command; @@ -108,6 +110,13 @@ fn wasm32_target_installed() -> bool { std::path::Path::new(libdir.trim()).is_dir() } +/// One generated client configuration to compile-check. +struct Case { + label: &'static str, + enable_sse_client: bool, + retry_config: Option, +} + #[test] fn generated_client_compiles_for_wasm32() { if !wasm32_target_installed() { @@ -118,15 +127,45 @@ fn generated_client_compiles_for_wasm32() { return; } - // Default configuration: `tracing_enabled` is true by default and pulls in - // reqwest-tracing, so the real default stack is what gets checked. - // - // Two opt-in stacks are deliberately out of scope and tracked separately, - // because neither is wasm32-compatible for reasons outside this helper: - // * the SSE runtime (`enable_sse_client` + `[[streaming.endpoints]]`) - // builds `Send` futures and `Pin>`; - // * retry (`[http_client.retry]`) pulls `retry-policies` -> `rand` -> - // `getrandom 0.4`, which rejects wasm32 without `wasm_js`. + // `tracing_enabled` is true by default and pulls in reqwest-tracing, so the + // real default stack is what gets checked first. + let cases = [ + Case { + label: "default", + enable_sse_client: false, + retry_config: None, + }, + Case { + label: "retry", + enable_sse_client: false, + retry_config: Some(RetryConfig { + max_retries: 2, + initial_delay_ms: 100, + max_delay_ms: 1_000, + }), + }, + Case { + label: "sse", + enable_sse_client: true, + retry_config: None, + }, + Case { + label: "sse-retry", + enable_sse_client: true, + retry_config: Some(RetryConfig { + max_retries: 2, + initial_delay_ms: 100, + max_delay_ms: 1_000, + }), + }, + ]; + + for case in &cases { + check_case(case); + } +} + +fn check_case(case: &Case) { let temp = tempfile::TempDir::new().unwrap(); let output_dir = temp.path().join("src/generated"); @@ -134,15 +173,13 @@ fn generated_client_compiles_for_wasm32() { .unwrap() .analyze() .unwrap(); - let generator = CodeGenerator::new(GeneratorConfig { + let mut config = GeneratorConfig { output_dir: output_dir.clone(), module_name: "generated".into(), enable_async_client: true, - // No `[streaming]` config: this exercises the plain client plus the - // auto-detected SSE operation, whose error path also buffers through - // the helper. - enable_sse_client: false, + enable_sse_client: case.enable_sse_client, tracing_enabled: true, + retry_config: case.retry_config.clone(), http_client_config: Some(HttpClientConfig { base_url: None, timeout_seconds: None, @@ -150,7 +187,20 @@ fn generated_client_compiles_for_wasm32() { default_headers: HashMap::new(), }), ..Default::default() - }); + }; + if case.enable_sse_client { + config.streaming_config = Some(StreamingConfig { + endpoints: vec![StreamingEndpoint { + operation_id: "streamEvents".into(), + path: "/events".into(), + http_method: HttpMethod::Get, + event_union_type: "Thing".into(), + ..Default::default() + }], + ..Default::default() + }); + } + let generator = CodeGenerator::new(config); let result = generator.generate_all(&mut analysis).unwrap(); generator.write_files(&result).unwrap(); @@ -179,28 +229,27 @@ fn generated_client_compiles_for_wasm32() { ) .unwrap(); - // Keep the check cheap: reuse one workspace target dir across runs so - // reqwest's wasm backend is compiled once. The scratch crate has no lock - // file, so allow the resolver to reach the index on a cold cache rather - // than passing `--offline`. + // Reuse one workspace target dir across cases so reqwest's wasm backend is + // compiled once. The scratch crate has no lock file, so allow the resolver + // to reach the index on a cold cache rather than passing `--offline`. let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/generated-wasm32-client"); - let output = Command::new("cargo") - .args([ - "check", - "--lib", - "--quiet", - "--target", - "wasm32-unknown-unknown", - ]) - .current_dir(temp.path()) - .env("CARGO_TARGET_DIR", manifest_dir) - .output() - .expect("cargo check runs"); - - assert!( - output.status.success(), - "generated client failed to compile for wasm32-unknown-unknown (issue #74):\n{}", - String::from_utf8_lossy(&output.stderr) - ); + for target in ["wasm32-unknown-unknown", "native"] { + let mut command = Command::new("cargo"); + command.args(["check", "--lib", "--quiet"]); + if target == "wasm32-unknown-unknown" { + command.args(["--target", target]); + } + let output = command + .current_dir(temp.path()) + .env("CARGO_TARGET_DIR", &manifest_dir) + .output() + .expect("cargo check runs"); + assert!( + output.status.success(), + "generated client ({}) failed to compile for {target} (issue #74):\n{}", + case.label, + String::from_utf8_lossy(&output.stderr) + ); + } } diff --git a/tests/generation_requirements_test.rs b/tests/generation_requirements_test.rs index 797ea7d..b44e32b 100644 --- a/tests/generation_requirements_test.rs +++ b/tests/generation_requirements_test.rs @@ -192,12 +192,17 @@ fn compile_case(name: &str, mut config: GeneratorConfig) -> openapi_to_rust::Gen let parsed = dependency_fragment .parse::() .expect("dependency fragment is valid TOML"); + let global_requirements = result + .required_deps + .iter() + .filter(|dependency| dependency.target.is_none()) + .count(); assert_eq!( parsed .get("dependencies") .and_then(toml::Value::as_table) .map(toml::Table::len), - Some(result.required_deps.len()) + Some(global_requirements) ); std::fs::write(&manifest_path, format!("{package}\n{dependency_fragment}")) @@ -414,6 +419,44 @@ fn every_generation_mode_compiles_from_its_exact_dependency_fragment() { assert_eq!(middleware.version, "0.5"); assert_eq!(middleware.features, vec!["multipart", "query"]); + // Retry pulls `getrandom 0.4`, which needs `wasm_js` on wasm32. The + // requirement must be target-scoped so native is unchanged. + let retry = compile_case( + "retry", + GeneratorConfig { + enable_async_client: true, + enable_sse_client: false, + tracing_enabled: false, + retry_config: Some(openapi_to_rust::RetryConfig { + max_retries: 2, + initial_delay_ms: 500, + max_delay_ms: 16_000, + }), + ..Default::default() + }, + ); + assert!( + retry + .required_deps + .iter() + .any(|dependency| dependency.crate_name == "reqwest-retry"), + "retry config must emit reqwest-retry" + ); + let wasm_getrandom = retry + .required_deps + .iter() + .find(|dependency| dependency.crate_name == "getrandom") + .expect("wasm getrandom for retry"); + assert_eq!(wasm_getrandom.target, Some("cfg(target_arch = \"wasm32\")")); + assert_eq!(wasm_getrandom.features, vec!["wasm_js"]); + assert!( + !retry + .required_deps + .iter() + .any(|dependency| dependency.crate_name == "getrandom" && dependency.target.is_none()), + "getrandom must not be a global dependency" + ); + let sse = compile_case( "sse", GeneratorConfig { @@ -465,6 +508,26 @@ fn every_generation_mode_compiles_from_its_exact_dependency_fragment() { ); assert!(sse_runtime.content.contains("pub async fn stream_raw")); assert!(sse_runtime.content.contains("Last-Event-ID")); + // The boxed-stream alias is cfg-split so native keeps `Send` and wasm drops + // it (issue #74 follow-up). + assert!(sse_runtime.content.contains("pub type BoxSseStream")); + assert!( + sse_runtime + .content + .contains("#[cfg(not(target_arch = \"wasm32\"))]") + ); + assert!( + sse_runtime + .content + .contains("#[cfg(target_arch = \"wasm32\")]") + ); + // `futures-timer` must gain its wasm backend under a target table. + let wasm_futures_timer = sse + .required_deps + .iter() + .find(|dependency| dependency.crate_name == "futures-timer" && dependency.target.is_some()) + .expect("target-scoped futures-timer"); + assert_eq!(wasm_futures_timer.features, vec!["wasm-bindgen"]); let streaming = sse .files .iter() @@ -473,7 +536,13 @@ fn every_generation_mode_compiles_from_its_exact_dependency_fragment() { assert!( streaming .content - .contains("use super::sse::{SseClient, SseReconnectOptions}") + .contains("use super::sse::{BoxSseStream, SseClient, SseReconnectOptions}") + ); + // `#[async_trait]` is `?Send` on wasm and default on native. + assert!( + streaming + .content + .contains("#[cfg_attr(target_arch = \"wasm32\", async_trait(?Send))]") ); assert!(streaming.content.contains("with_reconnect_options")); assert!(sse.mod_file.content.contains("pub mod sse;")); @@ -551,6 +620,7 @@ fn every_generation_mode_compiles_from_its_exact_dependency_fragment() { "futures-core", "futures-timer", "futures-util", + "getrandom", "http-body-util", "jsonschema", "mime", diff --git a/tests/live_sse_backend_test.rs b/tests/live_sse_backend_test.rs index 0d1cc71..0247d59 100644 --- a/tests/live_sse_backend_test.rs +++ b/tests/live_sse_backend_test.rs @@ -83,7 +83,7 @@ fn generated_sse_transport_streams_openai_and_anthropic_protocols() { assert!(output_dir.join("sse.rs").is_file()); let streaming = std::fs::read_to_string(output_dir.join("streaming.rs")).unwrap(); - assert!(streaming.contains("use super::sse::SseClient")); + assert!(streaming.contains("use super::sse::{BoxSseStream, SseClient}")); std::fs::write( temp.path().join("src/main.rs"), @@ -166,6 +166,14 @@ async fn main() -> Result<(), Box> { .unwrap(); let dependencies = std::fs::read_to_string(output_dir.join("REQUIRED_DEPS.toml")).unwrap(); + // The emitted fragment may end with a target-scoped table, so append the + // test-only runtime dependency to `[dependencies]` explicitly rather than + // after the fragment. + let dependencies = dependencies.replacen( + "[dependencies]\n", + "[dependencies]\ntokio = { version = \"1\", features = [\"macros\", \"rt-multi-thread\"] }\n", + 1, + ); std::fs::write( temp.path().join("Cargo.toml"), format!( @@ -176,7 +184,6 @@ edition = "2024" publish = false {dependencies} -tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }} "# ), ) diff --git a/tests/non_json_response_test.rs b/tests/non_json_response_test.rs index 131c390..50c014b 100644 --- a/tests/non_json_response_test.rs +++ b/tests/non_json_response_test.rs @@ -199,6 +199,8 @@ edition = "2024" publish = false {dependencies} + +[dev-dependencies] axum = "0.8" tokio = {{ version = "1", features = ["macros", "net", "rt-multi-thread"] }} "# From 2cb1bead3e0a6eef9dacfa521053e4de8905467f Mon Sep 17 00:00:00 2001 From: James Lal Date: Mon, 14 Sep 2026 00:47:52 -0600 Subject: [PATCH 3/4] fix(ci): always relink the requested corpus generator binary `corpus_build` builds the base and head generators into one shared target dir so dependencies compile once, and both resolve to the same friendly `//openapi-to-rust` path. With a restored `rust-cache`, cargo can consider the requested side fresh and skip relinking, leaving that path holding the other side's binary. `gen-diff` then generated the "head" corpus with the base generator and reported "no change" while the manifest check correctly saw stale output. Delete the friendly path before building so the missing output marks the unit dirty, and fail loudly if it is still absent afterward. This makes the corpus diff reliable on a warm cache. --- scripts/lib/corpus.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/lib/corpus.sh b/scripts/lib/corpus.sh index c5a67cf..c7bb9be 100755 --- a/scripts/lib/corpus.sh +++ b/scripts/lib/corpus.sh @@ -76,12 +76,26 @@ corpus_normalize() { # the checkout at , into . Echoes the binary path. # Default profile is release: debug generation of microsoft-graph alone takes # ~56s against ~9s release, and the corpus is generated twice per diff. +# +# Base and head share one target dir so dependencies compile once, and both +# resolve to the same friendly `//openapi-to-rust` path. With a +# restored `rust-cache`, cargo can consider the requested side fresh and skip +# relinking, leaving that path holding the *other* side's binary — the head +# corpus would then be generated by the base generator. Deleting the path first +# makes the missing output mark the unit dirty, so cargo relinks it from the +# cached artifacts; the guard below turns any silent miss into a failure. corpus_build() { local src="$1" target="$2" profile="${3:-release}" flag="" [ "$profile" = "release" ] && flag="--release" + local bin="$target/$profile/openapi-to-rust" + rm -f "$bin" cargo build $flag --bin openapi-to-rust \ --manifest-path "$src/Cargo.toml" --target-dir "$target" >&2 - printf '%s/%s/openapi-to-rust\n' "$target" "$profile" + if [ ! -x "$bin" ]; then + echo "[corpus] expected binary missing after build: $bin" >&2 + return 1 + fi + printf '%s\n' "$bin" } # corpus_generate [spec names...] — generate every spec From 866b6e2f2f6b4f618613e893de5a0e86bc929c87 Mon Sep 17 00:00:00 2001 From: James Lal Date: Mon, 14 Sep 2026 01:08:31 -0600 Subject: [PATCH 4/4] fix(ci): give the gen-diff base generator its own target dir `gen-diff.sh` built the base and head generators into the same target dir. Both are the same `openapi-to-rust` package, so they occupy the same cargo fingerprint/artifact slots. With a restored `rust-cache`, the head crate could look fresh after the base build overwrote those slots: cargo skipped relinking, and the "head" corpus was generated by the base binary. `gen-diff` then reported "no change" while the manifest gate correctly saw stale output. Build the base side with `--target-dir target/gen-diff-base` so each side's artifact is independent. The dir lives under `target/`, so rust-cache keeps it warm across runs; dependencies compile once per dir. Revert the earlier `rm -f` workaround in `corpus_build`, which did not help because cargo restores the friendly path from the collided artifact. --- scripts/gen-diff.sh | 19 ++++++++++++++----- scripts/lib/corpus.sh | 16 +--------------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/scripts/gen-diff.sh b/scripts/gen-diff.sh index 51273e2..f788022 100755 --- a/scripts/gen-diff.sh +++ b/scripts/gen-diff.sh @@ -27,6 +27,16 @@ PROFILE="${GEN_DIFF_PROFILE:-release}" MAX_DIFF_BYTES="${GEN_DIFF_MAX_DIFF_BYTES:-5000000}" read -r -a SPEC_FILTER <<<"${GEN_DIFF_SPECS:-}" +# The base generator gets its own target dir. It cannot share the working +# tree's: base and head are the same `openapi-to-rust` package, and a shared +# dir lets a restored build cache make the head crate look fresh after the base +# build has overwritten the shared artifact. Cargo then skips relinking and the +# "head" corpus is generated by the base binary, which reports "no change" +# while the manifest check sees stale output. A separate dir makes the two +# sides' artifacts independent. It lives under `target/` so rust-cache keeps it +# warm across runs. +BASE_TARGET="${GEN_DIFF_BASE_TARGET_DIR:-$PWD/target/gen-diff-base}" + BASE_REF="${1:-}" if [ -z "$BASE_REF" ]; then BASE_REF="$(git merge-base HEAD main 2>/dev/null || echo main)" @@ -70,11 +80,10 @@ else # trips over a stale registration. trap 'git worktree remove --force "$WT" >/dev/null 2>&1 || true' EXIT echo "[gen-diff] building generator at $BASE_SHA..." - # Both sides build into the workspace target dir: the dependency graph is - # identical, so reqwest and friends compile once instead of twice. Only the - # crate itself is rebuilt per side, and the base binary is stashed first - # because the head build overwrites it in place. - BASE_BIN="$(corpus_build "$PWD/$WT" "$PWD/target" "$PROFILE")" + # The base side builds into its own target dir (see BASE_TARGET above), so + # its artifact can never be confused with the head build's. Dependencies are + # compiled once per dir; the dir persists under target/ for rust-cache. + BASE_BIN="$(corpus_build "$PWD/$WT" "$BASE_TARGET" "$PROFILE")" cp "$BASE_BIN" "$ROOT/openapi-to-rust-$BASE_SHA" BASE_BIN="$PWD/$ROOT/openapi-to-rust-$BASE_SHA" echo "[gen-diff] generating base corpus..." diff --git a/scripts/lib/corpus.sh b/scripts/lib/corpus.sh index c7bb9be..c5a67cf 100755 --- a/scripts/lib/corpus.sh +++ b/scripts/lib/corpus.sh @@ -76,26 +76,12 @@ corpus_normalize() { # the checkout at , into . Echoes the binary path. # Default profile is release: debug generation of microsoft-graph alone takes # ~56s against ~9s release, and the corpus is generated twice per diff. -# -# Base and head share one target dir so dependencies compile once, and both -# resolve to the same friendly `//openapi-to-rust` path. With a -# restored `rust-cache`, cargo can consider the requested side fresh and skip -# relinking, leaving that path holding the *other* side's binary — the head -# corpus would then be generated by the base generator. Deleting the path first -# makes the missing output mark the unit dirty, so cargo relinks it from the -# cached artifacts; the guard below turns any silent miss into a failure. corpus_build() { local src="$1" target="$2" profile="${3:-release}" flag="" [ "$profile" = "release" ] && flag="--release" - local bin="$target/$profile/openapi-to-rust" - rm -f "$bin" cargo build $flag --bin openapi-to-rust \ --manifest-path "$src/Cargo.toml" --target-dir "$target" >&2 - if [ ! -x "$bin" ]; then - echo "[corpus] expected binary missing after build: $bin" >&2 - return 1 - fi - printf '%s\n' "$bin" + printf '%s/%s/openapi-to-rust\n' "$target" "$profile" } # corpus_generate [spec names...] — generate every spec