diff --git a/.changepacks/changepack_log_openapi-3-1-schema-output.json b/.changepacks/changepack_log_openapi-3-1-schema-output.json new file mode 100644 index 00000000..dbe4d4f3 --- /dev/null +++ b/.changepacks/changepack_log_openapi-3-1-schema-output.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Minor","crates/vespera_core/Cargo.toml":"Minor","crates/vespera_macro/Cargo.toml":"Minor","crates/vespera_inprocess/Cargo.toml":"Minor","crates/vespera_jni/Cargo.toml":"Minor","crates/vespera/Cargo.toml":"Minor","libs/vespera-bridge/build.gradle.kts":"Minor"},"note":"BREAKING (0.x minor): OpenAPI schema output is now strict OpenAPI 3.1 / JSON Schema 2020-12: nullable schemas serialize as type:[...,\"null\"] or nullable $ref anyOf, and schema-level #[schema(example = ...)] serializes as examples:[...] instead of singular example. SecurityScheme now includes OAuth/OpenID fields flows and openIdConnectUrl. vespera-bridge 0.2.0 DecodedResponse.body() returns a read-only ByteBuffer; use bodyBytes() for an owned byte[] copy.","date":"2026-06-20T00:00:00.000Z"} diff --git a/.changepacks/changepack_log_release-0-2-0-bridge.json b/.changepacks/changepack_log_release-0-2-0-bridge.json new file mode 100644 index 00000000..87437947 --- /dev/null +++ b/.changepacks/changepack_log_release-0-2-0-bridge.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Minor","libs/vespera-bridge/build.gradle.kts":"Minor","libs/vespera-bridge-gradle-plugin/build.gradle.kts":"Minor"},"note":"0.2.0 / 0.3.0 release — BREAKING (0.x minor): DecodedResponse.body() returns read-only ByteBuffer (bodyBytes() copies on demand); SmartDispatchModeResolver is the autoconfigured default (DIRECT ~2.2µs / SYNC ~3.2µs for small requests, opt out via vespera.bridge.dispatch-mode=bidirectional-streaming); Gradle plugin now also publishes to the Plugin Portal. Perf: JMethodID+GlobalRef caching for streaming closures, daemon-attached dispatchAsync completion, lazy bidirectional request-pull (spawn on first body poll), JsonGenerator wire-header encoding, zero-copy get_byte_array_region input conversion. Rust: Validated 422 envelope via derive(Serialize) (byte-identical, snapshot-locked), per-invocation fs::metadata epoch caching in vespera_macro, collector clone elimination. See libs/vespera-bridge/docs/jni-before-after-2026-06-11.md for measured numbers.","date":"2026-06-12T13:00:00.000Z"} diff --git a/.changepacks/config.json b/.changepacks/config.json index a28a25b8..46391859 100644 --- a/.changepacks/config.json +++ b/.changepacks/config.json @@ -9,6 +9,7 @@ ] }, "publish": { - "java": "./gradlew publishToMavenCentral --stacktrace" + "java": "./gradlew publishToMavenCentral --stacktrace", + "libs/vespera-bridge-gradle-plugin/build.gradle.kts": "./gradlew publishToMavenCentral publishPlugins --stacktrace" } } \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index bc06cc0e..64aafea1 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -39,6 +39,11 @@ jobs: run: cargo clippy --all-targets --all-features -- -D warnings - name: Test Deploy run: cargo publish --dry-run + - name: Doctest + # tarpaulin's --all-targets / default run never compiles doc + # tests, which let a never-passing doctest land unnoticed — + # run them explicitly before the (slow) coverage step. + run: cargo test --workspace --doc - name: Test run: | # rust coverage issue @@ -53,7 +58,7 @@ jobs: cargo fmt cargo tarpaulin --out Lcov Stdout --engine llvm - name: Upload to codecov.io - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true @@ -64,7 +69,9 @@ jobs: changepacks: name: changepacks runs-on: ubuntu-latest - needs: test + # jni-e2e gates publishing: a release must never ship with a broken + # JNI dispatch path on any supported OS. + needs: [test, jni-e2e] permissions: # create pull request comments pull-requests: write @@ -101,6 +108,75 @@ jobs: # GPG signing (in-memory key, no keyring file) ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SIGNING_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SIGNING_PASSWORD }} + # Gradle Plugin Portal credentials (read natively by + # com.gradle.plugin-publish for the `publishPlugins` task) + GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} outputs: changepacks: ${{ steps.changepacks.outputs.changepacks }} release_assets_urls: ${{ steps.changepacks.outputs.release_assets_urls }} + + # JNI end-to-end tests — builds the rust-jni-demo cdylib, publishes the + # vespera-bridge JAR to mavenLocal (so the demo-app Gradle plugin can + # resolve kr.devfive:vespera-bridge:0.1.1), then runs the full + # :demo-app:test suite (StreamingClosureStressTest + JNI dispatch tests) + # across all three target host OSes. This is the project's only Java/JNI + # coverage gate — until now the workflow ran zero JNI tests. + # + # Runs unconditionally on every push/PR (matching the existing CI job's + # style — no per-job paths-filter). The whole workflow already inherits + # the workflow-level `paths-ignore` for docs-only changes. + jni-e2e: + name: JNI E2E (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Build rust-jni-demo cdylib (release) + # The vespera-bridge Gradle plugin's bundleNativeLib task copies + # this cdylib from target/release into demo-app's resources, so it + # must exist before `:demo-app:test` (processResources) runs. + run: cargo build -p rust-jni-demo --release + - name: Make gradlew executable (unix) + if: runner.os != 'Windows' + run: | + chmod +x libs/vespera-bridge/gradlew + chmod +x libs/vespera-bridge-gradle-plugin/gradlew + chmod +x examples/rust-jni-demo/java/gradlew + - name: Publish vespera-bridge Gradle plugin to mavenLocal + # demo-app's plugins block resolves kr.devfive.vespera-bridge from + # mavenLocal (settings.gradle.kts pluginManagement) — the plugin is + # not on the Gradle Plugin Portal. + shell: bash + working-directory: libs/vespera-bridge-gradle-plugin + run: ./gradlew publishToMavenLocal --console=plain --no-daemon + - name: Publish vespera-bridge to mavenLocal + # demo-app resolves kr.devfive:vespera-bridge:0.1.1 from mavenLocal + # (see examples/rust-jni-demo/java/demo-app/build.gradle.kts — + # bridgeVersion.set("0.1.1")). + shell: bash + working-directory: libs/vespera-bridge + run: ./gradlew publishToMavenLocal --console=plain --no-daemon + - name: Run demo-app JNI E2E tests + # Includes StreamingClosureStressTest (1000 × 1 MiB SHA256 + # bidirectional round-trip). Bench knobs are NOT propagated — + # gated bench tests stay skipped in CI. + shell: bash + working-directory: examples/rust-jni-demo/java + run: ./gradlew :demo-app:test --console=plain --no-daemon + - name: Upload demo-app test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: jni-e2e-${{ matrix.os }}-test-results + path: examples/rust-jni-demo/java/demo-app/build/test-results/test/*.xml diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 00000000..96032fbb --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,112 @@ +name: Bench + +# Criterion regression gate for the in-process dispatch hot path. +# +# - push to main: runs the gated bench groups and saves the results as +# the `main` criterion baseline in the actions cache. +# - pull_request: restores the latest main baseline and compares; the +# job FAILS when any bench regresses by more than 10% mean change +# AND the 95% confidence interval lower bound exceeds +5% (the +# double condition filters shared-runner noise). +# +# Gated groups are the stable per-request paths (wire_path, +# headers_path, request_headers_path, resolve_path, dispatch_path). The +# streaming and contended groups are noisier (spawn_blocking / scheduler +# timing) and the router_path setup micro-bench is low-signal, so those +# are validated locally instead — see PERF_REPORT.md. +# +# This TIMING gate fires only at a loose ±10% (shared-runner drift), so it +# catches BIG regressions. Small, deterministic ALLOCATION regressions are +# caught noise-free by the `alloc_budget` integration test (a counting +# global allocator asserting exact per-dispatch allocation budgets) in the +# normal `cargo test` job — the two gates are complementary. + +on: + push: + branches: + - main + paths: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/bench.yml' + pull_request: + paths: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/bench.yml' + +concurrency: + group: bench-${{ github.ref }} + cancel-in-progress: true + +env: + BENCH_FILTER: 'wire_path|headers_path|request_headers_path|resolve_path|dispatch_path' + +jobs: + bench: + name: Criterion regression gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Restore criterion baseline (latest main) + id: restore-baseline + uses: actions/cache/restore@v5 + with: + path: target/criterion + key: bench-baseline-${{ runner.os }}-${{ github.sha }} + restore-keys: | + bench-baseline-${{ runner.os }}- + + - name: Run benches and save main baseline + if: github.event_name == 'push' + run: | + cargo bench -p vespera_inprocess --bench dispatch -- \ + --save-baseline main "${BENCH_FILTER}" + + - name: Save criterion baseline cache + if: github.event_name == 'push' + uses: actions/cache/save@v5 + with: + path: target/criterion + key: bench-baseline-${{ runner.os }}-${{ github.sha }} + + - name: Compare against main baseline + if: github.event_name == 'pull_request' + run: | + if [ ! -d target/criterion ] || ! find target/criterion -maxdepth 4 -type d -name main | grep -q .; then + echo "::notice::No main baseline in cache yet — running benches without a gate." + cargo bench -p vespera_inprocess --bench dispatch -- "${BENCH_FILTER}" + exit 0 + fi + cargo bench -p vespera_inprocess --bench dispatch -- \ + --baseline main "${BENCH_FILTER}" + + - name: Enforce regression gate + if: github.event_name == 'pull_request' + run: | + shopt -s nullglob + fail=0 + found=0 + while IFS= read -r f; do + found=1 + mean=$(jq -r '.mean.point_estimate' "$f") + lower=$(jq -r '.mean.confidence_interval.lower_bound' "$f") + bench=$(dirname "$(dirname "$f")") + bench=${bench#target/criterion/} + printf '%s: mean %+.2f%% (CI lower %+.2f%%)\n' \ + "$bench" "$(awk -v v="$mean" 'BEGIN{print v*100}')" \ + "$(awk -v v="$lower" 'BEGIN{print v*100}')" + if awk -v m="$mean" -v l="$lower" 'BEGIN{exit !(m > 0.10 && l > 0.05)}'; then + echo "::error::Performance regression: ${bench} mean change exceeds +10% with CI lower bound > +5%" + fail=1 + fi + done < <(find target/criterion -path '*/change/estimates.json') + if [ "$found" -eq 0 ]; then + echo "::notice::No change estimates found (first run against this baseline?) — nothing to gate." + fi + exit $fail diff --git a/.github/workflows/jni-bench.yml b/.github/workflows/jni-bench.yml new file mode 100644 index 00000000..220c3ee6 --- /dev/null +++ b/.github/workflows/jni-bench.yml @@ -0,0 +1,98 @@ +name: JNI Bench (nightly) + +# Informational JNI / perf benchmark run — NOT a regression gate. +# +# Most of the in-process & JNI performance work lives on the Java side +# (dispatch modes, daemon-env attach caching, direct buffers, mimalloc), +# which the criterion gate in bench.yml does NOT cover. Shared GitHub +# runners are far too noisy to threshold absolute ns/op, so this job runs +# the gated *BenchTest suite nightly purely to RECORD the numbers +# (printed to the job summary + uploaded as artifacts) so a human can spot +# drift over time. It never fails on a slow number — see PERF_REPORT.md +# for the locally-measured baselines. + +on: + schedule: + # 06:00 UTC daily (~15:00 KST) + - cron: '0 6 * * *' + workflow_dispatch: + +concurrency: + group: jni-bench-${{ github.ref }} + cancel-in-progress: true + +jobs: + jni-bench: + name: JNI Bench (ubuntu-latest) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Build rust-jni-demo cdylib (release) + # mimalloc is opt-in; the bench numbers reflect the default + # allocator unless the cdylib is built with --features mimalloc. + run: cargo build -p rust-jni-demo --release + - name: Make gradlew executable + run: | + chmod +x libs/vespera-bridge/gradlew + chmod +x libs/vespera-bridge-gradle-plugin/gradlew + chmod +x examples/rust-jni-demo/java/gradlew + - name: Publish vespera-bridge Gradle plugin to mavenLocal + working-directory: libs/vespera-bridge-gradle-plugin + run: ./gradlew publishToMavenLocal --console=plain --no-daemon + - name: Publish vespera-bridge to mavenLocal + working-directory: libs/vespera-bridge + run: ./gradlew publishToMavenLocal --console=plain --no-daemon + - name: Run JNI benchmarks (informational — never gates) + # The bench tests are gated behind -Dvespera.bench=true (the + # demo-app test task forwards that system property into the forked + # test JVM). This step is allowed to fail without failing the job: + # a flaky bench number must never break the nightly run. + continue-on-error: true + working-directory: examples/rust-jni-demo/java + run: | + ./gradlew :demo-app:test -Dvespera.bench=true \ + --tests 'kr.go.demo.*BenchTest' \ + --console=plain --no-daemon + - name: Summarise bench results + if: always() + run: | + { + echo '## JNI bench results' + echo '' + echo '> **Watch the ratios, not the absolute ns/op.** The latency bench' + echo '> measures every mode *interleaved* (round-robin blocks, median of' + echo '> 100), so the cross-mode ratios (`async_vs_sync`, `direct_vs_sync`,' + echo '> `resp_only_vs_bidi`) are the noise-robust regression signal — they' + echo '> stay stable run-to-run even when absolute numbers drift ±10% on a' + echo '> shared runner. A ratio moving materially = a real regression.' + echo '' + echo '### Noise-robust ratios' + echo '```' + grep -hoE 'VESPERA_BENCH summary[^<]*' \ + examples/rust-jni-demo/java/demo-app/build/test-results/test/*.xml \ + | sort -u || echo '(no ratio summary captured)' + echo '```' + echo '### All bench lines' + echo '```' + # Bench lines (VESPERA_BENCH / ALLOC / CONC / JFR_LOAD) are + # captured in the JUnit XML ; pull them out for a + # quick at-a-glance view in the run summary. + grep -hoE 'VESPERA_(BENCH|ALLOC|CONC|JFR_LOAD)[^<]*' \ + examples/rust-jni-demo/java/demo-app/build/test-results/test/*.xml \ + | sort -u || echo '(no bench lines captured)' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - name: Upload bench results + if: always() + uses: actions/upload-artifact@v7 + with: + name: jni-bench-results + path: examples/rust-jni-demo/java/demo-app/build/test-results/test/*.xml + if-no-files-found: warn diff --git a/AGENTS.md b/AGENTS.md index f3cf0a5f..6bb3c9ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Also provides in-process dispatch (`vespera_inprocess` crate) and JNI integratio | Capability | Where | Notes | |---|---|---| -| **`#[derive(Schema)]` → OpenAPI 3.1** | `vespera_macro::Schema` | Rust types become JSON Schema at compile time, including serde renames, `Option`, `Vec`, SeaORM relations | +| **`#[derive(Schema)]` → OpenAPI 3.1** | `vespera_macro::Schema` | Rust types become JSON Schema at compile time, including serde renames, `Option` as `type:[...,"null"]` / nullable `$ref` as `anyOf`, schema-level `examples:[...]` (not singular `example`), `Vec`, SeaORM relations | | **`Validated` extractor + auto-`422`** | `vespera::Validated`, `crates/vespera/src/validated.rs` | Wraps `Json`/`Form`/`Query`/`Path` and runs `garde::Validate` before the handler — rejection is **`422 Unprocessable Entity`** with `{"errors":[{"path","message"}]}` JSON envelope | | **`schema_type! { ... }`** | `vespera_macro::schema_type` | Derive request/response DTOs from existing structs (`pick` / `omit` / `partial` / `add` / `multipart` / `omit_default`) — first-class SeaORM relation support | | **One-liner `.serve(addr)`** | `vespera::Serve` (`crates/vespera/src/serve.rs`) | Extension trait on `axum::Router` — `create_app().serve("0.0.0.0:3000").await` replaces 3 lines of `TcpListener::bind` + `axum::serve` boilerplate | @@ -36,12 +36,17 @@ vespera/ │ ├── vespera_core/ # OpenAPI types, route/schema abstractions │ ├── vespera_macro/ # Proc-macros (main logic lives here) │ ├── vespera_inprocess/ # In-process dispatch (transport-agnostic) -│ │ └── src/lib.rs # dispatch(), register_app(), dispatch_from_bytes() +│ │ ├── src/lib.rs # dispatch(), register_app(), dispatch_from_bytes() +│ │ └── src/wire/ # hand-rolled wire-header parse (header_read) + serialize (header_write) │ └── vespera_jni/ # JNI bridge (depends on vespera_inprocess) -│ └── src/lib.rs # RUNTIME, jni_app! macro, JNI symbol export +│ ├── src/jni_impl.rs # RUNTIME, jni_app! macro, JNI symbol export +│ └── src/streaming_closures.rs # Streaming closure factories + JMethodID cache ├── libs/ │ └── vespera-bridge/ # Java library (com.devfive.vespera.bridge) -│ ├── VesperaBridge.java # JNI native loader + dispatch +│ ├── VesperaBridge.java # Public bridge facade + dispatch helpers +│ ├── VesperaNativeLoader.java # Native library extraction/loading +│ ├── WireHeaderStringSupport.java # Wire-header JSON/string helpers +│ ├── RequestShape.java # Request body/idempotency classifier │ └── VesperaProxyController.java # Auto-configured Spring proxy ├── examples/ │ ├── axum-example/ # Standard axum server demo @@ -62,9 +67,10 @@ vespera/ | Modify schema_type! macro | `crates/vespera_macro/src/schema_macro.rs` | Type derivation & SeaORM support | | Add core types | `crates/vespera_core/src/` | OpenAPI spec types | | Test new features | `examples/axum-example/` | Add route, run example | -| In-process dispatch | `crates/vespera_inprocess/src/lib.rs` | RequestEnvelope → Router → ResponseEnvelope | -| App factory (FFI pattern) | `crates/vespera_inprocess/src/lib.rs` | register_app(), dispatch_from_bytes() | -| JNI integration | `crates/vespera_jni/src/lib.rs` | RUNTIME, jni_app! macro, JNI symbol export | +| In-process dispatch | `crates/vespera_inprocess/src/dispatch.rs` | RequestEnvelope → Router → ResponseEnvelope; wire + direct-write entry points | +| Wire header parse/serialize | `crates/vespera_inprocess/src/wire/` | Hand-rolled `header_read` (request parse) + `header_write` (response serialize); byte-identical to serde_json, whose twins are kept private (`*_serde`) for the criterion A/B | +| App factory (FFI pattern) | `crates/vespera_inprocess/src/registry.rs` | register_app(), resolve_app_router() | +| JNI integration | `crates/vespera_jni/src/jni_impl.rs` | RUNTIME, jni_app! macro, JNI symbol export | | Java bridge library | `libs/vespera-bridge/` | com.devfive.vespera.bridge package | | JNI demo (Rust) | `examples/rust-jni-demo/src/` | Routes + vespera::jni_app! | | JNI demo (Java) | `examples/rust-jni-demo/java/` | Spring Boot proxy app | @@ -73,14 +79,27 @@ vespera/ | File | Lines | Role | |------|-------|------| -| `vespera_macro/src/lib.rs` | ~1044 | `vespera!`, `#[route]`, `#[derive(Schema)]` | -| `vespera_macro/src/schema_macro.rs` | ~3000 | `schema_type!` macro, SeaORM relation handling | -| `vespera_macro/src/parser/schema.rs` | ~1527 | Rust struct → JSON Schema conversion | -| `vespera_macro/src/parser/parameters.rs` | ~845 | Extract path/query params from handlers | -| `vespera_macro/src/openapi_generator.rs` | ~808 | OpenAPI doc assembly | -| `vespera_macro/src/collector.rs` | ~707 | Filesystem route scanning | -| `vespera_inprocess/src/lib.rs` | ~175 | In-process dispatch + app factory | -| `vespera_jni/src/lib.rs` | ~95 | JNI RUNTIME + jni_app! macro + JNI symbol | +| `vespera_macro/src/lib.rs` | ~429 | Proc-macro entry points: `vespera!`, `export_app!`, `#[route]`, `#[derive(Schema)]` | +| `vespera_macro/src/schema_macro/` | split modules | `schema_type!` macro, SeaORM relation handling | +| `vespera_macro/src/parser/schema/` | split modules | Rust struct/enum/type → JSON Schema conversion | +| `vespera_macro/src/parser/parameters.rs` | ~199 | Extract path/query/header params from handlers | +| `vespera_macro/src/openapi_generator.rs` | ~646 | OpenAPI doc assembly | +| `vespera_macro/src/collector.rs` | ~270 | Filesystem route scanning | +| `vespera_inprocess/src/lib.rs` | ~115 | Crate root: module wiring + public re-exports + `#[doc(hidden)]` `bench_support` (modularized — logic lives in the files below) | +| `vespera_inprocess/src/wire.rs` | ~1033 | Binary wire frame split/parse + 422 validation-error hoisting; `parse_wire_header` / `write_wire_header_into{,_slice}` delegate to the hand-rolled `wire/` submodules (serde_json twins retained private as `*_serde` for the criterion A/B) | +| `vespera_inprocess/src/wire/header_read.rs` | ~739 | Hand-rolled request-header JSON reader → `WireRequestHeader<'a>`: borrow-when-plain / own-when-escaped `Cow`, UTF-16 surrogate decode, any key order + unknown-skip + dup-reject, never panics (byte-behaviour-identical to the serde derive) | +| `vespera_inprocess/src/wire/header_write.rs` | ~282 | Hand-rolled response-header JSON serializer: `serde_json`-exact escape table + `\u00XX`, sorted `HeaderMap`, metadata, `validation_errors`; one `JsonSink` serves the `Vec` and overflow-counting `&mut [u8]` paths (byte-identical to `serde_json`) | +| `vespera_inprocess/src/dispatch.rs` | ~547 | Public dispatch entry points: text envelope API, binary wire API, direct-write (`dispatch_into`) API | +| `vespera_inprocess/src/internal.rs` | ~576 | Request building + router oneshot + response collection (malformed path/header → 400) | +| `vespera_inprocess/src/streaming.rs` | ~770 | Response / header-callback / bidirectional streaming; `RequestChunk`/`StreamAbort` error-aware request body; bounded `ChannelBody` | +| `vespera_inprocess/src/registry.rs` | ~258 | App registration + lock-free default-app `OnceLock` + named-app `RwLock` | +| `vespera_inprocess/benches/dispatch/serde_ab.rs` | ~210 | Criterion A/B helpers comparing serde_json vs hand-rolled wire-header paths | +| `vespera_jni/src/jni_impl.rs` | ~937 | JNI RUNTIME + jni_app! macro + 7 JNI symbols (incl. direct-buffer path) | +| `vespera_jni/src/streaming_closures.rs` | ~570 | Streaming closure factories (`make_pull_closure`, `make_push_closure`, `call_header_consumer`, `complete_future`) + `OnceLock` caching `JMethodID`+`GlobalRef` for `InputStream.read`, `OutputStream.write`, `Consumer.accept`, `CompletableFuture.complete` — `call_method_unchecked` on the hot path. Pull/push/header closures attach via [`daemon_env::with_cached_daemon_env`] (TLS-cached daemon attach), not `attach_current_thread` per chunk | +| `vespera_jni/src/daemon_env.rs` | ~258 | `with_cached_daemon_env(jvm, cb)` — resolves the current OS thread's `JNIEnv` once via `GetEnv` and caches it in a `thread_local!` `RefCell>`, reused for every JNI callback on that thread (streaming chunk pull/push, header callbacks, async `CompletableFuture.complete`). Already-attached JVM threads are **borrowed** (never detached); unattached Tokio/`spawn_blocking` threads are **owned** (attached via `AttachCurrentThreadAsDaemon`, detached in the TLS `Drop` on thread exit). Replaces the prior per-chunk attach/detach churn; per-call local frame + exception scrub preserved | +| `libs/vespera-bridge/.../RequestShape.java` | ~83 | Java request body/idempotency classifier used by smart dispatch-mode selection | +| `libs/vespera-bridge/.../VesperaNativeLoader.java` | ~105 | Native library lookup/extraction/loading for the bridge facade | +| `libs/vespera-bridge/.../WireHeaderStringSupport.java` | ~73 | Shared Java helpers for wire-header UTF-8 strings and JSON escaping | ## CRATE DEPENDENCY GRAPH @@ -134,6 +153,7 @@ Feature flags: |---------|-----------|------| | `inprocess` | `vespera::inprocess` (= `vespera_inprocess`) | dispatch, register_app, envelopes | | `jni` | `vespera::jni` (= `vespera_jni`) + implies `inprocess` | RUNTIME, jni_app!, JNI symbol | +| `mimalloc` | (with `jni`) mimalloc as the cdylib's `#[global_allocator]` | measured -15~19% on sync/direct dispatch vs Windows HeapAlloc | ## JNI ARCHITECTURE @@ -170,7 +190,7 @@ bytes 4+N.. : raw body bytes (UTF-8 text or binary — - All failure modes (malformed wire, panic in Rust, no app registered) return a valid length-prefixed wire response, so the Java decoder never has to special-case errors. - `validation_errors` is an optional array hoisted from 422 JSON bodies (`{"errors":[...]}`) — original body preserved verbatim alongside. -### JNI Dispatch Modes (four symbols) +### JNI Dispatch Modes (seven symbols) | Symbol | Java native | Mode | Memory | |---|---|---|---| @@ -178,8 +198,13 @@ bytes 4+N.. : raw body bytes (UTF-8 text or binary — | `Java_...dispatchAsync` | `void dispatchAsync(CompletableFuture, byte[])` | async | full body | | `Java_...dispatchStreaming` | `byte[] dispatchStreaming(byte[], OutputStream)` | sync response-streaming | chunk-bounded response | | `Java_...dispatchFullStreaming` | `byte[] dispatchFullStreaming(byte[], InputStream, OutputStream)` | sync bidirectional streaming | chunk-bounded both directions | +| `Java_...dispatchStreamingWithHeader` | `void dispatchStreamingWithHeader(byte[], Consumer, OutputStream)` | sync response-streaming, header callback before first body byte | chunk-bounded response | +| `Java_...dispatchFullStreamingWithHeader` | `void dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream)` | sync bidirectional streaming, header callback | chunk-bounded both directions | +| `Java_...dispatchDirect0` | `int dispatchDirect(ByteBuffer, int, ByteBuffer)` (public validated wrapper over the private native) | sync, direct buffers | full body, zero Java heap arrays | -All four share the same wire format, registered router, and panic-safe `catch_unwind` discipline. `dispatchAsync` spawns the dispatch on Rust's shared Tokio runtime via `tokio::spawn` (panic → `JoinError` → `error_wire(500)`) and completes the `CompletableFuture` from a worker thread via `attach_current_thread`. `dispatchStreaming` drains the response body chunk-by-chunk via `http_body::Body::frame()` and writes each chunk to the Java `OutputStream`. `dispatchFullStreaming` adds request-side streaming: a `tokio::task::spawn_blocking` thread pulls 16 KiB chunks from `InputStream.read(byte[])` and feeds them into axum via an `mpsc::channel`-backed `http_body::Body`, giving natural backpressure (bounded 16-slot channel) so 1 GiB uploads run in `O(chunk_size)` RAM. +All share the same wire format, registered router, and panic-safe `catch_unwind` discipline. **`DecodedResponse` (vespera-bridge 0.2.0, BREAKING):** `body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes); `bodyBytes()` materialises an owned `byte[]` copy on demand — callers that previously used `body()` as `byte[]` must switch to `bodyBytes()`. The direct-buffer path (`dispatchDirect` + pooled `dispatchDirectPooled`, per-thread 64 KiB→4 MiB buffers via `vespera.direct.maxBufferBytes`) removes the two JNI region copies of `dispatchBytes`; on response overflow it returns `-(requiredSize)` and a retry **re-runs the handler**, so the Java side only auto-retries idempotent requests (`BufferTooSmallException` otherwise). Spring autoconfigured default since vespera-bridge 0.2.0: `SmartDispatchModeResolver` (small/bodyless idempotent → `DIRECT` ~2.2µs, small non-idempotent → `SYNC` ~3.2µs, else streaming ~24µs). Opt out with `vespera.bridge.dispatch-mode=bidirectional-streaming` to restore the pre-0.2.0 default (`BidirectionalStreamingDispatchModeResolver`: provably bodyless requests — CL:0, or GET/HEAD/OPTIONS without CL/TE — downgrade to response-only `STREAMING` ~3x, 24.1→7.7µs; everything else streams both ways). `dispatchAsync` spawns the dispatch on Rust's shared Tokio runtime via `tokio::spawn` (panic → `JoinError` → `error_wire(500)`) and completes the `CompletableFuture` from a daemon-attached cached Tokio worker thread (`with_async_daemon_env` in `jni_impl.rs`: raw `AttachCurrentThreadAsDaemon` + TLS env cache + per-completion local frame + unconditional pending-exception cleanup) — ~1.3µs/op faster than scoped attach per completion. `dispatchStreaming` drains the response body chunk-by-chunk via `http_body::Body::frame()` and writes each chunk to the Java `OutputStream`. `dispatchFullStreaming` adds request-side streaming: a `tokio::task::spawn_blocking` thread pulls chunks (default 256 KiB) from `InputStream.read(byte[])` and feeds them into axum via an `mpsc::channel`-backed `http_body::Body`, giving natural backpressure (bounded channel, default 16 slots) so 1 GiB uploads run in `O(chunk_size)` RAM. + +**Streaming tuning (process-fixed after first dispatch):** chunk size via system property `vespera.streaming.chunkBytes` / env `VESPERA_STREAMING_CHUNK_BYTES` (default 256 KiB, clamped 4 KiB–8 MiB); channel capacity via `vespera.streaming.channelCapacity` / `VESPERA_STREAMING_CHANNEL_CAPACITY` (default 16, clamped 1–1024). Java API: `VesperaBridge.configureStreaming(chunkBytes, channelCapacity)` — pending-config pattern (call before `init()`; values stored pending and applied right after native load, before any dispatch; programmatic > sysprops > env > defaults). Rust-side setters: `vespera_inprocess::set_streaming_chunk_bytes` / `set_streaming_channel_capacity` (precedence: setter > env > default). The shared Tokio runtime's worker count is tunable the same way: `vespera.runtime.workerThreads` / `VESPERA_RUNTIME_WORKERS` (default: logical CPUs, clamped 1–1024) — cap it when JVM thread pools compete for the same cores. `_default`-app dispatch resolves through a lock-free `OnceLock` fast path; named apps go through the `RwLock`. The response wire header serializes straight from `http::HeaderMap` (zero per-header allocation) and request wire headers deserialize borrowing from the input buffer (`Cow`); both the response serialize and the request parse use **hand-rolled** writers/parsers (`wire/header_write.rs` / `wire/header_read.rs`) byte-identical to the prior `serde_json` path — the serde twins are retained private as `*_serde` for the same-run criterion A/B in `benches/dispatch.rs` (group `wire_header_serde`). The wire byte layout is locked by `crates/vespera_inprocess/tests/wire_contract.rs` and the hand-vs-serde round-trip property test in `wire.rs`. ### Rust Public API (vespera_inprocess) @@ -190,7 +215,11 @@ All four share the same wire format, registered router, and panic-safe `catch_un | `dispatch_from_bytes(Vec, &Runtime) -> Vec` | sync | FFI entry, blocks on runtime | | `dispatch_from_bytes_async(Vec) -> Vec` (async) | async | inside an existing runtime | | `dispatch_streaming_async(Vec, F) -> Vec` (async) | response streaming async | `F: FnMut(&[u8])` body chunks | +| `dispatch_streaming_with_header_async(Vec, H, F)` (async) | response streaming, header callback first | `H: FnMut(&[u8])` fires before first body chunk | | `dispatch_bidirectional_streaming(Vec, P, F) -> Vec` (async) | bidirectional streaming | `P: FnMut() -> Option> + Send + 'static`, `F: FnMut(&[u8])` | +| `dispatch_bidirectional_streaming_with_header(Vec, P, F, H)` (async) | bidirectional streaming, header callback | header before first body chunk | +| `dispatch_into(Vec, &mut [u8], &Runtime) -> DirectWriteResult` | sync | direct-write FFI entry — wire response streamed straight into the caller's buffer (no response `Vec`); `Complete(n)` / `Overflow(exact_required)`; 422 materialised internally to keep `validation_errors` hoisting | +| `dispatch_into_async(Vec, &mut [u8]) -> DirectWriteResult` (async) | async | same, inside an existing runtime | | `error_wire(u16, &str) -> Vec` | sync | wire-format error builder | | `dispatch_typed(Router, &RequestEnvelope) -> ResponseEnvelope` | async | direct axum API (BC) | @@ -225,7 +254,7 @@ vespera::jni_apps! { // multi-app primary API `@ConditionalOnMissingBean`: - `AppNameResolver` (default: `HeaderAppNameResolver("X-Vespera-App")`) — picks app per request -- `DispatchModeResolver` (default: `BidirectionalStreamingDispatchModeResolver`) — picks `DispatchMode` +- `DispatchModeResolver` (default since vespera-bridge 0.2.0: `SmartDispatchModeResolver` — small/bodyless idempotent → `DIRECT` ~2.2µs, small non-idempotent → `SYNC` ~3.2µs, else `BIDIRECTIONAL_STREAMING` ~24µs; `vespera.bridge.dispatch-mode=bidirectional-streaming` restores pre-0.2.0 `BidirectionalStreamingDispatchModeResolver` — bodyless requests take response-only `STREAMING`, everything else bidirectional) — picks `DispatchMode` Property `vespera.bridge.controller-enabled=false` disables the whole controller for BYO scenarios. See [`libs/vespera-bridge/README.md`](libs/vespera-bridge/README.md#customization) for the customization recipes. @@ -323,7 +352,7 @@ props only. | Concern | Location | |---|---| | Macro integration tests | `crates/vespera_macro/tests/` (+ `insta` snapshots) | -| Validated/422 contract | `crates/vespera/tests/validated_extractor.rs`, `crates/vespera/tests/jni_validation.rs` | +| Validated/422 contract | `crates/vespera/tests/validated_extractor.rs`, `crates/vespera/tests/jni_validation.rs` | Envelope built via `#[derive(Serialize)]` structs (not `serde_json::json!`); exact bytes locked by `insta::assert_snapshot!` in `validated_extractor.rs` | | Core unit tests | `crates/vespera_core/src/**` inline `#[cfg(test)]` | | JNI end-to-end | `examples/rust-jni-demo` (Rust + Java + Gradle) | | Front tests | `apps/front/src/__tests__/` (`bun test` + `bun-test-env-dom`) | @@ -337,6 +366,7 @@ props only. ## CONVENTIONS +- **File size cap**: every source file stays ≤ 1000 lines. Unit tests live **inline** (`#[cfg(test)] mod tests`) whenever code + tests fit the cap; only when they don't, tests move to sidecar child modules (`/tests.rs`, `/tests_.rs` — `use super::*` semantics preserved). Token-stream assertions use rstest cases + insta snapshots (explicit per-case snapshot names; `prettyplease` for item output) instead of `contains` probes. - **Rust 2024 edition** across all crates - **Workspace dependencies**: Internal crates use `{ workspace = true }` - **Test frameworks**: `rstest` for unit tests, `insta` for snapshots @@ -344,7 +374,7 @@ props only. - **No direct axum dep in examples**: Use `vespera::axum` re-export - **No direct vespera_jni/vespera_inprocess dep**: Use `vespera` features - **Java package**: `com.devfive.vespera.bridge` (fixed for JNI symbol stability) -- **Java build**: Gradle (Kotlin DSL), published to GitHub Packages +- **Java build**: Gradle (Kotlin DSL), published to Maven Central (`kr.devfive:vespera-bridge`, `kr.devfive:vespera-bridge-gradle-plugin`) via changepacks → `./gradlew publishToMavenCentral` (vanniktech maven-publish + GPG in-memory signing) ## ANTI-PATTERNS (THIS PROJECT) @@ -382,6 +412,9 @@ java -jar demo-app/build/libs/demo-app-0.1.0.jar # Check generated OpenAPI cat examples/axum-example/openapi.json + +# CI: jni-e2e job (3-OS matrix: ubuntu/windows/macos) runs demo-app E2E tests +# including StreamingClosureStressTest — see .github/workflows/CI.yml ``` ## NOTES @@ -392,3 +425,5 @@ cat examples/axum-example/openapi.json - Generic types in schemas require `#[derive(Schema)]` on all type params - JNI native library can be bundled inside the fat JAR for single-file deployment - `VesperaBridge.init()` auto-extracts bundled native lib to temp, falls back to system path +- JNI dispatch perf benchmarks: `libs/vespera-bridge/docs/jni-before-after-2026-06-11.md` (note: root `/docs` is gitignored) +- `vespera_macro` file_cache: per-macro-invocation epoch caching of `fs::metadata` (`bump_epoch` called at every file-cache-reaching entry point — `vespera!`, `schema_type!`, `schema!`, `export_app!`, `#[derive(Schema)]`); `collector.rs` clone-optimized diff --git a/Cargo.lock b/Cargo.lock index d2452f23..d9cf4f7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -404,9 +413,9 @@ dependencies = [ [[package]] name = "axum-test" -version = "20.0.0" +version = "20.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a86bfe2ef15bee102ac34912f7f4542b0bb37dc464fa55461763999c4d625e7" +checksum = "43c6a2f1d97ee33c39f13dacc0f84ae781a9c2ed373a75bad1129094f5a7c4bd" dependencies = [ "anyhow", "axum", @@ -436,12 +445,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bigdecimal" version = "0.4.10" @@ -458,9 +461,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -486,6 +489,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "borsh" version = "1.6.1" @@ -573,9 +585,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "shlex", @@ -606,9 +618,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -680,6 +692,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -692,9 +710,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -704,6 +722,10 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compile-bench-runner" +version = "0.1.0" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -724,12 +746,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-random" version = "0.1.18" @@ -888,14 +904,32 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -931,17 +965,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1017,17 +1040,26 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1102,13 +1134,12 @@ dependencies = [ [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1124,9 +1155,9 @@ dependencies = [ [[package]] name = "expect-json" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869f97f4abe8e78fc812a94ad6b721d72c4fb5532877c79610f2c238d7ccf6c4" +checksum = "5e80819dbfe83c8a651f5344b08910d0037dac72988aef27ee4e6bedd7ae2e33" dependencies = [ "chrono", "email_address", @@ -1142,9 +1173,9 @@ dependencies = [ [[package]] name = "expect-json-macros" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e6fdf550180a6c29a28cb9aac262dc0064c25735641d2317f670075e9a469d9" +checksum = "c0637949cd816934f3b7aab44ff98e7ec1fb903c379e07dcb9eac943ec33499e" dependencies = [ "proc-macro2", "quote", @@ -1165,9 +1196,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -1186,6 +1217,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1318,9 +1355,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1396,9 +1433,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1406,6 +1441,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashbrown" @@ -1415,11 +1455,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1434,7 +1474,7 @@ dependencies = [ "http", "httpdate", "mime", - "sha1", + "sha1 0.10.6", ] [[package]] @@ -1466,36 +1506,27 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "windows-sys 0.61.2", + "digest 0.11.3", ] [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1536,11 +1567,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1728,22 +1768,11 @@ dependencies = [ "serde_core", ] -[[package]] -name = "inherent" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "insta" -version = "1.47.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", @@ -1835,25 +1864,15 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - [[package]] name = "leb128fmt" version = "0.1.0" @@ -1930,22 +1949,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] -name = "libredox" -version = "0.1.16" +name = "libmimalloc-sys" +version = "0.1.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall 0.7.5", + "cc", ] [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -1975,9 +1991,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "mac_address" @@ -1990,6 +2006,14 @@ dependencies = [ "winapi", ] +[[package]] +name = "macro-compile-bench" +version = "0.1.0" +dependencies = [ + "serde", + "vespera", +] + [[package]] name = "matchit" version = "0.8.4" @@ -1998,19 +2022,19 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memoffset" @@ -2021,6 +2045,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "mime" version = "0.3.17" @@ -2029,9 +2062,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2092,22 +2125,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -2254,20 +2271,11 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2307,39 +2315,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plotters" version = "0.3.7" @@ -2598,20 +2579,11 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_syscall" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" -dependencies = [ - "bitflags", -] - [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -2632,9 +2604,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -2703,26 +2675,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rstest" version = "0.26.1" @@ -2779,9 +2731,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "borsh", @@ -2871,27 +2823,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "sea-bae" version = "0.2.1" @@ -2907,9 +2844,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "2.0.0-rc.38" +version = "2.0.0-rc.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b5428ce6a0c8f6b9858df21ad1aa00c2fb94e1c9f344a0436bc855391e5a225" +checksum = "628c3b6acb53ca9942f7f151431ed49db92dafa14d15976a1b9db9d4bd06431c" dependencies = [ "async-stream", "async-trait", @@ -2931,12 +2868,14 @@ dependencies = [ "serde", "serde_json", "sqlx", + "sqlx-core", "strum 0.28.0", "thiserror", "time", "tracing", "url", "uuid", + "web-time", ] [[package]] @@ -2952,9 +2891,9 @@ dependencies = [ [[package]] name = "sea-orm-macros" -version = "2.0.0-rc.38" +version = "2.0.0-rc.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae1374d83dd5b43f14dcc90fc726486c556f4db774b680b12b8c680af76e8233" +checksum = "68a91def07bceb98aab308f7dd16c27496b76a6b7b92b94a61b309b5043d93d5" dependencies = [ "heck 0.5.0", "itertools 0.14.0", @@ -2968,12 +2907,11 @@ dependencies = [ [[package]] name = "sea-query" -version = "1.0.0-rc.33" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b04cdb0135c16e829504e93fbe7880513578d56f07aaea152283526590111828" +checksum = "8d190cfb3bcceb8a8d7d04dee5a0c77f60c7627979cdcb47fdcb8934f009badf" dependencies = [ "chrono", - "inherent", "ordered-float", "rust_decimal", "sea-query-derive", @@ -2984,9 +2922,9 @@ dependencies = [ [[package]] name = "sea-query-derive" -version = "1.0.0-rc.12" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d88ad44b6ad9788c8b9476b6b91f94c7461d1e19d39cd8ea37838b1e6ff5aa8" +checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537" dependencies = [ "darling", "heck 0.4.1", @@ -2998,9 +2936,9 @@ dependencies = [ [[package]] name = "sea-query-sqlx" -version = "0.8.0-rc.15" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a04aeecfe00614fece56336fd35dc385bb9ffed0c75660695ba925e42a3991ef" +checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" dependencies = [ "sea-query", "sqlx", @@ -3008,9 +2946,9 @@ dependencies = [ [[package]] name = "sea-schema" -version = "0.17.0-rc.17" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b363dd21c20fe4d1488819cb2bc7f8d4696c62dd9f39554f97639f54d57dd0ab" +checksum = "f88267b43c127956a079895d864fc8318ee37c7f280a7aa33805b714c31995f0" dependencies = [ "async-trait", "sea-query", @@ -3110,6 +3048,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3124,24 +3071,23 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", @@ -3156,7 +3102,18 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3167,14 +3124,25 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3186,16 +3154,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "simd_cesu8" version = "1.1.1" @@ -3232,18 +3190,18 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3258,21 +3216,11 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "sqlx" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -3283,12 +3231,13 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64", "bytes", + "cfg-if", "chrono", "crc", "crossbeam-queue", @@ -3298,18 +3247,17 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hashlink", "indexmap", "log", "memchr", - "once_cell", "percent-encoding", "rust_decimal", "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror", "time", @@ -3318,14 +3266,14 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] name = "sqlx-macros" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", @@ -3336,20 +3284,20 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck 0.5.0", "hex", - "once_cell", "proc-macro2", "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -3361,55 +3309,39 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64", "bitflags", "byteorder", "bytes", "chrono", "crc", - "digest", + "digest 0.11.3", "dotenvy", "either", - "futures-channel", "futures-core", - "futures-io", "futures-util", "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", "log", - "md-5", - "memchr", - "once_cell", "percent-encoding", - "rand 0.8.6", - "rsa", "rust_decimal", "serde", - "sha1", - "sha2", - "smallvec", + "sha1 0.11.0", + "sha2 0.11.0", "sqlx-core", - "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", @@ -3425,17 +3357,15 @@ dependencies = [ "hex", "hkdf", "hmac", - "home", "itoa", "log", "md-5", "memchr", - "once_cell", - "rand 0.8.6", + "rand 0.10.1", "rust_decimal", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", @@ -3448,13 +3378,14 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -3464,7 +3395,6 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", "thiserror", "time", @@ -3580,6 +3510,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" version = "3.27.0" @@ -3593,6 +3529,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "third" version = "0.1.0" @@ -3755,6 +3700,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3766,9 +3726,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime", @@ -3785,6 +3745,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -3865,6 +3831,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3873,9 +3854,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typetag" @@ -3960,9 +3941,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3984,12 +3965,14 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.51" +version = "0.2.0" dependencies = [ "axum", "axum-extra", "chrono", + "criterion", "garde", + "insta", "serde", "serde_json", "tempfile", @@ -3998,6 +3981,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "trybuild", "vespera_core", "vespera_inprocess", "vespera_jni", @@ -4006,7 +3990,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.51" +version = "0.2.0" dependencies = [ "rstest", "serde", @@ -4015,14 +3999,17 @@ dependencies = [ [[package]] name = "vespera_inprocess" -version = "0.1.51" +version = "0.2.0" dependencies = [ + "arc-swap", "axum", "bytes", "criterion", + "futures-util", "http", "http-body", "http-body-util", + "mimalloc", "serde", "serde_json", "tokio", @@ -4031,26 +4018,32 @@ dependencies = [ [[package]] name = "vespera_jni" -version = "0.1.51" +version = "0.2.0" dependencies = [ + "futures-util", "jni", + "mimalloc", "tokio", "vespera_inprocess", ] [[package]] name = "vespera_macro" -version = "0.1.51" +version = "0.2.0" dependencies = [ + "croner", "insta", + "prettyplease", "proc-macro2", "quote", + "regex-syntax", "rstest", "serde", "serde_json", "serial_test", "syn 2.0.117", "tempfile", + "trybuild", "vespera_core", ] @@ -4081,9 +4074,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] @@ -4097,17 +4090,11 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -4119,9 +4106,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4129,9 +4116,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -4142,9 +4129,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -4185,21 +4172,22 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "webpki-roots" -version = "0.26.11" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "webpki-roots 1.0.7", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -4213,13 +4201,9 @@ dependencies = [ [[package]] name = "whoami" -version = "1.6.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" [[package]] name = "winapi" @@ -4311,22 +4295,13 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4338,67 +4313,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4411,48 +4353,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4585,9 +4503,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4608,18 +4526,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -4649,9 +4567,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 12fd3ae4..4915cd43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,12 @@ [workspace] resolver = "2" -members = ["crates/*", "examples/*"] +members = ["crates/*", "examples/*", "benches/*"] exclude = ["examples/java-jni-demo"] +# Bare `cargo build`/`test`/`clippy` (no `--workspace`) stay scoped to the +# shipped crates + examples; the compile-time benchmark fixture/harness under +# `benches/*` are built on demand (`cargo run -p compile-bench-runner`) so a +# deliberately macro-heavy fixture does not tax every local build. +default-members = ["crates/*", "examples/*"] [workspace.package] version = "0.2.0" @@ -10,6 +15,34 @@ license = "Apache-2.0" repository = "https://github.com/dev-five-git/vespera" readme = "README.md" +# Release profile tuned for the shipped artifacts (JNI cdylibs, server +# binaries): FAT LTO + single codegen unit deliberately trade much +# longer release-build time for maximum cross-crate inlining on the +# runtime-critical paths (wire parse/serialize, dispatch, JNI +# callbacks). Runtime performance of the shipped artifact is +# prioritized over build time by project policy. +# +# `strip = "debuginfo"` shrinks the artifact without touching the +# exported JNI dynamic symbols (they live in `.dynsym`, not the +# stripped debug/local symbol table). +# +# NEVER switch the panic strategy away from unwinding here — the JNI +# bridge relies on `catch_unwind` to convert handler panics into `500` +# wire responses; aborting would take down the host JVM instead. +[profile.release] +lto = "fat" +codegen-units = 1 +strip = "debuginfo" + +# Benchmarks must measure under the SAME codegen as the shipped release +# artifacts (fat LTO + single codegen unit); otherwise the absolute +# numbers reflect the default `bench` profile (no LTO, 16 codegen units) +# instead of production. Build time is intentionally traded for +# representative measurements. +[profile.bench] +lto = "fat" +codegen-units = 1 + [workspace.dependencies] vespera_core = { path = "crates/vespera_core", version = "0.2.0" } vespera_macro = { path = "crates/vespera_macro", version = "0.2.0" } diff --git a/README.md b/README.md index f109b6c1..e46c6ff8 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,38 @@ pub async fn create_user(Json(user): Json) -> Json { ... } pub async fn get_user(Path(id): Path) -> Json { ... } // Full options -#[vespera::route(put, path = "/{id}", tags = ["users"], description = "Update user")] +#[vespera::route( + put, + path = "/{id}", + tags = ["users"], + operation_id = "updateUser", + summary = "Update a user", + description = "Update user", + deprecated +)] pub async fn update_user(...) -> ... { ... } + +// Override or require auth for one operation in OpenAPI +#[vespera::route(get, path = "/me", tags = ["users"], security = ["bearerAuth"])] +pub async fn current_user(...) -> ... { ... } + +// Declare headers consumed by custom extractors so they appear in OpenAPI +#[vespera::route( + get, + headers = [ + { name = "Authorization", required = true, description = "Bearer token" }, + { name = "X-Trace-Id" } + ] +)] +pub async fn custom_auth_user(...) -> ... { ... } + +// Operation-level examples attach to requestBody / 200 response media types +#[vespera::route( + post, + request_example = r#"{"name":"Alice"}"#, + response_example = r#"{"id":1,"name":"Alice"}"# +)] +pub async fn create_user(...) -> ... { ... } ``` ### Schema Derivation @@ -211,6 +241,37 @@ Under JNI, the same `422` body is **hoisted** into the binary wire header as `"validation_errors": [...]` — Java decoders consume validation errors without parsing the body. See [`crates/vespera/tests/jni_validation.rs`](./crates/vespera/tests/jni_validation.rs). +### Security Schemes + +Declare OpenAPI security schemes in `vespera!`, then attach requirements to +routes with `security = [...]`. Each route entry becomes an OpenAPI security +requirement object with empty scopes; use `security = []` on a route to emit an +explicit unauthenticated operation. + +```rust +let app = vespera!( + openapi = "openapi.json", + docs_url = "/docs", + security_schemes = [ + { name = "bearerAuth", type = "http", scheme = "bearer", bearer_format = "JWT" }, + { name = "apiKey", type = "apiKey", in = "header", header_name = "X-API-Key" }, + { name = "basicAuth", type = "http", scheme = "basic" } + ], + security = ["bearerAuth"] // optional document-level default +); + +#[vespera::route(get, path = "/me", security = ["bearerAuth"])] +pub async fn current_user(...) -> ... { ... } + +#[vespera::route(get, path = "/health", security = [])] +pub async fn health() -> &'static str { "ok" } +``` + +Supported `type` values match OpenAPI's camelCase wire names: `"apiKey"`, +`"http"`, `"mutualTLS"`, `"oauth2"`, and `"openIdConnect"`. The DSL uses +`header_name` for the OpenAPI api-key `name` field so it does not conflict with +the security scheme entry name. + ### Supported Extractors | Extractor | OpenAPI Mapping | @@ -274,19 +335,57 @@ This generates a `multipart/form-data` request body with a generic `{ "type": "o ```rust #[derive(Serialize, Schema)] -pub struct ApiError { +pub struct NotFoundError { pub message: String, } -#[vespera::route(get, path = "/{id}")] -pub async fn get_user(Path(id): Path) -> Result, (StatusCode, Json)> { +#[vespera::route(get, path = "/{id}", responses = [(404, NotFoundError)])] +pub async fn get_user(Path(id): Path) -> Result, (StatusCode, Json)> { if id == 0 { - return Err((StatusCode::NOT_FOUND, Json(ApiError { message: "Not found".into() }))); + return Err((StatusCode::NOT_FOUND, Json(NotFoundError { message: "Not found".into() }))); } Ok(Json(User { id, name: "Alice".into() })) } ``` +Use `responses = [(status, Type)]` to document typed error bodies. `Type` may be +a bare type name or a path such as `crate::errors::NotFoundError`; Vespera uses +the last path segment as the OpenAPI schema name and emits a JSON `$ref` under +that status. `error_status = [400, 404]` remains available for schema-less extra +error statuses; when both are present, a typed `responses` entry wins for the +same status code. + +#### Explicit error responses are authoritative (no auto-`400`) + +By default a handler returning `Result<_, E>` (or `Result<_, (StatusCode, E)>`) +infers a single `400` error response, because the macro cannot read the runtime +`StatusCode`. **As soon as you declare any explicit error response** — via +`responses = [(code, Type)]` and/or `error_status = [code, ...]` — that explicit +set becomes authoritative and the inferred `400` is dropped (the success +response is untouched), *unless* `400` is itself among the declared codes: + +```rust +// Handler returns (StatusCode::INTERNAL_SERVER_ERROR, Json). +// Declaring responses = [(500, ...)] yields exactly { 200, 500 } — no spurious 400. +#[vespera::route(responses = [(500, ErrorResponse)])] +pub async fn fail() -> Result<&'static str, (StatusCode, Json)> { ... } +``` + +A plain `Result<_, E>` with **no** error annotations keeps the inferred `400`, +so existing routes are unaffected. + +#### Non-`200` success status (`status = `) + +Use `status = ` to override the inferred `200` success key with any `2xx` +code (a non-`2xx` value is a compile error). No-body statuses (`204`, `304`) +emit a success response with no `content`: + +```rust +// 204 success + 404 error (text/plain) — no 200, no 400. +#[vespera::route(delete, path = "/{id}", status = 204, error_status = [404])] +pub async fn delete_item(Path(id): Path) -> Result { ... } +``` + --- ## `vespera!` Macro Reference @@ -303,10 +402,38 @@ let app = vespera!( { url = "https://api.example.com", description = "Production" }, { url = "http://localhost:3000", description = "Development" } ], + security_schemes = [ // OpenAPI components.securitySchemes + { name = "bearerAuth", type = "http", scheme = "bearer", bearer_format = "JWT" }, + { name = "apiKey", type = "apiKey", in = "header", header_name = "X-API-Key" } + ], + security = ["bearerAuth"], // Optional document-level security + tags = [ // OpenAPI top-level tag descriptions + { name = "users", description = "User operations" }, + { name = "admin", description = "Admin operations" } + ], merge = [crate1::App1, crate2::App2] // Merge child vespera apps ); ``` +## `#[vespera::route]` Macro Reference + +| Parameter | Description | +|-----------|-------------| +| HTTP method | `get`, `post`, `put`, `patch`, `delete`, `head`, or `options` (default: `get`) | +| `path` | Route suffix appended to the file-based path | +| `tags` | OpenAPI operation tags, e.g. `tags = ["users"]` | +| `operation_id` | OpenAPI operationId override, e.g. `operation_id = "getUser"`; defaults to the Rust function name | +| `summary` | OpenAPI operation summary, e.g. `summary = "Get a user"` | +| `description` | OpenAPI operation description; otherwise doc comments are used | +| `status` | Success-response status override (must be `2xx`), e.g. `status = 204`; re-keys the inferred `200` response (no body for `204`/`304`) | +| `error_status` | Extra error status codes to include in OpenAPI responses; declaring any makes the explicit error set authoritative (see below) | +| `responses` | Typed error responses, e.g. `responses = [(404, NotFoundError), (400, crate::errors::BadRequestError)]`; declaring any makes the explicit error set authoritative (see below) | +| `security` | Per-operation security requirements, e.g. `security = ["bearerAuth"]`; `security = []` emits explicit no auth | +| `headers` | Header parameters consumed by custom extractors, e.g. `headers = [{ name = "Authorization", required = true, description = "Bearer token" }]`; `required` defaults to `false` | +| `request_example` | Operation-level request body example as a JSON string; invalid JSON is emitted as a JSON string value | +| `response_example` | Operation-level `200` response example as a JSON string; invalid JSON is emitted as a JSON string value | +| `deprecated` | Bare flag marking the OpenAPI operation as deprecated | + ## `export_app!` Macro Reference Export a vespera app for merging into other apps: @@ -547,8 +674,8 @@ How it works: - `user` on `ArticleResponse` → `UserInArticle` - `category` on `ArticleResponse` → `CategoryInArticle` - It generates local compile adapters so `Option.into()` works unchanged in the handler -- Those adapters stay internal to Rust typing -- OpenAPI does **not** expose the generated adapter wrapper names; the spec still points at the original related schemas (`UserSchema`, `CategorySchema`) +- The internal `__Vespera…Relation` wrapper type stays private to Rust typing +- OpenAPI references the **adapter DTO's own schema** (`UserInArticle`, `CategoryInArticle`) — so the documented response shape matches exactly what the handler serializes, instead of over-promising the base relation schema (`UserSchema`, `CategorySchema`) Use this when you want route-local response DTOs for single-value relations (`HasOne` / `BelongsTo`) without rewriting the route construction logic. diff --git a/apps/landing/next.config.ts b/apps/landing/next.config.ts index 4852158c..4ccc21db 100644 --- a/apps/landing/next.config.ts +++ b/apps/landing/next.config.ts @@ -6,6 +6,9 @@ import type { NextConfig } from 'next' const withMDX = createMDX({ extension: /\.mdx?$/, options: { + // remark-gfm enables GitHub-flavored markdown (pipe tables, strikethrough, + // task lists) — mdx-components.tsx already styles table/th/td elements. + remarkPlugins: ['remark-gfm'], rehypePlugins: ['rehype-slug', 'rehype-pretty-code'], }, }) diff --git a/apps/landing/package.json b/apps/landing/package.json index e05185df..abec4bda 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -13,12 +13,12 @@ "dependencies": { "@devup-api/fetch": "^0.1", "@devup-api/react-query": "^0.1", - "@devup-ui/components": "^0.1.46", + "@devup-ui/components": "^0.1.47", "@devup-ui/react": "^1", "@devup-ui/reset-css": "^1", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", - "@next/mdx": "^16.2.6", + "@next/mdx": "^16.2.9", "clsx": "^2.1.1", "next": "^16", "react": "^19", @@ -26,13 +26,14 @@ "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", - "shiki": "^4.1.0", + "shiki": "^4.2.0", "unified": "^11.0.5" }, "devDependencies": { - "@types/mdx": "^2.0.13", + "@types/mdx": "^2.0.14", "@devup-api/next-plugin": "^0.1", "@devup-ui/next-plugin": "^1", "@types/node": "^25", diff --git a/apps/landing/public/images/rust-code.png b/apps/landing/public/images/rust-code.png new file mode 100644 index 00000000..a5ffa1fe Binary files /dev/null and b/apps/landing/public/images/rust-code.png differ diff --git a/apps/landing/public/search.json b/apps/landing/public/search.json index 4e5e7d7f..2459b973 100644 --- a/apps/landing/public/search.json +++ b/apps/landing/public/search.json @@ -1 +1 @@ -[null,null,null,null,{"text":"## What is Devup UI?eeeeeeeeeeee\r\n\r\n**Devup UI is not just another CSS-in-JS library — it's the future of CSS-in-JS itself.**\r\n\r\nDevup UI is a zero-runtime CSS-in-JS preprocessor powered by Rust and WebAssembly. It transforms all your styles at build time, completely eliminating runtime overhead while providing full CSS-in-JS syntax coverage.\r\n\r\n### The Problem with Traditional CSS-in-JS\r\n\r\nTraditional CSS-in-JS solutions force you to choose between:\r\n\r\n- **Developer Experience**: Intuitive APIs, co-located styles, dynamic theming\r\n- **Performance**: No runtime overhead, fast page loads, optimal Core Web Vitals\r\n\r\nLibraries like styled-components and Emotion offer great DX but execute JavaScript at runtime to generate styles. Zero-runtime alternatives like Vanilla Extract sacrifice some flexibility for performance.\r\n\r\n### The Devup UI Solution\r\n\r\nDevup UI eliminates this trade-off entirely. Our Rust-powered preprocessor analyzes your code at build time and handles every CSS-in-JS pattern:\r\n\r\n- **Variables** — Dynamic values become CSS custom properties\r\n- **Conditionals** — Ternary expressions are statically analyzed\r\n- **Responsive Arrays** — Breakpoint-based styles are pre-generated\r\n- **Pseudo Selectors** — `_hover`, `_focus`, `_active` work seamlessly\r\n- **Themes** — Type-safe theme tokens with zero-cost switching\r\n\r\n### Key Advantages\r\n\r\n\r\n \r\n \r\n Feature\r\n Devup UI\r\n styled-components\r\n Emotion\r\n Vanilla Extract\r\n \r\n \r\n \r\n \r\n Zero Runtime\r\n Yes\r\n No\r\n No\r\n Yes\r\n \r\n \r\n Dynamic Values\r\n Yes\r\n Yes\r\n Yes\r\n Limited\r\n \r\n \r\n Full Syntax Coverage\r\n Yes\r\n Yes\r\n Yes\r\n No\r\n \r\n \r\n Type-Safe Themes\r\n Yes\r\n Limited\r\n Limited\r\n Yes\r\n \r\n \r\n Build Performance\r\n Fastest\r\n N/A\r\n N/A\r\n Fast\r\n \r\n \r\n
\r\n\r\n### How It Works\r\n\r\n```tsx\r\n// You write familiar CSS-in-JS syntax\r\nconst example = \r\n\r\n// Devup UI transforms it at build time\r\nconst generated =
\r\n\r\n// With optimized atomic CSS\r\n// .a { background-color: red; }\r\n// .b { padding: 16px; } /* 4 * 4 = 16px */\r\n// .c:hover { background-color: blue; }\r\n```\r\n\r\n> Numeric values are multiplied by 4. `p={4}` becomes `padding: 16px`.\r\n\r\nClass names use compact base-37 encoding (`a`, `b`, ... `z`, `_`, `aa`, `ab`, ...) for minimal CSS output.\r\n\r\n### Familiar API\r\n\r\nIf you've used styled-components or Emotion, you'll feel right at home:\r\n\r\n```tsx\r\nimport { styled } from '@devup-ui/react'\r\n\r\nconst Card = styled('div', {\r\n bg: 'white',\r\n p: 4, // 4 * 4 = 16px\r\n borderRadius: '8px',\r\n boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',\r\n _hover: {\r\n boxShadow: '0 10px 15px rgba(0, 0, 0, 0.1)',\r\n },\r\n})\r\n```\r\n\r\n### Proven Performance\r\n\r\nBenchmarks on Next.js (GitHub Actions - ubuntu-latest):\r\n\r\n\r\n \r\n \r\n Library\r\n Version\r\n Build Time\r\n Build Size\r\n \r\n \r\n \r\n \r\n tailwindcss\r\n 4.1.13\r\n 19.31s\r\n 59,521,539 bytes\r\n \r\n \r\n styleX\r\n 0.15.4\r\n 41.78s\r\n 86,869,452 bytes\r\n \r\n \r\n vanilla-extract\r\n 1.17.4\r\n 19.50s\r\n 61,494,033 bytes\r\n \r\n \r\n kuma-ui\r\n 1.5.9\r\n 20.93s\r\n 69,924,179 bytes\r\n \r\n \r\n panda-css\r\n 1.3.1\r\n 20.64s\r\n 64,573,260 bytes\r\n \r\n \r\n chakra-ui\r\n 3.27.0\r\n 28.81s\r\n 222,435,802 bytes\r\n \r\n \r\n mui\r\n 7.3.2\r\n 20.86s\r\n 97,964,458 bytes\r\n \r\n \r\n **devup-ui (per-file css)**\r\n **1.0.18**\r\n **16.90s**\r\n 59,540,459 bytes\r\n \r\n \r\n **devup-ui (single css)**\r\n **1.0.18**\r\n **17.05s**\r\n **59,520,196 bytes**\r\n \r\n \r\n tailwindcss (turbopack)\r\n 4.1.13\r\n 6.72s\r\n 5,355,082 bytes\r\n \r\n \r\n **devup-ui (single css + turbopack)**\r\n **1.0.18**\r\n 10.34s\r\n **4,772,050 bytes**\r\n \r\n \r\n
\r\n\r\n### Get Started\r\n\r\nReady to experience the future of CSS-in-JS? Head to the [Installation](/docs/installation) guide to get started in minutes.\r\n","title":"What is Devup UI?eeeeeeeeeeee","url":"/documentation/concept/concept-1"},null,null,null,null,null,{"text":"## What is Devup UI?\r\n\r\n**Devup UI is not just another CSS-in-JS library — it's the future of CSS-in-JS itself.**\r\n\r\nDevup UI is a zero-runtime CSS-in-JS preprocessor powered by Rust and WebAssembly. It transforms all your styles at build time, completely eliminating runtime overhead while providing full CSS-in-JS syntax coverage.\r\n\r\n### The Problem with Traditional CSS-in-JS\r\n\r\nTraditional CSS-in-JS solutions force you to choose between:\r\n\r\n- **Developer Experience**: Intuitive APIs, co-located styles, dynamic theming\r\n- **Performance**: No runtime overhead, fast page loads, optimal Core Web Vitals\r\n\r\nLibraries like styled-components and Emotion offer great DX but execute JavaScript at runtime to generate styles. Zero-runtime alternatives like Vanilla Extract sacrifice some flexibility for performance.\r\n\r\n### The Devup UI Solution\r\n\r\nDevup UI eliminates this trade-off entirely. Our Rust-powered preprocessor analyzes your code at build time and handles every CSS-in-JS pattern:\r\n\r\n- **Variables** — Dynamic values become CSS custom properties\r\n- **Conditionals** — Ternary expressions are statically analyzed\r\n- **Responsive Arrays** — Breakpoint-based styles are pre-generated\r\n- **Pseudo Selectors** — `_hover`, `_focus`, `_active` work seamlessly\r\n- **Themes** — Type-safe theme tokens with zero-cost switching\r\n\r\n### Key Advantages\r\n\r\n\r\n \r\n \r\n Feature\r\n Devup UI\r\n styled-components\r\n Emotion\r\n Vanilla Extract\r\n \r\n \r\n \r\n \r\n Zero Runtime\r\n Yes\r\n No\r\n No\r\n Yes\r\n \r\n \r\n Dynamic Values\r\n Yes\r\n Yes\r\n Yes\r\n Limited\r\n \r\n \r\n Full Syntax Coverage\r\n Yes\r\n Yes\r\n Yes\r\n No\r\n \r\n \r\n Type-Safe Themes\r\n Yes\r\n Limited\r\n Limited\r\n Yes\r\n \r\n \r\n Build Performance\r\n Fastest\r\n N/A\r\n N/A\r\n Fast\r\n \r\n \r\n
\r\n\r\n### How It Works\r\n\r\n```tsx\r\n// You write familiar CSS-in-JS syntax\r\nconst example = \r\n\r\n// Devup UI transforms it at build time\r\nconst generated =
\r\n\r\n// With optimized atomic CSS\r\n// .a { background-color: red; }\r\n// .b { padding: 16px; } /* 4 * 4 = 16px */\r\n// .c:hover { background-color: blue; }\r\n```\r\n\r\n> Numeric values are multiplied by 4. `p={4}` becomes `padding: 16px`.\r\n\r\nClass names use compact base-37 encoding (`a`, `b`, ... `z`, `_`, `aa`, `ab`, ...) for minimal CSS output.\r\n\r\n### Familiar API\r\n\r\nIf you've used styled-components or Emotion, you'll feel right at home:\r\n\r\n```tsx\r\nimport { styled } from '@devup-ui/react'\r\n\r\nconst Card = styled('div', {\r\n bg: 'white',\r\n p: 4, // 4 * 4 = 16px\r\n borderRadius: '8px',\r\n boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',\r\n _hover: {\r\n boxShadow: '0 10px 15px rgba(0, 0, 0, 0.1)',\r\n },\r\n})\r\n```\r\n\r\n### Proven Performance\r\n\r\nBenchmarks on Next.js (GitHub Actions - ubuntu-latest):\r\n\r\n\r\n \r\n \r\n Library\r\n Version\r\n Build Time\r\n Build Size\r\n \r\n \r\n \r\n \r\n tailwindcss\r\n 4.1.13\r\n 19.31s\r\n 59,521,539 bytes\r\n \r\n \r\n styleX\r\n 0.15.4\r\n 41.78s\r\n 86,869,452 bytes\r\n \r\n \r\n vanilla-extract\r\n 1.17.4\r\n 19.50s\r\n 61,494,033 bytes\r\n \r\n \r\n kuma-ui\r\n 1.5.9\r\n 20.93s\r\n 69,924,179 bytes\r\n \r\n \r\n panda-css\r\n 1.3.1\r\n 20.64s\r\n 64,573,260 bytes\r\n \r\n \r\n chakra-ui\r\n 3.27.0\r\n 28.81s\r\n 222,435,802 bytes\r\n \r\n \r\n mui\r\n 7.3.2\r\n 20.86s\r\n 97,964,458 bytes\r\n \r\n \r\n **devup-ui (per-file css)**\r\n **1.0.18**\r\n **16.90s**\r\n 59,540,459 bytes\r\n \r\n \r\n **devup-ui (single css)**\r\n **1.0.18**\r\n **17.05s**\r\n **59,520,196 bytes**\r\n \r\n \r\n tailwindcss (turbopack)\r\n 4.1.13\r\n 6.72s\r\n 5,355,082 bytes\r\n \r\n \r\n **devup-ui (single css + turbopack)**\r\n **1.0.18**\r\n 10.34s\r\n **4,772,050 bytes**\r\n \r\n \r\n
\r\n\r\n### Get Started\r\n\r\nReady to experience the future of CSS-in-JS? Head to the [Installation](/docs/installation) guide to get started in minutes.\r\n","title":"What is Devup UI?","url":"/documentation/overview"},null,null,null,null] \ No newline at end of file +[{"text":"# vespera! Macro\n\nThe `vespera!()` macro is the entry point for every Vespera application. It scans your route folder at compile time, builds an `axum::Router` with all discovered handlers, and optionally writes an OpenAPI 3.1 spec file.\n\n## Full Parameter Reference\n\n```rust\nlet app = vespera!(\n dir = \"routes\", // Route folder (default: \"routes\")\n openapi = \"openapi.json\", // Output path (writes file at compile time)\n title = \"My API\", // OpenAPI info.title\n version = \"1.0.0\", // OpenAPI info.version (default: CARGO_PKG_VERSION)\n docs_url = \"/docs\", // Swagger UI endpoint\n redoc_url = \"/redoc\", // ReDoc endpoint\n servers = [ // OpenAPI servers array\n { url = \"https://api.example.com\", description = \"Production\" },\n { url = \"http://localhost:3000\", description = \"Development\" }\n ],\n merge = [crate1::App1, crate2::App2] // Merge child vespera apps\n);\n```\n\n## Environment Variable Fallbacks\n\nEvery parameter has a corresponding environment variable. The macro parameter takes priority over the env var, which takes priority over the built-in default.\n\n| Parameter | Environment Variable | Default |\n|-----------|---------------------|---------|\n| `dir` | `VESPERA_DIR` | `\"routes\"` |\n| `openapi` | `VESPERA_OPENAPI` | none |\n| `title` | `VESPERA_TITLE` | `\"API\"` |\n| `version` | `VESPERA_VERSION` | `CARGO_PKG_VERSION` |\n| `docs_url` | `VESPERA_DOCS_URL` | none |\n| `redoc_url` | `VESPERA_REDOC_URL` | none |\n| `servers` | `VESPERA_SERVER_URL` + `VESPERA_SERVER_DESCRIPTION` | none |\n\n## Common Patterns\n\n### Minimal — just a router\n\n```rust\nlet app = vespera!();\n```\n\n### With Swagger UI\n\n```rust\nlet app = vespera!(docs_url = \"/docs\");\n```\n\n### Write OpenAPI file + Swagger UI\n\n```rust\nlet app = vespera!(\n openapi = \"openapi.json\",\n docs_url = \"/docs\",\n title = \"My API\",\n version = \"1.0.0\"\n);\n```\n\n### Multiple OpenAPI output files\n\n```rust\nlet app = vespera!(\n openapi = [\"openapi.json\", \"docs/api-spec.json\"]\n);\n```\n\n### Custom route folder\n\n```rust\n// Scans src/api/ instead of src/routes/\nlet app = vespera!(dir = \"api\");\n```\n\n### With state and middleware\n\n```rust\nlet app = vespera!(docs_url = \"/docs\")\n .with_state(AppState { db: pool })\n .layer(CorsLayer::permissive())\n .layer(TraceLayer::new_for_http());\n```\n\n### Merging child apps\n\n```rust\nlet app = vespera!(\n openapi = \"openapi.json\",\n docs_url = \"/docs\",\n merge = [billing::BillingApp, notifications::NotificationsApp]\n)\n.with_state(app_state);\n```\n\n## The `.serve()` Extension\n\n`vespera!()` returns an `axum::Router`. Vespera adds a `.serve(addr)` extension trait that replaces the usual `TcpListener::bind` + `axum::serve(...)` boilerplate:\n\n```rust\nuse vespera::{vespera, Serve};\n\n#[tokio::main]\nasync fn main() -> std::io::Result<()> {\n vespera!(docs_url = \"/docs\")\n .serve(\"0.0.0.0:3000\")\n .await\n}\n```\n\n`addr` accepts anything `tokio::net::ToSocketAddrs` takes — strings like `\"0.0.0.0:3000\"`, tuples like `([0, 0, 0, 0], 3000)`, or a `SocketAddr`.\n\n## export_app! Macro\n\nExport a Vespera app from a library crate so it can be merged into a parent app:\n\n```rust\n// In the child crate's src/lib.rs\nmod routes;\n\n// Scans \"routes\" folder by default\nvespera::export_app!(MyApp);\n\n// Or with a custom directory\nvespera::export_app!(MyApp, dir = \"api\");\n```\n\nThis generates a struct with two associated items:\n- `MyApp::OPENAPI_SPEC: &'static str` — the OpenAPI JSON spec as a static string\n- `MyApp::router() -> Router` — a function returning the Axum router\n\nThe parent app merges it with `merge = [MyApp]` in `vespera!()`.\n","title":"vespera! Macro","url":"/documentation/api/api-1"},{"text":"# Route Attribute & Extractors\n\n`#[vespera::route]` marks a `pub async fn` as an HTTP handler. Vespera reads the function signature to extract path parameters, query parameters, request body, and response types for the OpenAPI spec.\n\n## Route Attribute Parameters\n\n```rust\n#[vespera::route(\n get, // HTTP method (default: get)\n path = \"/{id}\", // Path suffix (appended to file-based prefix)\n tags = [\"users\", \"admin\"], // OpenAPI tags\n description = \"Get user by ID\" // OpenAPI operation description\n)]\npub async fn get_user(Path(id): Path) -> Json { ... }\n```\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| method | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` | `get` | HTTP method |\n| `path` | string | `\"\"` | Path suffix appended to the file-based prefix |\n| `tags` | string array | `[]` | OpenAPI tags for grouping in Swagger UI |\n| `description` | string | `\"\"` | OpenAPI operation description |\n\n## Extractor to OpenAPI Mapping\n\nVespera reads your handler's extractor types and maps them to OpenAPI parameters and request bodies automatically:\n\n\n \n \n Extractor\n OpenAPI Location\n Notes\n \n \n \n \n `Path`\n Path parameters\n `T` can be a primitive or a struct\n \n \n `Query`\n Query parameters\n Struct fields become individual query params\n \n \n `Json`\n Request body (`application/json`)\n \n \n \n `Form`\n Request body (`application/x-www-form-urlencoded`)\n \n \n \n `TypedMultipart`\n Request body (`multipart/form-data`)\n Typed with schema\n \n \n `Multipart`\n Request body (`multipart/form-data`)\n Untyped, generic object\n \n \n `TypedHeader`\n Header parameters\n \n \n \n `State`\n Ignored\n Internal — not part of the API\n \n \n `Extension`\n Ignored\n Internal — not part of the API\n \n \n
\n\n## Examples\n\n### Path Parameters\n\n```rust\n// Single path param\n#[vespera::route(get, path = \"/{id}\")]\npub async fn get_user(Path(id): Path) -> Json { ... }\n\n// Multiple path params via struct\n#[derive(Deserialize)]\npub struct PostParams {\n pub user_id: u32,\n pub post_id: u32,\n}\n\n#[vespera::route(get, path = \"/{user_id}/posts/{post_id}\")]\npub async fn get_post(Path(params): Path) -> Json { ... }\n```\n\n### Query Parameters\n\n```rust\n#[derive(Deserialize, Schema)]\npub struct ListUsersQuery {\n pub page: Option,\n pub limit: Option,\n pub search: Option,\n}\n\n#[vespera::route(get)]\npub async fn list_users(Query(q): Query) -> Json> { ... }\n```\n\n### JSON Body\n\n```rust\n#[derive(Deserialize, Schema)]\npub struct CreateUserRequest {\n pub name: String,\n pub email: String,\n}\n\n#[vespera::route(post)]\npub async fn create_user(Json(req): Json) -> Json { ... }\n```\n\n### Validated Body (with 422)\n\n```rust\nuse vespera::Validated;\nuse garde::Validate;\n\n#[derive(Deserialize, Schema, Validate)]\npub struct CreateUserRequest {\n #[garde(length(min = 3, max = 32))]\n pub username: String,\n #[garde(email)]\n pub email: String,\n}\n\n#[vespera::route(post)]\npub async fn create_user(\n Validated(Json(req)): Validated>,\n) -> Json { ... }\n```\n\n### State (Ignored by OpenAPI)\n\n```rust\n#[vespera::route(get)]\npub async fn list_users(\n State(db): State, // ignored by OpenAPI\n Query(q): Query, // included in OpenAPI\n) -> Json> { ... }\n```\n\n### Error Responses\n\n```rust\n#[derive(Serialize, Schema)]\npub struct ApiError {\n pub message: String,\n}\n\n#[vespera::route(get, path = \"/{id}\")]\npub async fn get_user(\n Path(id): Path,\n) -> Result, (StatusCode, Json)> {\n if id == 0 {\n return Err((\n StatusCode::NOT_FOUND,\n Json(ApiError { message: \"Not found\".into() }),\n ));\n }\n Ok(Json(User { id, name: \"Alice\".into() }))\n}\n```\n\n## Handler Requirements\n\n- Must be `pub async fn` — private or non-async functions are ignored\n- Must have `#[vespera::route]` attribute\n- Can live anywhere in `src/routes/` (or your configured `dir`)\n- The URL is: **file path prefix + `path` attribute value**\n","title":"Route Attribute & Extractors","url":"/documentation/api/api-2"},{"text":"# schema_type!, schema!, and export_app!\n\n## schema_type! Macro\n\nGenerate request/response types from existing structs. Perfect for creating API DTOs from database models without duplicating field definitions.\n\n### Basic Usage\n\n```rust\nuse vespera::schema_type;\n\n// Include only specific fields\nschema_type!(CreateUserRequest from crate::models::user::Model, pick = [\"name\", \"email\"]);\n\n// Exclude specific fields\nschema_type!(UserResponse from crate::models::user::Model, omit = [\"password_hash\"]);\n\n// Add new fields (disables auto From impl)\nschema_type!(UpdateUserRequest from crate::models::user::Model, pick = [\"name\"], add = [(\"id\": i32)]);\n```\n\n### Auto-Generated From Impl\n\nWhen `add` is NOT used, a `From` impl is generated automatically:\n\n```rust\nschema_type!(UserResponse from crate::models::user::Model, omit = [\"password_hash\"]);\n\n// Use it directly:\nlet model: Model = db.find_user(id).await?;\nJson(model.into()) // From impl handles the conversion\n```\n\n### Same-File Model Reference\n\nWhen the model is in the same file, use a simple name with the `name` parameter:\n\n```rust\n// In src/models/user.rs\npub struct Model {\n pub id: i32,\n pub name: String,\n pub email: String,\n}\n\nvespera::schema_type!(Schema from Model, name = \"UserSchema\");\n```\n\n### Cross-File References\n\nReference structs from other files using full module paths:\n\n```rust\n// In src/routes/users.rs\nschema_type!(UserResponse from crate::models::user::Model, omit = [\"password_hash\"]);\n```\n\n### Partial Updates (PATCH)\n\n```rust\n// All fields become Option\nschema_type!(UserPatch from User, partial);\n\n// Only specific fields become Option\nschema_type!(UserPatch from User, partial = [\"name\", \"email\"]);\n```\n\n### Omit Database Defaults\n\n`omit_default` automatically omits fields with `#[sea_orm(primary_key)]` or `#[sea_orm(default_value = \"...\")]` — perfect for create DTOs:\n\n```rust\n#[derive(DeriveEntityModel)]\n#[sea_orm(table_name = \"posts\")]\npub struct Model {\n #[sea_orm(primary_key)] // omitted\n pub id: i32,\n pub title: String,\n pub content: String,\n #[sea_orm(default_value = \"NOW()\")] // omitted\n pub created_at: DateTimeWithTimeZone,\n}\n\n// Generated struct only has: title, content\nschema_type!(CreatePostRequest from crate::models::post::Model, omit_default);\n\n// Combine with add\nschema_type!(CreateItemRequest from Model, omit_default, add = [(\"tags\": Vec)]);\n```\n\n### Multipart Mode\n\nGenerate `Multipart` structs from existing types:\n\n```rust\n#[derive(vespera::Multipart, vespera::Schema)]\npub struct CreateUploadRequest {\n pub name: String,\n #[form_data(limit = \"10MiB\")]\n pub file: Option>,\n pub description: Option,\n}\n\n// Generates a Multipart struct (no serde derives), all fields Optional\nschema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = [\"file\"]);\n```\n\nWhen `multipart` is enabled:\n- Derives `Multipart` instead of `Serialize`/`Deserialize`\n- Preserves `#[form_data(...)]` attributes from the source struct\n- Skips SeaORM relation fields\n- Does not generate a `From` impl\n\n### Same-File Relation Adapters\n\nWhen a route file defines local response DTOs for SeaORM relations, `schema_type!` generates compile adapters so existing handler code stays valid:\n\n```rust\n#[derive(Serialize, vespera::Schema)]\n#[serde(rename_all = \"camelCase\")]\npub struct UserInArticle {\n pub id: Uuid,\n pub name: String,\n pub email: String,\n}\n\nschema_type!(\n ArticleResponse from crate::models::article::Model,\n add = [(\"review_users\": Vec)]\n);\n\n// Handler code unchanged:\nOk(ArticleResponse {\n user: user.into(), // adapter generated automatically\n review_users,\n ..\n})\n```\n\nThe naming convention is `{RelationNamePascal}In{ResponseBase}` — `user` on `ArticleResponse` → `UserInArticle`.\n\n### All Parameters\n\n| Parameter | Description |\n|-----------|-------------|\n| `pick` | Include only specified fields |\n| `omit` | Exclude specified fields |\n| `rename` | Rename fields: `rename = [(\"old\", \"new\")]` |\n| `add` | Add new fields (disables auto `From` impl) |\n| `clone` | Control Clone derive (default: `true`) |\n| `partial` | Make fields optional: `partial` or `partial = [\"field1\"]` |\n| `name` | Custom OpenAPI schema name (same-file references only) |\n| `rename_all` | Serde rename strategy: `rename_all = \"camelCase\"` |\n| `ignore` | Skip Schema derive (bare keyword) |\n| `multipart` | Derive `Multipart` instead of serde (bare keyword) |\n| `omit_default` | Auto-omit fields with DB defaults (bare keyword) |\n\n---\n\n## schema! Macro\n\nGet a `Schema` value at runtime with optional field filtering. Useful for programmatic schema access without generating a new struct type.\n\n```rust\nuse vespera::{Schema, schema};\n\n#[derive(Schema)]\npub struct User {\n pub id: i32,\n pub name: String,\n pub password: String,\n}\n\n// Full schema\nlet full: vespera::schema::Schema = schema!(User);\n\n// With fields omitted\nlet safe: vespera::schema::Schema = schema!(User, omit = [\"password\"]);\n\n// With only specified fields\nlet summary: vespera::schema::Schema = schema!(User, pick = [\"id\", \"name\"]);\n```\n\n> For creating request/response types with `From` impls, use `schema_type!` instead.\n\n---\n\n## export_app! Macro\n\nExport a Vespera app from a library crate for merging into a parent app. See [vespera! Macro](/documentation/api/api-1) for the merge usage.\n\n```rust\n// In the child crate's src/lib.rs\nmod routes;\n\n// Scans \"routes\" folder by default\nvespera::export_app!(MyApp);\n\n// Or with a custom directory\nvespera::export_app!(MyApp, dir = \"api\");\n```\n\nGenerates:\n- `MyApp::OPENAPI_SPEC: &'static str` — the OpenAPI JSON spec\n- `MyApp::router() -> Router` — the Axum router\n","title":"schema_type!, schema!, and export_app!","url":"/documentation/api/api-3"},{"text":"# API Reference\n\nComplete reference for Vespera's macros and attributes.\n\n## vespera! Macro\n\nThe entry point for every Vespera application. Scans your route folder at compile time, builds an `axum::Router`, and optionally writes an OpenAPI spec file.\n\nSee [vespera! Macro](/documentation/api/api-1) for the full parameter reference.\n\n## Route Attribute & Extractors\n\n`#[vespera::route]` marks a `pub async fn` as an HTTP handler. Vespera reads the function signature to extract path parameters, query parameters, request body, and response types for the OpenAPI spec.\n\nSee [Route Attribute & Extractors](/documentation/api/api-2) for all options and extractor mappings.\n\n## schema_type!, schema!, and export_app!\n\n- `schema_type!` — derive request/response DTOs from existing structs with `pick`, `omit`, `partial`, `add`, and SeaORM relation support\n- `schema!` — get a `Schema` value at runtime with optional field filtering\n- `export_app!` — export a Vespera app for merging into a parent app\n\nSee [schema_type! & More](/documentation/api/api-3) for the full reference.\n","title":"API Reference","url":"/documentation/api"},{"text":"# File-Based Routing\n\nVespera maps your `src/routes/` folder structure directly to URL paths. The `vespera!()` macro scans the folder at compile time — no manual `Router::new().route(...)` calls needed.\n\n## Folder to URL Mapping\n\n```\nsrc/routes/\n├── mod.rs → /\n├── users.rs → /users\n├── posts.rs → /posts\n└── admin/\n ├── mod.rs → /admin\n └── stats.rs → /admin/stats\n```\n\nThe final URL for a handler is: **file path prefix + `#[route]` path attribute**.\n\n```rust\n// In src/routes/users.rs\n#[vespera::route(get, path = \"/{id}\")]\npub async fn get_user(...) // → GET /users/{id}\n```\n\n## Handler Requirements\n\nHandlers must be `pub async fn`. Private or non-async functions are silently ignored by the scanner.\n\n```rust\n// Ignored — private\nasync fn get_users() -> Json> { ... }\n\n// Ignored — not async\npub fn get_users() -> Json> { ... }\n\n// Discovered\npub async fn get_users() -> Json> { ... }\n```\n\n## Route Attribute\n\n```rust\n// GET /users (default method is GET)\n#[vespera::route]\npub async fn list_users() -> Json> { ... }\n\n// POST /users\n#[vespera::route(post)]\npub async fn create_user(Json(user): Json) -> Json { ... }\n\n// GET /users/{id}\n#[vespera::route(get, path = \"/{id}\")]\npub async fn get_user(Path(id): Path) -> Json { ... }\n\n// PUT /users/{id} with tags and description\n#[vespera::route(put, path = \"/{id}\", tags = [\"users\"], description = \"Update user\")]\npub async fn update_user(...) -> ... { ... }\n```\n\n### Attribute Parameters\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| method | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` | HTTP method (default: `get`) |\n| `path` | string | Path suffix appended to the file-based prefix |\n| `tags` | string array | OpenAPI tags for grouping in Swagger UI |\n| `description` | string | OpenAPI operation description |\n\n## Custom Route Folder\n\nThe default folder is `src/routes/`. Change it with the `dir` parameter or the `VESPERA_DIR` environment variable:\n\n```rust\n// Scans src/api/ instead of src/routes/\nlet app = vespera!(dir = \"api\");\n```\n\n## Error Handling\n\nReturn `Result` from handlers. Both `T` and `E` are included in the OpenAPI response schemas:\n\n```rust\n#[derive(Serialize, Schema)]\npub struct ApiError {\n pub message: String,\n}\n\n#[vespera::route(get, path = \"/{id}\")]\npub async fn get_user(\n Path(id): Path,\n) -> Result, (StatusCode, Json)> {\n if id == 0 {\n return Err((\n StatusCode::NOT_FOUND,\n Json(ApiError { message: \"Not found\".into() }),\n ));\n }\n Ok(Json(User { id, name: \"Alice\".into() }))\n}\n```\n","title":"File-Based Routing","url":"/documentation/concept/concept-1"},{"text":"# Schema & OpenAPI Generation\n\nVespera generates a complete OpenAPI 3.1 spec from your Rust types at compile time. Derive `Schema` on any type used in a handler's input or output and it appears in the spec automatically.\n\n## Deriving Schema\n\n```rust\n#[derive(Serialize, Deserialize, vespera::Schema)]\npub struct User {\n pub id: u32,\n pub name: String,\n pub email: String,\n pub bio: Option, // optional — not in `required` array\n}\n```\n\nVespera respects all standard serde attributes:\n\n```rust\n#[derive(Serialize, Deserialize, vespera::Schema)]\n#[serde(rename_all = \"camelCase\")]\npub struct CreateUserRequest {\n pub user_name: String, // → \"userName\" in OpenAPI\n pub email: String,\n\n #[serde(rename = \"fullName\")]\n pub name: String, // → \"fullName\" in OpenAPI\n\n #[serde(skip)]\n pub internal_id: u64, // excluded from schema\n\n pub bio: Option, // optional field\n}\n```\n\n## Type Mapping\n\n\n \n \n Rust Type\n OpenAPI Schema\n \n \n \n \n `String`, `&str`\n `string`\n \n \n `i8`–`i128`, `u8`–`u128`\n `integer`\n \n \n `f32`, `f64`\n `number`\n \n \n `bool`\n `boolean`\n \n \n `Vec`\n `array` with items\n \n \n `Option`\n T (parent marks field as optional)\n \n \n `HashMap`\n `object` with `additionalProperties`\n \n \n `BTreeSet`, `HashSet`\n `array` with `uniqueItems: true`\n \n \n `Uuid`\n `string` with `format: uuid`\n \n \n `Decimal`\n `string` with `format: decimal`\n \n \n `NaiveDate`\n `string` with `format: date`\n \n \n `NaiveTime`\n `string` with `format: time`\n \n \n `DateTime`, `DateTimeWithTimeZone`\n `string` with `format: date-time`\n \n \n `FieldData`\n `string` with `format: binary`\n \n \n `()`\n empty response (204 No Content)\n \n \n Custom struct\n `$ref` to `components/schemas`\n \n \n
\n\n## Generic Types\n\nAll type parameters must also derive `Schema`:\n\n```rust\n#[derive(Schema)]\nstruct Paginated {\n items: Vec,\n total: u32,\n page: u32,\n}\n```\n\n## SeaORM Integration\n\n`schema_type!` has first-class support for SeaORM models. Relation fields are converted automatically:\n\n```rust\n#[derive(Clone, Debug, DeriveEntityModel)]\n#[sea_orm(table_name = \"memos\")]\npub struct Model {\n #[sea_orm(primary_key)]\n pub id: i32,\n pub title: String,\n pub user_id: i32,\n pub user: BelongsTo, // → Option>\n pub comments: HasMany, // → Vec\n pub created_at: DateTimeWithTimeZone, // → chrono::DateTime\n}\n\nvespera::schema_type!(Schema from Model, name = \"MemoSchema\");\n```\n\n\n \n \n SeaORM Type\n Generated Schema Type\n \n \n \n \n `HasOne`\n `Box` or `Option>`\n \n \n `BelongsTo`\n `Option>`\n \n \n `HasMany`\n `Vec`\n \n \n `DateTimeWithTimeZone`\n `chrono::DateTime`\n \n \n
\n\nCircular references (e.g. User ↔ Memo) are detected automatically and handled by inlining fields to prevent infinite recursion.\n\n## Database Defaults in OpenAPI\n\nFields with SeaORM database defaults get `default` values in the generated schema:\n\n| SeaORM Attribute | OpenAPI Default |\n|-----------------|-----------------|\n| `primary_key` (Uuid) | `\"00000000-0000-0000-0000-000000000000\"` |\n| `primary_key` (i32/i64) | `0` |\n| `default_value = \"NOW()\"` | `\"1970-01-01T00:00:00+00:00\"` |\n| `default_value = \"gen_random_uuid()\"` | `\"00000000-0000-0000-0000-000000000000\"` |\n| `default_value = \"true\"` | `true` |\n\n> `required` is determined solely by nullability (`Option`). Fields with defaults are still `required` unless they are `Option`.\n\n## Configuring the OpenAPI Output\n\nPass parameters to `vespera!()` to control the spec:\n\n```rust\nlet app = vespera!(\n openapi = \"openapi.json\", // write spec to this file at compile time\n title = \"My API\",\n version = \"1.0.0\",\n docs_url = \"/docs\", // Swagger UI\n redoc_url = \"/redoc\", // ReDoc\n servers = [\n { url = \"https://api.example.com\", description = \"Production\" },\n { url = \"http://localhost:3000\", description = \"Development\" }\n ]\n);\n```\n\nSee [vespera! Macro](/documentation/api/api-1) for the full parameter reference.\n","title":"Schema & OpenAPI Generation","url":"/documentation/concept/concept-2"},{"text":"# `Validated` and 422\n\n`Validated` is a Vespera extractor wrapper that runs [`garde`](https://crates.io/crates/garde) validation **before** your handler is called. Invalid requests are rejected with `422 Unprocessable Entity` and a canonical JSON error envelope — no per-handler error mapping, no boilerplate.\n\n## Basic Usage\n\nAdd `garde` to your dependencies:\n\n```toml\n[dependencies]\nvespera = \"0.1\"\ngarde = { version = \"0.20\", features = [\"derive\"] }\n```\n\nAnnotate your request type with `garde` constraints and derive `Validate`:\n\n```rust\nuse vespera::{Validated, Schema, axum::Json};\nuse garde::Validate;\n\n#[derive(serde::Deserialize, Schema, Validate)]\npub struct CreateUser {\n #[garde(length(min = 3, max = 32))]\n pub username: String,\n #[garde(email)]\n pub email: String,\n #[garde(range(min = 18, max = 120))]\n pub age: u8,\n}\n\n#[vespera::route(post, tags = [\"users\"])]\npub async fn create_user(\n Validated(Json(req)): Validated>,\n) -> Json<&'static str> {\n // `req` has already passed garde validation — no manual checks needed.\n Json(\"ok\")\n}\n```\n\n## 422 Response Envelope\n\nWhen validation fails, Vespera returns `HTTP 422 Unprocessable Entity` with this JSON body:\n\n```json\n{\n \"errors\": [\n { \"path\": \"username\", \"message\": \"length is lower than 3\" },\n { \"path\": \"email\", \"message\": \"not a valid email\" }\n ]\n}\n```\n\nThe envelope is identical regardless of which extractor failed — your API clients only need to handle one error shape.\n\n## Supported Extractors\n\n`Validated` works with every common Axum extractor:\n\n\n \n \n Extractor\n Validates\n \n \n \n \n `Validated>`\n JSON request body\n \n \n `Validated>`\n URL-encoded form body\n \n \n `Validated>`\n URL query parameters\n \n \n `Validated>`\n Path parameters\n \n \n
\n\n## JNI Hoisting\n\nUnder JNI, the same `422` body is **hoisted** into the binary wire header as `\"validation_errors\": [...]`. Java decoders can read validation errors directly from the header without parsing the response body — no special-casing needed on the Java side.\n\n```json\n{\n \"v\": 1,\n \"status\": 422,\n \"headers\": { \"content-type\": \"application/json\" },\n \"validation_errors\": [\n { \"path\": \"username\", \"message\": \"length is lower than 3\" }\n ]\n}\n```\n\n## Common garde Constraints\n\n```rust\n#[derive(Deserialize, Schema, Validate)]\npub struct UpdateProfile {\n #[garde(length(min = 1, max = 100))]\n pub display_name: String,\n\n #[garde(url)]\n pub website: Option,\n\n #[garde(length(min = 8))]\n pub password: String,\n\n #[garde(range(min = 0.0, max = 5.0))]\n pub rating: f64,\n\n #[garde(inner(length(min = 1)))]\n pub tags: Vec,\n}\n```\n\nSee the [garde documentation](https://docs.rs/garde) for the full list of available constraints.\n","title":"`Validated` and 422","url":"/documentation/concept/concept-3"},{"text":"# Core Concepts\n\nVespera is built on three ideas: file-based routing, compile-time schema extraction, and automatic request validation.\n\n## File-Based Routing\n\nYour folder structure becomes your URL structure. Drop a `pub async fn` with `#[vespera::route]` anywhere in `src/routes/` and Vespera discovers it at compile time — no manual router registration.\n\n```\nsrc/routes/\n├── mod.rs → /\n├── users.rs → /users\n├── posts.rs → /posts\n└── admin/\n ├── mod.rs → /admin\n └── stats.rs → /admin/stats\n```\n\nSee [File-Based Routing](/documentation/concept/concept-1) for the full rules.\n\n## Schema & OpenAPI Generation\n\nDerive `Schema` on any Rust type and Vespera includes it in the generated OpenAPI 3.1 spec. Serde attributes (`rename_all`, `rename`, `skip`, `default`) are respected automatically.\n\n```rust\n#[derive(Serialize, Deserialize, vespera::Schema)]\n#[serde(rename_all = \"camelCase\")]\npub struct CreateUserRequest {\n pub user_name: String, // → \"userName\" in OpenAPI\n pub email: String,\n pub bio: Option, // optional field\n}\n```\n\nSee [Schema & OpenAPI](/documentation/concept/concept-2) for type mapping and SeaORM integration.\n\n## `Validated` and 422\n\nWrap any extractor in `Validated` to run `garde` validation before the handler runs. Invalid requests are rejected with `422 Unprocessable Entity` and a canonical JSON error envelope — no per-handler error mapping needed.\n\n```rust\n#[vespera::route(post)]\npub async fn create_user(\n Validated(Json(req)): Validated>,\n) -> Json<&'static str> {\n Json(\"ok\")\n}\n```\n\nSee [Validated & 422](/documentation/concept/concept-3) for the full contract.\n","title":"Core Concepts","url":"/documentation/concept"},{"text":"# Features\n\nBeyond routing and OpenAPI generation, Vespera ships several production-ready features that integrate with the same compile-time discovery system.\n\n## Cron Jobs\n\nSchedule background tasks with `#[vespera::cron]`. Jobs are auto-discovered like routes — no extra registration needed.\n\n### Enable the Feature\n\n```toml\n[dependencies]\nvespera = { version = \"0.1\", features = [\"cron\"] }\n```\n\n### Define Jobs\n\nPlace `#[vespera::cron(\"...\")]` on any `pub async fn` with zero parameters. The function can live anywhere in your project:\n\n```rust\n// src/cron/cleanup.rs, src/tasks.rs, or even src/routes/users.rs — anywhere works\n#[vespera::cron(\"1/10 * * * * *\")]\npub async fn cleanup_sessions() {\n println!(\"Running cleanup every 10 seconds\");\n}\n\n#[vespera::cron(\"0 0 * * * *\")]\npub async fn hourly_report() {\n println!(\"Running hourly report\");\n}\n```\n\nNo extra config in `vespera!()` — jobs are discovered and started automatically:\n\n```rust\nlet app = vespera!(docs_url = \"/docs\");\n// Background scheduler starts when the app starts\n```\n\n### Cron Expression Format\n\nUses 6-field cron expressions (`sec min hour day month weekday`):\n\n| Expression | Schedule |\n|-----------|----------|\n| `0 */5 * * * *` | Every 5 minutes |\n| `0 0 * * * *` | Every hour |\n| `0 0 0 * * *` | Daily at midnight |\n| `1/10 * * * * *` | Every 10 seconds |\n| `0 30 9 * * Mon-Fri` | Weekdays at 9:30 AM |\n\n### Requirements\n\n- Functions must be `pub async fn`\n- Functions must take **no parameters** (no `State`, no extractors)\n- The `cron` feature must be enabled in `Cargo.toml`\n\n---\n\n## Multipart Form Data\n\n### Typed Multipart (Recommended)\n\nUse `TypedMultipart` for file uploads with a statically-known schema. Vespera generates `multipart/form-data` content type in OpenAPI and maps `FieldData` to `{ \"type\": \"string\", \"format\": \"binary\" }`:\n\n```rust\nuse vespera::multipart::{FieldData, TypedMultipart};\nuse vespera::{Multipart, Schema};\nuse tempfile::NamedTempFile;\n\n#[derive(Multipart, Schema)]\npub struct CreateUploadRequest {\n pub name: String,\n #[form_data(limit = \"10MiB\")]\n pub file: Option>,\n}\n\n#[vespera::route(post, tags = [\"uploads\"])]\npub async fn create_upload(\n TypedMultipart(req): TypedMultipart,\n) -> Json { ... }\n```\n\n### Raw Multipart (Untyped)\n\nFor dynamic fields not known at compile time, use Axum's built-in `Multipart` extractor. Vespera generates a generic `{ \"type\": \"object\" }` schema:\n\n```rust\nuse vespera::axum::extract::Multipart;\n\n#[vespera::route(post, tags = [\"uploads\"])]\npub async fn upload(mut multipart: Multipart) -> Json {\n while let Some(field) = multipart.next_field().await.unwrap() {\n let name = field.name().unwrap_or(\"unknown\").to_string();\n let data = field.bytes().await.unwrap();\n // Process each field dynamically...\n }\n Json(UploadResponse { success: true })\n}\n```\n\n---\n\n## Merging Multiple Vespera Apps\n\nCombine routes and OpenAPI specs from multiple crates at compile time. Useful for splitting a large API into separate crates while presenting a single unified spec.\n\n### Export a Child App\n\n```rust\n// In the child crate's src/lib.rs\nmod routes;\n\n// Export for merging (scans \"routes\" folder by default)\nvespera::export_app!(ThirdApp);\n\n// Or with a custom directory\nvespera::export_app!(ThirdApp, dir = \"api\");\n```\n\nThis generates:\n- `ThirdApp::OPENAPI_SPEC: &'static str` — the child's OpenAPI JSON\n- `ThirdApp::router() -> Router` — the child's Axum router\n\n### Merge in the Parent App\n\n```rust\nuse vespera::vespera;\n\nlet app = vespera!(\n openapi = \"openapi.json\",\n docs_url = \"/docs\",\n merge = [third::ThirdApp, other::OtherApp]\n)\n.with_state(app_state);\n```\n\nVespera automatically:\n- Merges all child routes into the parent router\n- Combines OpenAPI specs (paths, schemas, tags) into a single document\n- Makes Swagger UI show all routes from all apps\n\n---\n\n## Multi-App Routing (JNI)\n\nWhen embedding Vespera in a Java/Spring application via JNI, you can register multiple independent apps and route between them per request.\n\n```rust\npub fn create_app() -> axum::Router { vespera!(title = \"Default\") }\npub fn admin_app() -> axum::Router { vespera!(dir = \"admin_routes\", title = \"Admin\") }\npub fn public_app() -> axum::Router { vespera!(dir = \"public_routes\", title = \"Public\") }\n\nvespera::jni_apps! {\n \"_default\" => create_app,\n \"admin\" => admin_app,\n \"public\" => public_app,\n}\n```\n\nThe Java side selects an app per request via the `X-Vespera-App` header (configurable):\n\n```bash\n# Default app (no header)\ncurl http://localhost:8080/health\n\n# Admin app\ncurl -H \"X-Vespera-App: admin\" http://localhost:8080/dashboard\n```\n\nSee [Streaming & Multi-App](/documentation/theme/theme-3) for the full multi-app routing reference.\n","title":"Features","url":"/documentation/features"},{"text":"# Installation\n\nGet Vespera running in your Axum project in under five minutes.\n\n## 1. Add Dependencies\n\n```toml\n[dependencies]\nvespera = \"0.1\"\naxum = \"0.8\"\ntokio = { version = \"1\", features = [\"full\"] }\nserde = { version = \"1\", features = [\"derive\"] }\n```\n\n> Vespera re-exports `axum` — use `vespera::axum` in your code instead of depending on `axum` directly. This keeps the version in sync automatically.\n\n## 2. Create Your First Route\n\nCreate the routes folder and add a handler:\n\n```\nsrc/\n├── main.rs\n└── routes/\n └── users.rs\n```\n\n**`src/routes/users.rs`**:\n\n```rust\nuse vespera::axum::{Json, extract::Path};\nuse serde::{Deserialize, Serialize};\nuse vespera::Schema;\n\n#[derive(Serialize, Deserialize, Schema)]\npub struct User {\n pub id: u32,\n pub name: String,\n}\n\n/// Get user by ID\n#[vespera::route(get, path = \"/{id}\", tags = [\"users\"])]\npub async fn get_user(Path(id): Path) -> Json {\n Json(User { id, name: \"Alice\".into() })\n}\n\n/// Create a new user\n#[vespera::route(post, tags = [\"users\"])]\npub async fn create_user(Json(user): Json) -> Json {\n Json(user)\n}\n```\n\n## 3. Set Up `main.rs`\n\n```rust\nuse vespera::{vespera, Serve};\n\n#[tokio::main]\nasync fn main() -> std::io::Result<()> {\n println!(\"Swagger UI: http://localhost:3000/docs\");\n vespera!(\n openapi = \"openapi.json\",\n title = \"My API\",\n docs_url = \"/docs\"\n )\n .serve(\"0.0.0.0:3000\")\n .await\n}\n```\n\n`.serve(addr)` is a Vespera extension trait on `axum::Router`. It replaces the usual `TcpListener::bind` + `axum::serve(...)` dance with a single chained call. `addr` accepts anything `tokio::net::ToSocketAddrs` takes — strings, tuples, or `SocketAddr`.\n\n## 4. Run\n\n```bash\ncargo run\n# Open http://localhost:3000/docs\n```\n\nYour Swagger UI is live. The `openapi.json` file is written to the project root at compile time.\n\n## Adding State and Middleware\n\nChain standard Axum methods after `vespera!()`:\n\n```rust\nlet app = vespera!(docs_url = \"/docs\")\n .with_state(AppState { db: pool })\n .layer(CorsLayer::permissive())\n .layer(TraceLayer::new_for_http());\n```\n\n## JNI / Java Integration\n\nTo embed Vespera inside a Java/Spring application, enable the `jni` feature:\n\n```toml\n[dependencies]\nvespera = { version = \"0.1\", features = [\"jni\"] }\n```\n\nThen add two lines to your Rust lib:\n\n```rust\npub fn create_app() -> vespera::axum::Router {\n vespera!(title = \"My API\")\n}\n\nvespera::jni_app!(create_app);\n```\n\nSee the [JNI / Java Integration](/documentation/theme) section for the full setup guide.\n\n## Cron Jobs\n\nEnable the `cron` feature to schedule background tasks:\n\n```toml\n[dependencies]\nvespera = { version = \"0.1\", features = [\"cron\"] }\n```\n\nSee [Features](/documentation/features) for usage details.\n","title":"Installation","url":"/documentation/installation"},{"text":"# What is Vespera?\n\n**FastAPI-like developer experience for Rust.** Zero-config OpenAPI 3.1 generation for Axum.\n\n```rust\n// That's it. Swagger UI at /docs, OpenAPI at openapi.json\nlet app = vespera!(openapi = \"openapi.json\", docs_url = \"/docs\");\n```\n\nVespera scans your `src/routes/` folder at compile time, extracts every `#[vespera::route]` handler and `#[derive(Schema)]` type, and assembles a complete OpenAPI 3.1 spec — no annotations to maintain, no runtime reflection, no hand-written JSON.\n\n## Why Vespera?\n\n\n \n \n Feature\n Vespera\n Manual Approach\n \n \n \n \n Route registration\n Automatic (file-based)\n Manual `Router::new().route(...)`\n \n \n OpenAPI spec\n Generated at compile time\n Hand-written or runtime generation\n \n \n Schema extraction\n `#[derive(Schema)]` on Rust types\n Manual JSON Schema\n \n \n Request validation\n `Validated` extractor → auto `422`\n Manual checks in every handler\n \n \n Server startup\n `.serve(\"0.0.0.0:3000\")` one-liner\n `TcpListener::bind` + `axum::serve`\n \n \n Swagger UI\n Built-in\n Separate setup\n \n \n Type safety\n Compile-time verified\n Runtime errors\n \n \n
\n\n## Headline Capabilities\n\n\n \n \n Capability\n How\n \n \n \n \n `#[derive(Schema)]` → OpenAPI 3.1\n Rust types become JSON Schema at compile time, including serde renames, `Option`, `Vec`, SeaORM relations\n \n \n `Validated` extractor + auto-`422`\n Wraps `Json`/`Form`/`Query`/`Path` and runs `garde::Validate` before the handler — rejection is `422` with a canonical JSON envelope\n \n \n `schema_type! { ... }`\n Derive request/response DTOs from existing structs (`pick` / `omit` / `partial` / `add` / `multipart` / `omit_default`) with first-class SeaORM relation support\n \n \n One-liner `.serve(addr)`\n Extension trait on `axum::Router` — replaces `TcpListener::bind` + `axum::serve` boilerplate\n \n \n JNI / Spring integration\n Embed your Axum router inside a Java/Spring app in-process — no TCP, no base64, raw bytes end to end\n \n \n Cron jobs\n `#[vespera::cron(\"...\")]` — auto-discovered like routes, runs via `tokio-cron-scheduler`\n \n \n
\n\n## JNI Performance Numbers\n\nWhen embedding Vespera inside a Java/Spring application via JNI, the `SmartDispatchModeResolver` (default since vespera-bridge 0.2.0) picks the cheapest safe path per request. Measured on a `GET /health` round-trip through the real JNI boundary (AMD Ryzen 9 9950X, Java 21, Windows 11):\n\n\n \n \n Request shape\n Mode\n ns / round-trip\n \n \n \n \n Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB)\n `DIRECT` (pooled direct buffers)\n ~2,200 ns\n \n \n Small (≤ 256 KiB) + non-idempotent (POST/PATCH)\n `SYNC` (heap-buffered)\n ~3,200 ns\n \n \n Large or unknown-length body\n `BIDIRECTIONAL_STREAMING`\n ~24,100 ns\n \n \n
\n\nBinary streaming throughput (64 MiB payload, bidirectional):\n\n\n \n \n Chunk size\n Throughput\n \n \n \n \n 16 KiB\n ~10,408 MiB/s\n \n \n 64 KiB\n ~11,587 MiB/s\n \n \n 256 KiB\n ~14,458 MiB/s\n \n \n
\n\nThe `direct_pooled` path completes a tiny `/health` round-trip in **2,349 ns/op** — **1.55× faster** than the pre-0.2.0 sync baseline (3,643 ns/op).\n\n## How It Works\n\n```\nsrc/routes/\n├── mod.rs → /\n├── users.rs → /users\n└── admin/\n └── stats.rs → /admin/stats\n```\n\n1. You place `pub async fn` handlers in `src/routes/` and annotate them with `#[vespera::route]`.\n2. The `vespera!()` macro scans the folder at compile time, discovers every handler, and builds an `axum::Router`.\n3. Types annotated with `#[derive(Schema)]` are extracted into OpenAPI component schemas automatically.\n4. The generated `openapi.json` and Swagger UI are served at the URLs you configure.\n\n## Get Started\n\nHead to [Installation](/documentation/installation) to add Vespera to your project in under five minutes.\n","title":"What is Vespera?","url":"/documentation/overview"},{"text":"# JNI / Java Integration\n\nVespera can embed your Axum router directly inside a Java/Spring application — no TCP socket, no JSON envelope overhead, raw bytes from end to end.\n\nThe `vespera-bridge` library (`kr.devfive:vespera-bridge`) provides a Spring Boot autoconfiguration that wires up a catch-all `VesperaProxyController`. Every HTTP request Spring receives is forwarded to Rust through a length-prefixed binary wire format, and the response comes back the same way.\n\n## Why In-Process?\n\nA traditional microservice setup adds a full HTTP round-trip between Java and Rust. In-process JNI dispatch eliminates that entirely:\n\n- No TCP connection overhead\n- No JSON serialization of the envelope\n- Binary bodies (multipart, PDFs, images) travel as raw bytes — no base64\n- Measured latency for small requests: **~2,200 ns** with the `DIRECT` dispatch mode\n\n## Quick Navigation\n\n- [jni_app! & VesperaBridge](/documentation/theme/theme-1) — Rust setup, Java setup, native library loading\n- [Dispatch Modes & Wire Format](/documentation/theme/theme-2) — all seven dispatch methods, binary wire layout, `SmartDispatchModeResolver` defaults\n- [Streaming & Multi-App](/documentation/theme/theme-3) — streaming tuning, multi-app routing, virtual thread notes, 0.2.0 breaking changes\n\n## Two-Line Integration\n\n**Rust side:**\n\n```rust\npub fn create_app() -> vespera::axum::Router {\n vespera!(title = \"My API\")\n}\n\nvespera::jni_app!(create_app);\n```\n\n**Java side:**\n\n```java\n@SpringBootApplication\n@ComponentScan(basePackages = {\"com.example.app\", \"com.devfive.vespera.bridge\"})\npublic class MyApp {\n public static void main(String[] args) {\n VesperaBridge.init(\"my_rust_lib\");\n SpringApplication.run(MyApp.class, args);\n }\n}\n```\n\nThat's it. `VesperaProxyController` is autoconfigured and forwards every HTTP request to Rust. Zero controller code, zero `application.yml` config, zero extra imports beyond the Spring Boot starter.\n","title":"JNI / Java Integration","url":"/documentation/theme"},{"text":"# jni_app! & VesperaBridge\n\n## Rust Setup\n\n### 1. Enable the JNI Feature\n\n```toml\n[dependencies]\nvespera = { version = \"0.1\", features = [\"jni\"] }\n```\n\nThe `jni` feature implies `inprocess` — both are enabled automatically.\n\n### 2. Export Your App\n\nIn your cdylib crate's `src/lib.rs`:\n\n```rust\nuse vespera::{axum, vespera};\n\npub fn create_app() -> axum::Router {\n vespera!(title = \"My API\", version = \"1.0.0\")\n}\n\n// Single app — generates JNI_OnLoad and the dispatch symbol\nvespera::jni_app!(create_app);\n```\n\n`jni_app!` generates all JNI boilerplate: `JNI_OnLoad`, the Tokio runtime, and the seven dispatch symbols. You write zero JNI code.\n\n### 3. Build as a cdylib\n\n```toml\n[lib]\ncrate-type = [\"cdylib\"]\n```\n\n```bash\ncargo build --release\n# Produces: target/release/libmy_rust_lib.so (Linux)\n# target/release/my_rust_lib.dll (Windows)\n# target/release/libmy_rust_lib.dylib (macOS)\n```\n\n---\n\n## Java Setup\n\n### Maven\n\n```xml\n\n kr.devfive\n vespera-bridge\n 0.2.0\n\n```\n\n### Gradle (Kotlin DSL)\n\n```kotlin\ndependencies {\n implementation(\"kr.devfive:vespera-bridge:0.2.0\")\n}\n```\n\n### Gradle Plugin (Recommended)\n\nThe `kr.devfive.vespera-bridge` Gradle plugin replaces ~22 lines of native-library-bundling boilerplate with a 5-line block:\n\n```kotlin\nplugins {\n id(\"kr.devfive.vespera-bridge\") version \"0.1.1\"\n}\n\nvespera {\n crateName.set(\"my_rust_lib\")\n cargoRoot.set(rootProject.layout.projectDirectory.dir(\"../..\"))\n bridgeVersion.set(\"0.2.0\")\n}\n```\n\nThe plugin auto-wires `bundleNativeLib` (cdylib → `resources/native/-/`), the `processResources` dependency, and the `vespera-bridge` implementation dependency.\n\n### Spring Boot Application\n\n```java\n@SpringBootApplication\n@ComponentScan(basePackages = {\"com.example.app\", \"com.devfive.vespera.bridge\"})\npublic class MyApp {\n public static void main(String[] args) {\n VesperaBridge.init(\"my_rust_lib\"); // loads cdylib (bundled or system path)\n SpringApplication.run(MyApp.class, args);\n }\n}\n```\n\n`VesperaProxyController` is autoconfigured via Spring Boot's `AutoConfiguration.imports`. It registers a `@RequestMapping(\"/**\")` catch-all that forwards every HTTP request to Rust. The routes published in Vespera's generated `openapi.json` are reachable at the same URLs through Spring.\n\n---\n\n## Native Library Loading\n\n`VesperaBridge.init(\"crateName\")` tries two paths in order:\n\n1. **Bundled** — looks up `native/{os}-{arch}/{libname}` inside the running JAR's classpath. If found, the file is extracted to a temp file (auto-deleted on JVM exit) and loaded via `System.load`.\n2. **Fallback** — `System.loadLibrary(\"crateName\")` searches `java.library.path`.\n\nSupported platform triples: `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64`.\n\nPlace the cdylib at `src/main/resources/native/{os}-{arch}/` to bundle it inside the JAR for single-file deployment.\n\n---\n\n## Zero-Config Defaults\n\nOut of the box the autoconfigure module wires up:\n\n| Concern | Default | Override |\n|---------|---------|----------|\n| App selection | Read `X-Vespera-App` request header; absent → default app | Property `vespera.bridge.app-header`, or custom `AppNameResolver` bean |\n| Dispatch mode | `SmartDispatchModeResolver` since 0.2.0 — `DIRECT` for small/bodyless idempotent, `SYNC` for small non-idempotent, `BIDIRECTIONAL_STREAMING` for the rest | Property `vespera.bridge.dispatch-mode: bidirectional-streaming`, or custom `DispatchModeResolver` bean |\n| URL pattern | `@RequestMapping(\"/**\")` catch-all | Set `vespera.bridge.controller-enabled: false` and supply your own controller |\n\n---\n\n## Customization\n\n### Tweak via application.yml\n\n```yaml\nvespera:\n bridge:\n app-header: X-My-App # change the header that selects the app\n controller-enabled: true # set false to disable the proxy controller\n```\n\n### Custom App-Selection Strategy\n\n```java\n@Bean\npublic AppNameResolver myAppResolver() {\n return request -> {\n String uri = request.getRequestURI();\n if (uri.startsWith(\"/admin/\")) return \"admin\";\n if (uri.startsWith(\"/public/\")) return \"public\";\n return null; // default app\n };\n}\n```\n\nSpring's `@ConditionalOnMissingBean` automatically disables `HeaderAppNameResolver` when you supply your own bean.\n\n### Custom Dispatch-Mode Policy\n\n```java\n@Bean\npublic DispatchModeResolver myModeResolver() {\n return request -> {\n long contentLength = request.getContentLengthLong();\n if (contentLength >= 0 && contentLength < 4096\n && \"application/json\".equals(request.getContentType())) {\n return DispatchMode.SYNC;\n }\n return DispatchMode.BIDIRECTIONAL_STREAMING;\n };\n}\n```\n\n### BYO Controller\n\n```yaml\nvespera:\n bridge:\n controller-enabled: false\n```\n\n```java\n@RestController\npublic class MyController {\n @PostMapping(\"/api/admin/{path}\")\n public ResponseEntity adminRoute(@PathVariable String path, @RequestBody byte[] body) {\n byte[] wire = VesperaBridge.encodeRequest(\n \"admin\", \"POST\", \"/\" + path, null,\n Map.of(\"content-type\", \"application/json\"), body);\n byte[] resp = VesperaBridge.dispatchBytes(wire);\n DecodedResponse d = VesperaBridge.decodeResponse(resp);\n return ResponseEntity.status(d.status()).body(d.bodyBytes());\n }\n}\n```\n","title":"jni_app! & VesperaBridge","url":"/documentation/theme/theme-1"},{"text":"# Dispatch Modes & Wire Format\n\n## Binary Wire Format\n\nBoth request and response use the same length-prefixed layout:\n\n```\nbytes 0..4 : u32 BE = header_json byte length N\nbytes 4..4+N : UTF-8 JSON\n (request) { \"v\":1, \"method\", \"path\",\n \"query\"?, \"headers\"? }\n (response) { \"v\":1, \"status\", \"headers\",\n \"metadata\", \"validation_errors\"? }\nbytes 4+N.. : raw body bytes (UTF-8 text or binary —\n no encoding applied)\n```\n\nKey properties:\n- No base64 — multipart uploads, PDFs, and images travel as raw bytes\n- `\"v\":1` is the protocol version; mismatched versions return a `400` wire response\n- `\"validation_errors\"` is an optional array hoisted from `422` JSON bodies — Java decoders read validation errors from the header without parsing the body\n- All failure paths (malformed wire, Rust panic, no app registered) return a valid length-prefixed response, so the decoder never has to special-case errors\n\n## Dispatch Modes\n\n`VesperaBridge` exposes seven native methods — all sharing the same wire format, the same registered router, and the same panic-safe `catch_unwind` discipline:\n\n\n \n \n Method\n Mode\n Java return\n Memory\n \n \n \n \n `dispatchBytes(byte[])`\n sync\n `byte[]` (header + body)\n full body in memory\n \n \n `dispatchAsync(CompletableFuture, byte[])`\n async\n `void` (future completes)\n full body in memory\n \n \n `dispatchStreaming(byte[], OutputStream)`\n sync, response-streaming\n `byte[]` (header only)\n chunk-bounded response\n \n \n `dispatchFullStreaming(byte[], InputStream, OutputStream)`\n sync, bidirectional streaming\n `byte[]` (header only)\n chunk-bounded both ways\n \n \n `dispatchStreamingWithHeader(byte[], Consumer, OutputStream)`\n sync, response-streaming\n `void` (header via callback)\n chunk-bounded response\n \n \n `dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream)`\n sync, bidirectional streaming\n `void` (header via callback)\n chunk-bounded both ways\n \n \n `dispatchDirect(ByteBuffer, int, ByteBuffer)`\n sync, direct buffers\n `int` (response length / overflow code)\n no Java heap arrays\n \n \n
\n\n### Choosing a Mode\n\n- Small JSON RPC, single request/response → `dispatchBytes`\n- Hot small/bounded payloads where JNI copy overhead matters → `dispatchDirect` / `dispatchDirectPooled`\n- Async I/O coordination (parallel Java requests, non-blocking) → `dispatchAsync` + `CompletableFuture`\n- Large download / streaming response (video, PDF, SSE) → `dispatchStreaming` + `OutputStream`\n- Large upload + large download (file transfer, video transcoding) → `dispatchFullStreaming` + `InputStream` + `OutputStream`\n- The `*WithHeader` variants let Spring-style controllers commit status/headers before the first body byte is written\n\n## SmartDispatchModeResolver (Default since 0.2.0)\n\nThe autoconfigured default since vespera-bridge 0.2.0 picks the cheapest safe path per request. Measured on a `GET /health` round-trip through the real JNI boundary:\n\n| Request shape | Mode | ns / round-trip |\n|---------------|------|-----------------|\n| Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB) | `DIRECT` | ~2,200 |\n| Small (≤ 256 KiB Content-Length) + non-idempotent (POST/PATCH) | `SYNC` | ~3,200 |\n| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | ~24,100 |\n\nTrade-offs:\n- **DIRECT** writes the wire response straight into a pooled per-thread direct `ByteBuffer` (64 KiB → `vespera.direct.maxBufferBytes`, default 4 MiB). Responses larger than the pooled buffer trigger a single retry that **re-runs the Rust handler** — which is why DIRECT is gated on idempotent methods only.\n- **SYNC** fully buffers the response on the JVM heap. The 256 KiB request-size gate keeps the response size reasonable for JSON-RPC-shaped traffic.\n- **BIDIRECTIONAL_STREAMING** is unchanged for large/unknown-length bodies — multi-GB upload + multi-GB download runs chunk-bounded, ~32 KiB resident each side.\n\nRestore the pre-0.2.0 default (every request that may carry a body streams both ways, ~24 µs uniform):\n\n```yaml\nvespera:\n bridge:\n dispatch-mode: bidirectional-streaming\n```\n\n## Direct Buffer Dispatch\n\n`dispatchDirect(ByteBuffer in, int inLen, ByteBuffer out)` eliminates the two JNI `GetByteArrayRegion`/`SetByteArrayRegion` copies that `dispatchBytes` pays. The response is streamed straight into the out buffer — no intermediate `Vec`. Measured at **1.4–3.4× per round-trip** versus `dispatchBytes` depending on payload size.\n\nContract:\n- Both buffers MUST be direct (`ByteBuffer.allocateDirect`); heap buffers are rejected with `IllegalArgumentException`\n- The request is read from absolute offsets `in[0..inLen]` — the buffer's position/limit are ignored; `inLen` is authoritative\n- Return `>= 0`: a complete wire response occupies `out[0..n]`\n- Return `< 0`: `-(requiredSize)` — the response did not fit; **retrying re-runs the Rust handler**, so only retry idempotent requests\n- `Integer.MIN_VALUE`: response exceeds 2 GiB\n\n`dispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow)` wraps the raw call with per-thread reusable direct buffers (64 KiB initial, doubling up to `vespera.direct.maxBufferBytes`, default 4 MiB).\n\n## Direct API (Without the Proxy Controller)\n\n```java\nimport com.devfive.vespera.bridge.VesperaBridge;\nimport com.devfive.vespera.bridge.VesperaBridge.DecodedResponse;\n\n// 1. Initialise once at startup\nVesperaBridge.init(\"my_rust_lib\");\n\n// 2. Encode a request\nbyte[] wireRequest = VesperaBridge.encodeRequest(\n \"POST\",\n \"/documents/validate\",\n /* query */ null,\n Map.of(\"content-type\", \"application/json\"),\n \"{\\\"title\\\":\\\"…\\\"}\".getBytes(StandardCharsets.UTF_8));\n\n// 3. Dispatch through Rust\nbyte[] wireResponse = VesperaBridge.dispatchBytes(wireRequest);\n\n// 4. Decode\nDecodedResponse resp = VesperaBridge.decodeResponse(wireResponse);\nSystem.out.println(resp.status()); // 200\nSystem.out.println(resp.headers()); // { \"content-type\": \"application/json\", … }\nSystem.out.println(new String(resp.bodyBytes())); // copies the raw response body\n```\n\n> **0.2.0 breaking change:** `DecodedResponse.body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes). The owned `byte[]` materialisation moved to `DecodedResponse.bodyBytes()`. Callers that previously used `body()` as `byte[]` must switch to `bodyBytes()`.\n\n## Async Dispatch\n\n```java\nCompletableFuture future = VesperaBridge.dispatch(wireRequest);\n\nfuture.thenAccept(wireResponse -> {\n DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse);\n System.out.println(\"Status: \" + resp.status());\n});\n```\n\nThe future is **always** completed with a valid wire response, even on Rust panics or JNI conversion failures. You will never see a dangling future.\n\n## Streaming Dispatch\n\n```java\nbyte[] wireRequest = VesperaBridge.encodeRequest(\n \"GET\", \"/files/large.pdf\", null, Map.of(), new byte[0]);\n\ntry (ByteArrayOutputStream sink = new ByteArrayOutputStream()) {\n byte[] headerOnly = VesperaBridge.dispatchStreaming(wireRequest, sink);\n DecodedResponse meta = VesperaBridge.decodeResponse(headerOnly);\n System.out.println(\"Status: \" + meta.status());\n System.out.println(\"Body size: \" + sink.size());\n}\n```\n\n## Bidirectional Streaming\n\n```java\ntry (InputStream upload = Files.newInputStream(Path.of(\"huge.mp4\"));\n OutputStream download = Files.newOutputStream(Path.of(\"transcoded.mp4\"))) {\n\n byte[] wireHeader = VesperaBridge.encodeRequestHeader(\n \"POST\", \"/transcode\", null,\n Map.of(\"content-type\", \"video/mp4\"));\n\n byte[] respHeader = VesperaBridge.dispatchFullStreaming(\n wireHeader, upload, download);\n\n DecodedResponse meta = VesperaBridge.decodeResponse(respHeader);\n System.out.println(\"Status: \" + meta.status());\n}\n```\n\nA 1 GiB upload paired with a 1 GiB download runs in low-single-digit MiB resident memory on each side. Backpressure is enforced naturally — if Axum reads slowly, `InputStream.read()` blocks on the bounded channel.\n","title":"Dispatch Modes & Wire Format","url":"/documentation/theme/theme-2"},{"text":"# Streaming & Multi-App\n\n## Streaming Tuning\n\nBoth streaming knobs are fixed for the process lifetime once the first dispatch runs. Configuration precedence (first hit wins):\n\n1. **Programmatic setter** — `VesperaBridge.configureStreaming(chunkBytes, channelCapacity)` (call before or after `init`)\n2. **System properties** — `vespera.streaming.chunkBytes`, `vespera.streaming.channelCapacity`\n3. **Environment variables** — `VESPERA_STREAMING_CHUNK_BYTES`, `VESPERA_STREAMING_CHANNEL_CAPACITY`\n4. **Built-in defaults** — 256 KiB chunk size, 16 channel slots\n\n| Setting | System property | Env var | Default | Range |\n|---------|----------------|---------|---------|-------|\n| Chunk buffer size | `vespera.streaming.chunkBytes` | `VESPERA_STREAMING_CHUNK_BYTES` | 256 KiB | 4 KiB – 8 MiB |\n| Request channel slots | `vespera.streaming.channelCapacity` | `VESPERA_STREAMING_CHANNEL_CAPACITY` | 16 | 1 – 1024 |\n| Tokio worker threads | `vespera.runtime.workerThreads` | `VESPERA_RUNTIME_WORKERS` | logical CPUs | 1 – 1024 |\n\n### Java API\n\nCall before `VesperaBridge.init(...)` for guaranteed precedence:\n\n```java\nVesperaBridge.configureStreaming(\n 131072, // chunkBytes: 128 KiB (clamped to 4 KiB – 8 MiB)\n 32 // channelCapacity: 32 slots (clamped to 1 – 1024)\n);\nVesperaBridge.init(\"my_rust_lib\");\n```\n\nWhen called before `init()`, values are stored as pending and applied immediately after the native library loads — before any dispatch can occur. This ensures the programmatic setter beats system properties and environment variables.\n\nThrows `IllegalArgumentException` if `chunkBytes` is outside `[4096, 8388608]` or `channelCapacity` is outside `[1, 1024]`.\n\n### System Properties\n\n```bash\njava -Dvespera.streaming.chunkBytes=131072 \\\n -Dvespera.streaming.channelCapacity=32 \\\n -jar app.jar\n```\n\n### Environment Variables\n\n```bash\nexport VESPERA_STREAMING_CHUNK_BYTES=131072\nexport VESPERA_STREAMING_CHANNEL_CAPACITY=32\njava -jar app.jar\n```\n\n### Tuning Tips\n\n- Larger chunks reduce the per-chunk JNI crossing cost (one `SetByteArrayRegion` + one `OutputStream.write` per chunk) at the price of per-stream memory. 256 KiB is a reasonable ceiling for throughput-oriented deployments.\n- The Tokio worker-thread knob caps Rust's shared runtime — useful when the JVM's own pools (Tomcat request threads, virtual-thread carriers) compete with Tokio for the same cores, or when a container CPU limit is lower than the host's logical CPU count.\n\n---\n\n## Multi-App Routing\n\nMulti-app routing is primarily a feature for external-dispatcher scenarios — JNI (Java host picks app per request via header), WebAssembly bridge, C FFI, or any in-process embedding where the host distinguishes between multiple independent Vespera API surfaces.\n\n### Rust Side\n\n```rust\npub fn create_app() -> axum::Router { vespera!(title = \"Default\") }\npub fn admin_app() -> axum::Router { vespera!(dir = \"admin_routes\", title = \"Admin\") }\npub fn public_app() -> axum::Router { vespera!(dir = \"public_routes\", title = \"Public\") }\n\nvespera::jni_apps! {\n \"_default\" => create_app,\n \"admin\" => admin_app,\n \"public\" => public_app,\n}\n```\n\n`jni_apps!` is the primary multi-app API. `jni_app!(create_app)` is syntactic sugar for a single default app.\n\n### Java Side\n\nThe default `HeaderAppNameResolver` selects an app per request via the `X-Vespera-App` header:\n\n```bash\n# Default app (no header)\ncurl http://localhost:8080/health\n\n# Admin app\ncurl -H \"X-Vespera-App: admin\" http://localhost:8080/dashboard\n\n# Public app\ncurl -H \"X-Vespera-App: public\" http://localhost:8080/info\n```\n\nEach app's URLs are independent — the same `/users` path can mean different things in `admin` vs `public` apps. Unknown app names return `404`; invalid app names (special characters, > 64 bytes) return `400`.\n\n### Custom App-Selection Strategy\n\n```java\n@Bean\npublic AppNameResolver myAppResolver() {\n // App name from the first path segment:\n // /admin/dashboard → app \"admin\", path \"/dashboard\"\n // /public/info → app \"public\", path \"/info\"\n return request -> {\n String uri = request.getRequestURI();\n if (uri.startsWith(\"/admin/\")) return \"admin\";\n if (uri.startsWith(\"/public/\")) return \"public\";\n return null; // default app\n };\n}\n```\n\n---\n\n## Virtual Thread (Project Loom) Limitation\n\nThe pooled direct-buffer methods (`dispatchDirectPooled`) use `ThreadLocal` to maintain per-thread reusable buffers. In Java 21+, `ThreadLocal` binds to the **virtual thread** (not the carrier thread) — so in a virtual-thread-per-request server, each virtual thread allocates a fresh direct buffer and loses all pooling benefit. Direct memory accumulates until the virtual thread is garbage-collected, potentially causing memory pressure under high concurrency.\n\n**Recommendations for virtual-thread deployments:**\n\n- Set `vespera.bridge.dispatch-mode=bidirectional-streaming` to opt out of the smart default, so `DIRECT` is never chosen by the autoconfigured resolver.\n- Or use `dispatchBytes`, `dispatchStreaming`, or `dispatchFullStreaming` directly instead of the pooled direct variants.\n- Or run dispatch on a bounded platform-thread executor (e.g. a `ForkJoinPool` with a fixed parallelism cap).\n- Or lower `vespera.direct.maxBufferBytes` to reduce per-thread allocation size.\n\n`DispatchMode.BIDIRECTIONAL_STREAMING` is safe for virtual threads and handles all payload sizes without pooling.\n\n---\n\n## 0.2.0 Breaking Changes\n\n### 1. Default DispatchModeResolver Flipped to SmartDispatchModeResolver\n\nPre-0.2.0 the autoconfigured default was `BidirectionalStreamingDispatchModeResolver` — every request that may carry a body streamed both ways, ~24.1 µs per round-trip uniform. Since 0.2.0 the default is `SmartDispatchModeResolver`.\n\n| Request shape | Pre-0.2.0 mode | 0.2.0+ mode |\n|---------------|----------------|-------------|\n| Small/bodyless idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB CL or no CL) | `STREAMING` / `BIDIRECTIONAL_STREAMING` | `DIRECT` |\n| Small non-idempotent (POST/PATCH, ≤ 256 KiB CL) | `BIDIRECTIONAL_STREAMING` | `SYNC` |\n| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | `BIDIRECTIONAL_STREAMING` |\n\nOpt out (restore the pre-0.2.0 default):\n\n```yaml\nvespera:\n bridge:\n dispatch-mode: bidirectional-streaming\n```\n\nOr register a custom `DispatchModeResolver` bean — `@ConditionalOnMissingBean` ensures it wins over both the property and the autoconfigured default.\n\n### 2. DecodedResponse.body() Returns ByteBuffer\n\n`DecodedResponse.body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes). The owned `byte[]` materialisation moved to `DecodedResponse.bodyBytes()`.\n\n```java\n// Before 0.2.0\nbyte[] body = resp.body();\n\n// After 0.2.0\nbyte[] body = resp.bodyBytes(); // owned copy\nByteBuffer view = resp.body(); // zero-copy view\n```\n\nCallers that previously consumed `body()` as `byte[]` must switch to `bodyBytes()`.\n\n---\n\n## Migrating from the JSON-Envelope Bridge (≤ 0.0.13)\n\nThe pre-0.0.14 bridge used `dispatch(String) → String` with base64-encoded binary bodies.\n\n| Before | After |\n|--------|-------|\n| `VesperaBridge.dispatch(json)` | `encodeRequest(...)` → `dispatchBytes(...)` → `decodeResponse(...)` |\n| `body_bytes_b64` field on the response JSON | raw body bytes after the wire header (no base64) |\n| ~33% size overhead on binary bodies | zero overhead |\n\nExisting users of `VesperaProxyController` need no code change — the controller was rewritten to the new wire path internally. Direct callers of `VesperaBridge.dispatch(String)` must update; the old method was removed in 0.0.14.\n","title":"Streaming & Multi-App","url":"/documentation/theme/theme-3"}] \ No newline at end of file diff --git a/apps/landing/src/app/documentation/[...name]/api.api-1.mdx b/apps/landing/src/app/documentation/[...name]/api.api-1.mdx index 7b4d68d7..eb18a44a 100644 --- a/apps/landing/src/app/documentation/[...name]/api.api-1.mdx +++ b/apps/landing/src/app/documentation/[...name]/api.api-1.mdx @@ -1 +1,133 @@ -empty \ No newline at end of file +# vespera! Macro + +The `vespera!()` macro is the entry point for every Vespera application. It scans your route folder at compile time, builds an `axum::Router` with all discovered handlers, and optionally writes an OpenAPI 3.1 spec file. + +## Full Parameter Reference + +```rust +let app = vespera!( + dir = "routes", // Route folder (default: "routes") + openapi = "openapi.json", // Output path (writes file at compile time) + title = "My API", // OpenAPI info.title + version = "1.0.0", // OpenAPI info.version (default: CARGO_PKG_VERSION) + docs_url = "/docs", // Swagger UI endpoint + redoc_url = "/redoc", // ReDoc endpoint + servers = [ // OpenAPI servers array + { url = "https://api.example.com", description = "Production" }, + { url = "http://localhost:3000", description = "Development" } + ], + merge = [crate1::App1, crate2::App2] // Merge child vespera apps +); +``` + +## Environment Variable Fallbacks + +Every parameter has a corresponding environment variable. The macro parameter takes priority over the env var, which takes priority over the built-in default. + +| Parameter | Environment Variable | Default | +|-----------|---------------------|---------| +| `dir` | `VESPERA_DIR` | `"routes"` | +| `openapi` | `VESPERA_OPENAPI` | none | +| `title` | `VESPERA_TITLE` | `"API"` | +| `version` | `VESPERA_VERSION` | `CARGO_PKG_VERSION` | +| `docs_url` | `VESPERA_DOCS_URL` | none | +| `redoc_url` | `VESPERA_REDOC_URL` | none | +| `servers` | `VESPERA_SERVER_URL` + `VESPERA_SERVER_DESCRIPTION` | none | + +## Common Patterns + +### Minimal — just a router + +```rust +let app = vespera!(); +``` + +### With Swagger UI + +```rust +let app = vespera!(docs_url = "/docs"); +``` + +### Write OpenAPI file + Swagger UI + +```rust +let app = vespera!( + openapi = "openapi.json", + docs_url = "/docs", + title = "My API", + version = "1.0.0" +); +``` + +### Multiple OpenAPI output files + +```rust +let app = vespera!( + openapi = ["openapi.json", "docs/api-spec.json"] +); +``` + +### Custom route folder + +```rust +// Scans src/api/ instead of src/routes/ +let app = vespera!(dir = "api"); +``` + +### With state and middleware + +```rust +let app = vespera!(docs_url = "/docs") + .with_state(AppState { db: pool }) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()); +``` + +### Merging child apps + +```rust +let app = vespera!( + openapi = "openapi.json", + docs_url = "/docs", + merge = [billing::BillingApp, notifications::NotificationsApp] +) +.with_state(app_state); +``` + +## The `.serve()` Extension + +`vespera!()` returns an `axum::Router`. Vespera adds a `.serve(addr)` extension trait that replaces the usual `TcpListener::bind` + `axum::serve(...)` boilerplate: + +```rust +use vespera::{vespera, Serve}; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + vespera!(docs_url = "/docs") + .serve("0.0.0.0:3000") + .await +} +``` + +`addr` accepts anything `tokio::net::ToSocketAddrs` takes — strings like `"0.0.0.0:3000"`, tuples like `([0, 0, 0, 0], 3000)`, or a `SocketAddr`. + +## export_app! Macro + +Export a Vespera app from a library crate so it can be merged into a parent app: + +```rust +// In the child crate's src/lib.rs +mod routes; + +// Scans "routes" folder by default +vespera::export_app!(MyApp); + +// Or with a custom directory +vespera::export_app!(MyApp, dir = "api"); +``` + +This generates a struct with two associated items: +- `MyApp::OPENAPI_SPEC: &'static str` — the OpenAPI JSON spec as a static string +- `MyApp::router() -> Router` — a function returning the Axum router + +The parent app merges it with `merge = [MyApp]` in `vespera!()`. diff --git a/apps/landing/src/app/documentation/[...name]/api.api-2.mdx b/apps/landing/src/app/documentation/[...name]/api.api-2.mdx index 7b4d68d7..26f48d47 100644 --- a/apps/landing/src/app/documentation/[...name]/api.api-2.mdx +++ b/apps/landing/src/app/documentation/[...name]/api.api-2.mdx @@ -1 +1,198 @@ -empty \ No newline at end of file +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@/components/mdx/components/Table' + +# Route Attribute & Extractors + +`#[vespera::route]` marks a `pub async fn` as an HTTP handler. Vespera reads the function signature to extract path parameters, query parameters, request body, and response types for the OpenAPI spec. + +## Route Attribute Parameters + +```rust +#[vespera::route( + get, // HTTP method (default: get) + path = "/{id}", // Path suffix (appended to file-based prefix) + tags = ["users", "admin"], // OpenAPI tags + description = "Get user by ID" // OpenAPI operation description +)] +pub async fn get_user(Path(id): Path) -> Json { ... } +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| method | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` | `get` | HTTP method | +| `path` | string | `""` | Path suffix appended to the file-based prefix | +| `tags` | string array | `[]` | OpenAPI tags for grouping in Swagger UI | +| `description` | string | `""` | OpenAPI operation description | + +## Extractor to OpenAPI Mapping + +Vespera reads your handler's extractor types and maps them to OpenAPI parameters and request bodies automatically: + + + + + Extractor + OpenAPI Location + Notes + + + + + `Path` + Path parameters + `T` can be a primitive or a struct + + + `Query` + Query parameters + Struct fields become individual query params + + + `Json` + Request body (`application/json`) + + + + `Form` + Request body (`application/x-www-form-urlencoded`) + + + + `TypedMultipart` + Request body (`multipart/form-data`) + Typed with schema + + + `Multipart` + Request body (`multipart/form-data`) + Untyped, generic object + + + `TypedHeader` + Header parameters + + + + `State` + Ignored + Internal — not part of the API + + + `Extension` + Ignored + Internal — not part of the API + + +
+ +## Examples + +### Path Parameters + +```rust +// Single path param +#[vespera::route(get, path = "/{id}")] +pub async fn get_user(Path(id): Path) -> Json { ... } + +// Multiple path params via struct +#[derive(Deserialize)] +pub struct PostParams { + pub user_id: u32, + pub post_id: u32, +} + +#[vespera::route(get, path = "/{user_id}/posts/{post_id}")] +pub async fn get_post(Path(params): Path) -> Json { ... } +``` + +### Query Parameters + +```rust +#[derive(Deserialize, Schema)] +pub struct ListUsersQuery { + pub page: Option, + pub limit: Option, + pub search: Option, +} + +#[vespera::route(get)] +pub async fn list_users(Query(q): Query) -> Json> { ... } +``` + +### JSON Body + +```rust +#[derive(Deserialize, Schema)] +pub struct CreateUserRequest { + pub name: String, + pub email: String, +} + +#[vespera::route(post)] +pub async fn create_user(Json(req): Json) -> Json { ... } +``` + +### Validated Body (with 422) + +```rust +use vespera::Validated; +use garde::Validate; + +#[derive(Deserialize, Schema, Validate)] +pub struct CreateUserRequest { + #[garde(length(min = 3, max = 32))] + pub username: String, + #[garde(email)] + pub email: String, +} + +#[vespera::route(post)] +pub async fn create_user( + Validated(Json(req)): Validated>, +) -> Json { ... } +``` + +### State (Ignored by OpenAPI) + +```rust +#[vespera::route(get)] +pub async fn list_users( + State(db): State, // ignored by OpenAPI + Query(q): Query, // included in OpenAPI +) -> Json> { ... } +``` + +### Error Responses + +```rust +#[derive(Serialize, Schema)] +pub struct ApiError { + pub message: String, +} + +#[vespera::route(get, path = "/{id}")] +pub async fn get_user( + Path(id): Path, +) -> Result, (StatusCode, Json)> { + if id == 0 { + return Err(( + StatusCode::NOT_FOUND, + Json(ApiError { message: "Not found".into() }), + )); + } + Ok(Json(User { id, name: "Alice".into() })) +} +``` + +## Handler Requirements + +- Must be `pub async fn` — private or non-async functions are ignored +- Must have `#[vespera::route]` attribute +- Can live anywhere in `src/routes/` (or your configured `dir`) +- The URL is: **file path prefix + `path` attribute value** diff --git a/apps/landing/src/app/documentation/[...name]/api.api-3.mdx b/apps/landing/src/app/documentation/[...name]/api.api-3.mdx index 7b4d68d7..e28c5bfd 100644 --- a/apps/landing/src/app/documentation/[...name]/api.api-3.mdx +++ b/apps/landing/src/app/documentation/[...name]/api.api-3.mdx @@ -1 +1,205 @@ -empty \ No newline at end of file +# schema_type!, schema!, and export_app! + +## schema_type! Macro + +Generate request/response types from existing structs. Perfect for creating API DTOs from database models without duplicating field definitions. + +### Basic Usage + +```rust +use vespera::schema_type; + +// Include only specific fields +schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]); + +// Exclude specific fields +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); + +// Add new fields (disables auto From impl) +schema_type!(UpdateUserRequest from crate::models::user::Model, pick = ["name"], add = [("id": i32)]); +``` + +### Auto-Generated From Impl + +When `add` is NOT used, a `From` impl is generated automatically: + +```rust +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); + +// Use it directly: +let model: Model = db.find_user(id).await?; +Json(model.into()) // From impl handles the conversion +``` + +### Same-File Model Reference + +When the model is in the same file, use a simple name with the `name` parameter: + +```rust +// In src/models/user.rs +pub struct Model { + pub id: i32, + pub name: String, + pub email: String, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +``` + +### Cross-File References + +Reference structs from other files using full module paths: + +```rust +// In src/routes/users.rs +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); +``` + +### Partial Updates (PATCH) + +```rust +// All fields become Option +schema_type!(UserPatch from User, partial); + +// Only specific fields become Option +schema_type!(UserPatch from User, partial = ["name", "email"]); +``` + +### Omit Database Defaults + +`omit_default` automatically omits fields with `#[sea_orm(primary_key)]` or `#[sea_orm(default_value = "...")]` — perfect for create DTOs: + +```rust +#[derive(DeriveEntityModel)] +#[sea_orm(table_name = "posts")] +pub struct Model { + #[sea_orm(primary_key)] // omitted + pub id: i32, + pub title: String, + pub content: String, + #[sea_orm(default_value = "NOW()")] // omitted + pub created_at: DateTimeWithTimeZone, +} + +// Generated struct only has: title, content +schema_type!(CreatePostRequest from crate::models::post::Model, omit_default); + +// Combine with add +schema_type!(CreateItemRequest from Model, omit_default, add = [("tags": Vec)]); +``` + +### Multipart Mode + +Generate `Multipart` structs from existing types: + +```rust +#[derive(vespera::Multipart, vespera::Schema)] +pub struct CreateUploadRequest { + pub name: String, + #[form_data(limit = "10MiB")] + pub file: Option>, + pub description: Option, +} + +// Generates a Multipart struct (no serde derives), all fields Optional +schema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = ["file"]); +``` + +When `multipart` is enabled: +- Derives `Multipart` instead of `Serialize`/`Deserialize` +- Preserves `#[form_data(...)]` attributes from the source struct +- Skips SeaORM relation fields +- Does not generate a `From` impl + +### Same-File Relation Adapters + +When a route file defines local response DTOs for SeaORM relations, `schema_type!` generates compile adapters so existing handler code stays valid: + +```rust +#[derive(Serialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct UserInArticle { + pub id: Uuid, + pub name: String, + pub email: String, +} + +schema_type!( + ArticleResponse from crate::models::article::Model, + add = [("review_users": Vec)] +); + +// Handler code unchanged: +Ok(ArticleResponse { + user: user.into(), // adapter generated automatically + review_users, + .. +}) +``` + +The naming convention is `{RelationNamePascal}In{ResponseBase}` — `user` on `ArticleResponse` → `UserInArticle`. + +### All Parameters + +| Parameter | Description | +|-----------|-------------| +| `pick` | Include only specified fields | +| `omit` | Exclude specified fields | +| `rename` | Rename fields: `rename = [("old", "new")]` | +| `add` | Add new fields (disables auto `From` impl) | +| `clone` | Control Clone derive (default: `true`) | +| `partial` | Make fields optional: `partial` or `partial = ["field1"]` | +| `name` | Custom OpenAPI schema name (same-file references only) | +| `rename_all` | Serde rename strategy: `rename_all = "camelCase"` | +| `ignore` | Skip Schema derive (bare keyword) | +| `multipart` | Derive `Multipart` instead of serde (bare keyword) | +| `omit_default` | Auto-omit fields with DB defaults (bare keyword) | + +--- + +## schema! Macro + +Get a `Schema` value at runtime with optional field filtering. Useful for programmatic schema access without generating a new struct type. + +```rust +use vespera::{Schema, schema}; + +#[derive(Schema)] +pub struct User { + pub id: i32, + pub name: String, + pub password: String, +} + +// Full schema +let full: vespera::schema::Schema = schema!(User); + +// With fields omitted +let safe: vespera::schema::Schema = schema!(User, omit = ["password"]); + +// With only specified fields +let summary: vespera::schema::Schema = schema!(User, pick = ["id", "name"]); +``` + +> For creating request/response types with `From` impls, use `schema_type!` instead. + +--- + +## export_app! Macro + +Export a Vespera app from a library crate for merging into a parent app. See [vespera! Macro](/documentation/api/api-1) for the merge usage. + +```rust +// In the child crate's src/lib.rs +mod routes; + +// Scans "routes" folder by default +vespera::export_app!(MyApp); + +// Or with a custom directory +vespera::export_app!(MyApp, dir = "api"); +``` + +Generates: +- `MyApp::OPENAPI_SPEC: &'static str` — the OpenAPI JSON spec +- `MyApp::router() -> Router` — the Axum router diff --git a/apps/landing/src/app/documentation/[...name]/api.mdx b/apps/landing/src/app/documentation/[...name]/api.mdx index 7b4d68d7..b9b326c0 100644 --- a/apps/landing/src/app/documentation/[...name]/api.mdx +++ b/apps/landing/src/app/documentation/[...name]/api.mdx @@ -1 +1,23 @@ -empty \ No newline at end of file +# API Reference + +Complete reference for Vespera's macros and attributes. + +## vespera! Macro + +The entry point for every Vespera application. Scans your route folder at compile time, builds an `axum::Router`, and optionally writes an OpenAPI spec file. + +See [vespera! Macro](/documentation/api/api-1) for the full parameter reference. + +## Route Attribute & Extractors + +`#[vespera::route]` marks a `pub async fn` as an HTTP handler. Vespera reads the function signature to extract path parameters, query parameters, request body, and response types for the OpenAPI spec. + +See [Route Attribute & Extractors](/documentation/api/api-2) for all options and extractor mappings. + +## schema_type!, schema!, and export_app! + +- `schema_type!` — derive request/response DTOs from existing structs with `pick`, `omit`, `partial`, `add`, and SeaORM relation support +- `schema!` — get a `Schema` value at runtime with optional field filtering +- `export_app!` — export a Vespera app for merging into a parent app + +See [schema_type! & More](/documentation/api/api-3) for the full reference. diff --git a/apps/landing/src/app/documentation/[...name]/concept.concept-1.mdx b/apps/landing/src/app/documentation/[...name]/concept.concept-1.mdx index 56a0be64..b5fc97da 100644 --- a/apps/landing/src/app/documentation/[...name]/concept.concept-1.mdx +++ b/apps/landing/src/app/documentation/[...name]/concept.concept-1.mdx @@ -1,215 +1,100 @@ -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, -} from '@/components/mdx/components/Table' - -export const metadata = { - title: 'What is Devup UI?', - alternates: { - canonical: '/docs/overview', - }, -} +# File-Based Routing + +Vespera maps your `src/routes/` folder structure directly to URL paths. The `vespera!()` macro scans the folder at compile time — no manual `Router::new().route(...)` calls needed. + +## Folder to URL Mapping + +``` +src/routes/ +├── mod.rs → / +├── users.rs → /users +├── posts.rs → /posts +└── admin/ + ├── mod.rs → /admin + └── stats.rs → /admin/stats +``` + +The final URL for a handler is: **file path prefix + `#[route]` path attribute**. + +```rust +// In src/routes/users.rs +#[vespera::route(get, path = "/{id}")] +pub async fn get_user(...) // → GET /users/{id} +``` + +## Handler Requirements + +Handlers must be `pub async fn`. Private or non-async functions are silently ignored by the scanner. + +```rust +// Ignored — private +async fn get_users() -> Json> { ... } -## What is Devup UI?eeeeeeeeeeee - -**Devup UI is not just another CSS-in-JS library — it's the future of CSS-in-JS itself.** - -Devup UI is a zero-runtime CSS-in-JS preprocessor powered by Rust and WebAssembly. It transforms all your styles at build time, completely eliminating runtime overhead while providing full CSS-in-JS syntax coverage. - -### The Problem with Traditional CSS-in-JS - -Traditional CSS-in-JS solutions force you to choose between: - -- **Developer Experience**: Intuitive APIs, co-located styles, dynamic theming -- **Performance**: No runtime overhead, fast page loads, optimal Core Web Vitals - -Libraries like styled-components and Emotion offer great DX but execute JavaScript at runtime to generate styles. Zero-runtime alternatives like Vanilla Extract sacrifice some flexibility for performance. - -### The Devup UI Solution - -Devup UI eliminates this trade-off entirely. Our Rust-powered preprocessor analyzes your code at build time and handles every CSS-in-JS pattern: - -- **Variables** — Dynamic values become CSS custom properties -- **Conditionals** — Ternary expressions are statically analyzed -- **Responsive Arrays** — Breakpoint-based styles are pre-generated -- **Pseudo Selectors** — `_hover`, `_focus`, `_active` work seamlessly -- **Themes** — Type-safe theme tokens with zero-cost switching - -### Key Advantages - - - - - Feature - Devup UI - styled-components - Emotion - Vanilla Extract - - - - - Zero Runtime - Yes - No - No - Yes - - - Dynamic Values - Yes - Yes - Yes - Limited - - - Full Syntax Coverage - Yes - Yes - Yes - No - - - Type-Safe Themes - Yes - Limited - Limited - Yes - - - Build Performance - Fastest - N/A - N/A - Fast - - -
- -### How It Works - -```tsx -// You write familiar CSS-in-JS syntax -const example = - -// Devup UI transforms it at build time -const generated =
- -// With optimized atomic CSS -// .a { background-color: red; } -// .b { padding: 16px; } /* 4 * 4 = 16px */ -// .c:hover { background-color: blue; } +// Ignored — not async +pub fn get_users() -> Json> { ... } + +// Discovered +pub async fn get_users() -> Json> { ... } ``` -> Numeric values are multiplied by 4. `p={4}` becomes `padding: 16px`. +## Route Attribute + +```rust +// GET /users (default method is GET) +#[vespera::route] +pub async fn list_users() -> Json> { ... } + +// POST /users +#[vespera::route(post)] +pub async fn create_user(Json(user): Json) -> Json { ... } + +// GET /users/{id} +#[vespera::route(get, path = "/{id}")] +pub async fn get_user(Path(id): Path) -> Json { ... } + +// PUT /users/{id} with tags and description +#[vespera::route(put, path = "/{id}", tags = ["users"], description = "Update user")] +pub async fn update_user(...) -> ... { ... } +``` -Class names use compact base-37 encoding (`a`, `b`, ... `z`, `_`, `aa`, `ab`, ...) for minimal CSS output. +### Attribute Parameters -### Familiar API +| Parameter | Type | Description | +|-----------|------|-------------| +| method | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` | HTTP method (default: `get`) | +| `path` | string | Path suffix appended to the file-based prefix | +| `tags` | string array | OpenAPI tags for grouping in Swagger UI | +| `description` | string | OpenAPI operation description | -If you've used styled-components or Emotion, you'll feel right at home: +## Custom Route Folder -```tsx -import { styled } from '@devup-ui/react' +The default folder is `src/routes/`. Change it with the `dir` parameter or the `VESPERA_DIR` environment variable: -const Card = styled('div', { - bg: 'white', - p: 4, // 4 * 4 = 16px - borderRadius: '8px', - boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)', - _hover: { - boxShadow: '0 10px 15px rgba(0, 0, 0, 0.1)', - }, -}) +```rust +// Scans src/api/ instead of src/routes/ +let app = vespera!(dir = "api"); ``` -### Proven Performance - -Benchmarks on Next.js (GitHub Actions - ubuntu-latest): - - - - - Library - Version - Build Time - Build Size - - - - - tailwindcss - 4.1.13 - 19.31s - 59,521,539 bytes - - - styleX - 0.15.4 - 41.78s - 86,869,452 bytes - - - vanilla-extract - 1.17.4 - 19.50s - 61,494,033 bytes - - - kuma-ui - 1.5.9 - 20.93s - 69,924,179 bytes - - - panda-css - 1.3.1 - 20.64s - 64,573,260 bytes - - - chakra-ui - 3.27.0 - 28.81s - 222,435,802 bytes - - - mui - 7.3.2 - 20.86s - 97,964,458 bytes - - - **devup-ui (per-file css)** - **1.0.18** - **16.90s** - 59,540,459 bytes - - - **devup-ui (single css)** - **1.0.18** - **17.05s** - **59,520,196 bytes** - - - tailwindcss (turbopack) - 4.1.13 - 6.72s - 5,355,082 bytes - - - **devup-ui (single css + turbopack)** - **1.0.18** - 10.34s - **4,772,050 bytes** - - -
- -### Get Started - -Ready to experience the future of CSS-in-JS? Head to the [Installation](/docs/installation) guide to get started in minutes. +## Error Handling + +Return `Result` from handlers. Both `T` and `E` are included in the OpenAPI response schemas: + +```rust +#[derive(Serialize, Schema)] +pub struct ApiError { + pub message: String, +} + +#[vespera::route(get, path = "/{id}")] +pub async fn get_user( + Path(id): Path, +) -> Result, (StatusCode, Json)> { + if id == 0 { + return Err(( + StatusCode::NOT_FOUND, + Json(ApiError { message: "Not found".into() }), + )); + } + Ok(Json(User { id, name: "Alice".into() })) +} +``` diff --git a/apps/landing/src/app/documentation/[...name]/concept.concept-2.mdx b/apps/landing/src/app/documentation/[...name]/concept.concept-2.mdx index 7b4d68d7..9b912997 100644 --- a/apps/landing/src/app/documentation/[...name]/concept.concept-2.mdx +++ b/apps/landing/src/app/documentation/[...name]/concept.concept-2.mdx @@ -1 +1,216 @@ -empty \ No newline at end of file +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@/components/mdx/components/Table' + +# Schema & OpenAPI Generation + +Vespera generates a complete OpenAPI 3.1 spec from your Rust types at compile time. Derive `Schema` on any type used in a handler's input or output and it appears in the spec automatically. + +## Deriving Schema + +```rust +#[derive(Serialize, Deserialize, vespera::Schema)] +pub struct User { + pub id: u32, + pub name: String, + pub email: String, + pub bio: Option, // optional — not in `required` array +} +``` + +Vespera respects all standard serde attributes: + +```rust +#[derive(Serialize, Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct CreateUserRequest { + pub user_name: String, // → "userName" in OpenAPI + pub email: String, + + #[serde(rename = "fullName")] + pub name: String, // → "fullName" in OpenAPI + + #[serde(skip)] + pub internal_id: u64, // excluded from schema + + pub bio: Option, // optional field +} +``` + +## Type Mapping + + + + + Rust Type + OpenAPI Schema + + + + + `String`, `&str` + `string` + + + `i8`–`i128`, `u8`–`u128` + `integer` + + + `f32`, `f64` + `number` + + + `bool` + `boolean` + + + `Vec` + `array` with items + + + `Option` + T (parent marks field as optional) + + + `HashMap` + `object` with `additionalProperties` + + + `BTreeSet`, `HashSet` + `array` with `uniqueItems: true` + + + `Uuid` + `string` with `format: uuid` + + + `Decimal` + `string` with `format: decimal` + + + `NaiveDate` + `string` with `format: date` + + + `NaiveTime` + `string` with `format: time` + + + `DateTime`, `DateTimeWithTimeZone` + `string` with `format: date-time` + + + `FieldData` + `string` with `format: binary` + + + `()` + empty response (204 No Content) + + + Custom struct + `$ref` to `components/schemas` + + +
+ +## Generic Types + +All type parameters must also derive `Schema`: + +```rust +#[derive(Schema)] +struct Paginated { + items: Vec, + total: u32, + page: u32, +} +``` + +## SeaORM Integration + +`schema_type!` has first-class support for SeaORM models. Relation fields are converted automatically: + +```rust +#[derive(Clone, Debug, DeriveEntityModel)] +#[sea_orm(table_name = "memos")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub title: String, + pub user_id: i32, + pub user: BelongsTo, // → Option> + pub comments: HasMany, // → Vec + pub created_at: DateTimeWithTimeZone, // → chrono::DateTime +} + +vespera::schema_type!(Schema from Model, name = "MemoSchema"); +``` + + + + + SeaORM Type + Generated Schema Type + + + + + `HasOne` + `Box` or `Option>` + + + `BelongsTo` + `Option>` + + + `HasMany` + `Vec` + + + `DateTimeWithTimeZone` + `chrono::DateTime` + + +
+ +Circular references (e.g. User ↔ Memo) are detected automatically and handled by inlining fields to prevent infinite recursion. + +## Database Defaults in OpenAPI + +Fields with SeaORM database defaults get `default` values in the generated schema: + +| SeaORM Attribute | OpenAPI Default | +|-----------------|-----------------| +| `primary_key` (Uuid) | `"00000000-0000-0000-0000-000000000000"` | +| `primary_key` (i32/i64) | `0` | +| `default_value = "NOW()"` | `"1970-01-01T00:00:00+00:00"` | +| `default_value = "gen_random_uuid()"` | `"00000000-0000-0000-0000-000000000000"` | +| `default_value = "true"` | `true` | + +> `required` is determined solely by nullability (`Option`). Fields with defaults are still `required` unless they are `Option`. + +## Configuring the OpenAPI Output + +Pass parameters to `vespera!()` to control the spec: + +```rust +let app = vespera!( + openapi = "openapi.json", // write spec to this file at compile time + title = "My API", + version = "1.0.0", + docs_url = "/docs", // Swagger UI + redoc_url = "/redoc", // ReDoc + servers = [ + { url = "https://api.example.com", description = "Production" }, + { url = "http://localhost:3000", description = "Development" } + ] +); +``` + +See [vespera! Macro](/documentation/api/api-1) for the full parameter reference. diff --git a/apps/landing/src/app/documentation/[...name]/concept.concept-3.mdx b/apps/landing/src/app/documentation/[...name]/concept.concept-3.mdx index 7b4d68d7..4d7c597b 100644 --- a/apps/landing/src/app/documentation/[...name]/concept.concept-3.mdx +++ b/apps/landing/src/app/documentation/[...name]/concept.concept-3.mdx @@ -1 +1,132 @@ -empty \ No newline at end of file +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@/components/mdx/components/Table' + +# `Validated` and 422 + +`Validated` is a Vespera extractor wrapper that runs [`garde`](https://crates.io/crates/garde) validation **before** your handler is called. Invalid requests are rejected with `422 Unprocessable Entity` and a canonical JSON error envelope — no per-handler error mapping, no boilerplate. + +## Basic Usage + +Add `garde` to your dependencies: + +```toml +[dependencies] +vespera = "0.1" +garde = { version = "0.20", features = ["derive"] } +``` + +Annotate your request type with `garde` constraints and derive `Validate`: + +```rust +use vespera::{Validated, Schema, axum::Json}; +use garde::Validate; + +#[derive(serde::Deserialize, Schema, Validate)] +pub struct CreateUser { + #[garde(length(min = 3, max = 32))] + pub username: String, + #[garde(email)] + pub email: String, + #[garde(range(min = 18, max = 120))] + pub age: u8, +} + +#[vespera::route(post, tags = ["users"])] +pub async fn create_user( + Validated(Json(req)): Validated>, +) -> Json<&'static str> { + // `req` has already passed garde validation — no manual checks needed. + Json("ok") +} +``` + +## 422 Response Envelope + +When validation fails, Vespera returns `HTTP 422 Unprocessable Entity` with this JSON body: + +```json +{ + "errors": [ + { "path": "username", "message": "length is lower than 3" }, + { "path": "email", "message": "not a valid email" } + ] +} +``` + +The envelope is identical regardless of which extractor failed — your API clients only need to handle one error shape. + +## Supported Extractors + +`Validated` works with every common Axum extractor: + + + + + Extractor + Validates + + + + + `Validated>` + JSON request body + + + `Validated>` + URL-encoded form body + + + `Validated>` + URL query parameters + + + `Validated>` + Path parameters + + +
+ +## JNI Hoisting + +Under JNI, the same `422` body is **hoisted** into the binary wire header as `"validation_errors": [...]`. Java decoders can read validation errors directly from the header without parsing the response body — no special-casing needed on the Java side. + +```json +{ + "v": 1, + "status": 422, + "headers": { "content-type": "application/json" }, + "validation_errors": [ + { "path": "username", "message": "length is lower than 3" } + ] +} +``` + +## Common garde Constraints + +```rust +#[derive(Deserialize, Schema, Validate)] +pub struct UpdateProfile { + #[garde(length(min = 1, max = 100))] + pub display_name: String, + + #[garde(url)] + pub website: Option, + + #[garde(length(min = 8))] + pub password: String, + + #[garde(range(min = 0.0, max = 5.0))] + pub rating: f64, + + #[garde(inner(length(min = 1)))] + pub tags: Vec, +} +``` + +See the [garde documentation](https://docs.rs/garde) for the full list of available constraints. diff --git a/apps/landing/src/app/documentation/[...name]/concept.mdx b/apps/landing/src/app/documentation/[...name]/concept.mdx index e69de29b..633b7952 100644 --- a/apps/landing/src/app/documentation/[...name]/concept.mdx +++ b/apps/landing/src/app/documentation/[...name]/concept.mdx @@ -0,0 +1,50 @@ +# Core Concepts + +Vespera is built on three ideas: file-based routing, compile-time schema extraction, and automatic request validation. + +## File-Based Routing + +Your folder structure becomes your URL structure. Drop a `pub async fn` with `#[vespera::route]` anywhere in `src/routes/` and Vespera discovers it at compile time — no manual router registration. + +``` +src/routes/ +├── mod.rs → / +├── users.rs → /users +├── posts.rs → /posts +└── admin/ + ├── mod.rs → /admin + └── stats.rs → /admin/stats +``` + +See [File-Based Routing](/documentation/concept/concept-1) for the full rules. + +## Schema & OpenAPI Generation + +Derive `Schema` on any Rust type and Vespera includes it in the generated OpenAPI 3.1 spec. Serde attributes (`rename_all`, `rename`, `skip`, `default`) are respected automatically. + +```rust +#[derive(Serialize, Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct CreateUserRequest { + pub user_name: String, // → "userName" in OpenAPI + pub email: String, + pub bio: Option, // optional field +} +``` + +See [Schema & OpenAPI](/documentation/concept/concept-2) for type mapping and SeaORM integration. + +## `Validated` and 422 + +Wrap any extractor in `Validated` to run `garde` validation before the handler runs. Invalid requests are rejected with `422 Unprocessable Entity` and a canonical JSON error envelope — no per-handler error mapping needed. + +```rust +#[vespera::route(post)] +pub async fn create_user( + Validated(Json(req)): Validated>, +) -> Json<&'static str> { + Json("ok") +} +``` + +See [Validated & 422](/documentation/concept/concept-3) for the full contract. diff --git a/apps/landing/src/app/documentation/[...name]/features.mdx b/apps/landing/src/app/documentation/[...name]/features.mdx index 7b4d68d7..0a323484 100644 --- a/apps/landing/src/app/documentation/[...name]/features.mdx +++ b/apps/landing/src/app/documentation/[...name]/features.mdx @@ -1 +1,171 @@ -empty \ No newline at end of file +# Features + +Beyond routing and OpenAPI generation, Vespera ships several production-ready features that integrate with the same compile-time discovery system. + +## Cron Jobs + +Schedule background tasks with `#[vespera::cron]`. Jobs are auto-discovered like routes — no extra registration needed. + +### Enable the Feature + +```toml +[dependencies] +vespera = { version = "0.1", features = ["cron"] } +``` + +### Define Jobs + +Place `#[vespera::cron("...")]` on any `pub async fn` with zero parameters. The function can live anywhere in your project: + +```rust +// src/cron/cleanup.rs, src/tasks.rs, or even src/routes/users.rs — anywhere works +#[vespera::cron("1/10 * * * * *")] +pub async fn cleanup_sessions() { + println!("Running cleanup every 10 seconds"); +} + +#[vespera::cron("0 0 * * * *")] +pub async fn hourly_report() { + println!("Running hourly report"); +} +``` + +No extra config in `vespera!()` — jobs are discovered and started automatically: + +```rust +let app = vespera!(docs_url = "/docs"); +// Background scheduler starts when the app starts +``` + +### Cron Expression Format + +Uses 6-field cron expressions (`sec min hour day month weekday`): + +| Expression | Schedule | +|-----------|----------| +| `0 */5 * * * *` | Every 5 minutes | +| `0 0 * * * *` | Every hour | +| `0 0 0 * * *` | Daily at midnight | +| `1/10 * * * * *` | Every 10 seconds | +| `0 30 9 * * Mon-Fri` | Weekdays at 9:30 AM | + +### Requirements + +- Functions must be `pub async fn` +- Functions must take **no parameters** (no `State`, no extractors) +- The `cron` feature must be enabled in `Cargo.toml` + +--- + +## Multipart Form Data + +### Typed Multipart (Recommended) + +Use `TypedMultipart` for file uploads with a statically-known schema. Vespera generates `multipart/form-data` content type in OpenAPI and maps `FieldData` to `{ "type": "string", "format": "binary" }`: + +```rust +use vespera::multipart::{FieldData, TypedMultipart}; +use vespera::{Multipart, Schema}; +use tempfile::NamedTempFile; + +#[derive(Multipart, Schema)] +pub struct CreateUploadRequest { + pub name: String, + #[form_data(limit = "10MiB")] + pub file: Option>, +} + +#[vespera::route(post, tags = ["uploads"])] +pub async fn create_upload( + TypedMultipart(req): TypedMultipart, +) -> Json { ... } +``` + +### Raw Multipart (Untyped) + +For dynamic fields not known at compile time, use Axum's built-in `Multipart` extractor. Vespera generates a generic `{ "type": "object" }` schema: + +```rust +use vespera::axum::extract::Multipart; + +#[vespera::route(post, tags = ["uploads"])] +pub async fn upload(mut multipart: Multipart) -> Json { + while let Some(field) = multipart.next_field().await.unwrap() { + let name = field.name().unwrap_or("unknown").to_string(); + let data = field.bytes().await.unwrap(); + // Process each field dynamically... + } + Json(UploadResponse { success: true }) +} +``` + +--- + +## Merging Multiple Vespera Apps + +Combine routes and OpenAPI specs from multiple crates at compile time. Useful for splitting a large API into separate crates while presenting a single unified spec. + +### Export a Child App + +```rust +// In the child crate's src/lib.rs +mod routes; + +// Export for merging (scans "routes" folder by default) +vespera::export_app!(ThirdApp); + +// Or with a custom directory +vespera::export_app!(ThirdApp, dir = "api"); +``` + +This generates: +- `ThirdApp::OPENAPI_SPEC: &'static str` — the child's OpenAPI JSON +- `ThirdApp::router() -> Router` — the child's Axum router + +### Merge in the Parent App + +```rust +use vespera::vespera; + +let app = vespera!( + openapi = "openapi.json", + docs_url = "/docs", + merge = [third::ThirdApp, other::OtherApp] +) +.with_state(app_state); +``` + +Vespera automatically: +- Merges all child routes into the parent router +- Combines OpenAPI specs (paths, schemas, tags) into a single document +- Makes Swagger UI show all routes from all apps + +--- + +## Multi-App Routing (JNI) + +When embedding Vespera in a Java/Spring application via JNI, you can register multiple independent apps and route between them per request. + +```rust +pub fn create_app() -> axum::Router { vespera!(title = "Default") } +pub fn admin_app() -> axum::Router { vespera!(dir = "admin_routes", title = "Admin") } +pub fn public_app() -> axum::Router { vespera!(dir = "public_routes", title = "Public") } + +vespera::jni_apps! { + "_default" => create_app, + "admin" => admin_app, + "public" => public_app, +} +``` + +The Java side selects an app per request via the `X-Vespera-App` header (configurable): + +```bash +# Default app (no header) +curl http://localhost:8080/health + +# Admin app +curl -H "X-Vespera-App: admin" http://localhost:8080/dashboard +``` + +See [Streaming & Multi-App](/documentation/theme/theme-3) for the full multi-app routing reference. diff --git a/apps/landing/src/app/documentation/[...name]/installation.mdx b/apps/landing/src/app/documentation/[...name]/installation.mdx index 7b4d68d7..7582e873 100644 --- a/apps/landing/src/app/documentation/[...name]/installation.mdx +++ b/apps/landing/src/app/documentation/[...name]/installation.mdx @@ -1 +1,124 @@ -empty \ No newline at end of file +# Installation + +Get Vespera running in your Axum project in under five minutes. + +## 1. Add Dependencies + +```toml +[dependencies] +vespera = "0.1" +axum = "0.8" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +``` + +> Vespera re-exports `axum` — use `vespera::axum` in your code instead of depending on `axum` directly. This keeps the version in sync automatically. + +## 2. Create Your First Route + +Create the routes folder and add a handler: + +``` +src/ +├── main.rs +└── routes/ + └── users.rs +``` + +**`src/routes/users.rs`**: + +```rust +use vespera::axum::{Json, extract::Path}; +use serde::{Deserialize, Serialize}; +use vespera::Schema; + +#[derive(Serialize, Deserialize, Schema)] +pub struct User { + pub id: u32, + pub name: String, +} + +/// Get user by ID +#[vespera::route(get, path = "/{id}", tags = ["users"])] +pub async fn get_user(Path(id): Path) -> Json { + Json(User { id, name: "Alice".into() }) +} + +/// Create a new user +#[vespera::route(post, tags = ["users"])] +pub async fn create_user(Json(user): Json) -> Json { + Json(user) +} +``` + +## 3. Set Up `main.rs` + +```rust +use vespera::{vespera, Serve}; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + println!("Swagger UI: http://localhost:3000/docs"); + vespera!( + openapi = "openapi.json", + title = "My API", + docs_url = "/docs" + ) + .serve("0.0.0.0:3000") + .await +} +``` + +`.serve(addr)` is a Vespera extension trait on `axum::Router`. It replaces the usual `TcpListener::bind` + `axum::serve(...)` dance with a single chained call. `addr` accepts anything `tokio::net::ToSocketAddrs` takes — strings, tuples, or `SocketAddr`. + +## 4. Run + +```bash +cargo run +# Open http://localhost:3000/docs +``` + +Your Swagger UI is live. The `openapi.json` file is written to the project root at compile time. + +## Adding State and Middleware + +Chain standard Axum methods after `vespera!()`: + +```rust +let app = vespera!(docs_url = "/docs") + .with_state(AppState { db: pool }) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()); +``` + +## JNI / Java Integration + +To embed Vespera inside a Java/Spring application, enable the `jni` feature: + +```toml +[dependencies] +vespera = { version = "0.1", features = ["jni"] } +``` + +Then add two lines to your Rust lib: + +```rust +pub fn create_app() -> vespera::axum::Router { + vespera!(title = "My API") +} + +vespera::jni_app!(create_app); +``` + +See the [JNI / Java Integration](/documentation/theme) section for the full setup guide. + +## Cron Jobs + +Enable the `cron` feature to schedule background tasks: + +```toml +[dependencies] +vespera = { version = "0.1", features = ["cron"] } +``` + +See [Features](/documentation/features) for usage details. diff --git a/apps/landing/src/app/documentation/[...name]/overview.mdx b/apps/landing/src/app/documentation/[...name]/overview.mdx index 2f40a4bc..0dba9e87 100644 --- a/apps/landing/src/app/documentation/[...name]/overview.mdx +++ b/apps/landing/src/app/documentation/[...name]/overview.mdx @@ -7,209 +7,176 @@ import { TableRow, } from '@/components/mdx/components/Table' -export const metadata = { - title: 'What is Devup UI?', - alternates: { - canonical: '/docs/overview', - }, -} +# What is Vespera? -## What is Devup UI? +**FastAPI-like developer experience for Rust.** Zero-config OpenAPI 3.1 generation for Axum. -**Devup UI is not just another CSS-in-JS library — it's the future of CSS-in-JS itself.** - -Devup UI is a zero-runtime CSS-in-JS preprocessor powered by Rust and WebAssembly. It transforms all your styles at build time, completely eliminating runtime overhead while providing full CSS-in-JS syntax coverage. - -### The Problem with Traditional CSS-in-JS - -Traditional CSS-in-JS solutions force you to choose between: - -- **Developer Experience**: Intuitive APIs, co-located styles, dynamic theming -- **Performance**: No runtime overhead, fast page loads, optimal Core Web Vitals - -Libraries like styled-components and Emotion offer great DX but execute JavaScript at runtime to generate styles. Zero-runtime alternatives like Vanilla Extract sacrifice some flexibility for performance. - -### The Devup UI Solution - -Devup UI eliminates this trade-off entirely. Our Rust-powered preprocessor analyzes your code at build time and handles every CSS-in-JS pattern: +```rust +// That's it. Swagger UI at /docs, OpenAPI at openapi.json +let app = vespera!(openapi = "openapi.json", docs_url = "/docs"); +``` -- **Variables** — Dynamic values become CSS custom properties -- **Conditionals** — Ternary expressions are statically analyzed -- **Responsive Arrays** — Breakpoint-based styles are pre-generated -- **Pseudo Selectors** — `_hover`, `_focus`, `_active` work seamlessly -- **Themes** — Type-safe theme tokens with zero-cost switching +Vespera scans your `src/routes/` folder at compile time, extracts every `#[vespera::route]` handler and `#[derive(Schema)]` type, and assembles a complete OpenAPI 3.1 spec — no annotations to maintain, no runtime reflection, no hand-written JSON. -### Key Advantages +## Why Vespera? Feature - Devup UI - styled-components - Emotion - Vanilla Extract + Vespera + Manual Approach - Zero Runtime - Yes - No - No - Yes + Route registration + Automatic (file-based) + Manual `Router::new().route(...)` + + + OpenAPI spec + Generated at compile time + Hand-written or runtime generation - Dynamic Values - Yes - Yes - Yes - Limited + Schema extraction + `#[derive(Schema)]` on Rust types + Manual JSON Schema - Full Syntax Coverage - Yes - Yes - Yes - No + Request validation + `Validated` extractor → auto `422` + Manual checks in every handler - Type-Safe Themes - Yes - Limited - Limited - Yes + Server startup + `.serve("0.0.0.0:3000")` one-liner + `TcpListener::bind` + `axum::serve` - Build Performance - Fastest - N/A - N/A - Fast + Swagger UI + Built-in + Separate setup + + + Type safety + Compile-time verified + Runtime errors
-### How It Works - -```tsx -// You write familiar CSS-in-JS syntax -const example = - -// Devup UI transforms it at build time -const generated =
- -// With optimized atomic CSS -// .a { background-color: red; } -// .b { padding: 16px; } /* 4 * 4 = 16px */ -// .c:hover { background-color: blue; } -``` - -> Numeric values are multiplied by 4. `p={4}` becomes `padding: 16px`. - -Class names use compact base-37 encoding (`a`, `b`, ... `z`, `_`, `aa`, `ab`, ...) for minimal CSS output. - -### Familiar API - -If you've used styled-components or Emotion, you'll feel right at home: - -```tsx -import { styled } from '@devup-ui/react' - -const Card = styled('div', { - bg: 'white', - p: 4, // 4 * 4 = 16px - borderRadius: '8px', - boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)', - _hover: { - boxShadow: '0 10px 15px rgba(0, 0, 0, 0.1)', - }, -}) -``` - -### Proven Performance - -Benchmarks on Next.js (GitHub Actions - ubuntu-latest): +## Headline Capabilities - Library - Version - Build Time - Build Size + Capability + How - tailwindcss - 4.1.13 - 19.31s - 59,521,539 bytes + `#[derive(Schema)]` → OpenAPI 3.1 + Rust types become JSON Schema at compile time, including serde renames, `Option`, `Vec`, SeaORM relations + + + `Validated` extractor + auto-`422` + Wraps `Json`/`Form`/`Query`/`Path` and runs `garde::Validate` before the handler — rejection is `422` with a canonical JSON envelope - styleX - 0.15.4 - 41.78s - 86,869,452 bytes + `schema_type! { ... }` + Derive request/response DTOs from existing structs (`pick` / `omit` / `partial` / `add` / `multipart` / `omit_default`) with first-class SeaORM relation support - vanilla-extract - 1.17.4 - 19.50s - 61,494,033 bytes + One-liner `.serve(addr)` + Extension trait on `axum::Router` — replaces `TcpListener::bind` + `axum::serve` boilerplate - kuma-ui - 1.5.9 - 20.93s - 69,924,179 bytes + JNI / Spring integration + Embed your Axum router inside a Java/Spring app in-process — no TCP, no base64, raw bytes end to end - panda-css - 1.3.1 - 20.64s - 64,573,260 bytes + Cron jobs + `#[vespera::cron("...")]` — auto-discovered like routes, runs via `tokio-cron-scheduler` + +
+ +## JNI Performance Numbers + +When embedding Vespera inside a Java/Spring application via JNI, the `SmartDispatchModeResolver` (default since vespera-bridge 0.2.0) picks the cheapest safe path per request. Measured on a `GET /health` round-trip through the real JNI boundary (AMD Ryzen 9 9950X, Java 21, Windows 11): + + + - chakra-ui - 3.27.0 - 28.81s - 222,435,802 bytes + Request shape + Mode + ns / round-trip + + - mui - 7.3.2 - 20.86s - 97,964,458 bytes + Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB) + `DIRECT` (pooled direct buffers) + ~2,200 ns - **devup-ui (per-file css)** - **1.0.18** - **16.90s** - 59,540,459 bytes + Small (≤ 256 KiB) + non-idempotent (POST/PATCH) + `SYNC` (heap-buffered) + ~3,200 ns - **devup-ui (single css)** - **1.0.18** - **17.05s** - **59,520,196 bytes** + Large or unknown-length body + `BIDIRECTIONAL_STREAMING` + ~24,100 ns + + +
+ +Binary streaming throughput (64 MiB payload, bidirectional): + + + + + Chunk size + Throughput + + + + + 16 KiB + ~10,408 MiB/s - tailwindcss (turbopack) - 4.1.13 - 6.72s - 5,355,082 bytes + 64 KiB + ~11,587 MiB/s - **devup-ui (single css + turbopack)** - **1.0.18** - 10.34s - **4,772,050 bytes** + 256 KiB + ~14,458 MiB/s
-### Get Started +The `direct_pooled` path completes a tiny `/health` round-trip in **2,349 ns/op** — **1.55× faster** than the pre-0.2.0 sync baseline (3,643 ns/op). + +## How It Works + +``` +src/routes/ +├── mod.rs → / +├── users.rs → /users +└── admin/ + └── stats.rs → /admin/stats +``` + +1. You place `pub async fn` handlers in `src/routes/` and annotate them with `#[vespera::route]`. +2. The `vespera!()` macro scans the folder at compile time, discovers every handler, and builds an `axum::Router`. +3. Types annotated with `#[derive(Schema)]` are extracted into OpenAPI component schemas automatically. +4. The generated `openapi.json` and Swagger UI are served at the URLs you configure. + +## Get Started -Ready to experience the future of CSS-in-JS? Head to the [Installation](/docs/installation) guide to get started in minutes. +Head to [Installation](/documentation/installation) to add Vespera to your project in under five minutes. diff --git a/apps/landing/src/app/documentation/[...name]/theme.mdx b/apps/landing/src/app/documentation/[...name]/theme.mdx index 7b4d68d7..8a0ab0d4 100644 --- a/apps/landing/src/app/documentation/[...name]/theme.mdx +++ b/apps/landing/src/app/documentation/[...name]/theme.mdx @@ -1 +1,47 @@ -empty \ No newline at end of file +# JNI / Java Integration + +Vespera can embed your Axum router directly inside a Java/Spring application — no TCP socket, no JSON envelope overhead, raw bytes from end to end. + +The `vespera-bridge` library (`kr.devfive:vespera-bridge`) provides a Spring Boot autoconfiguration that wires up a catch-all `VesperaProxyController`. Every HTTP request Spring receives is forwarded to Rust through a length-prefixed binary wire format, and the response comes back the same way. + +## Why In-Process? + +A traditional microservice setup adds a full HTTP round-trip between Java and Rust. In-process JNI dispatch eliminates that entirely: + +- No TCP connection overhead +- No JSON serialization of the envelope +- Binary bodies (multipart, PDFs, images) travel as raw bytes — no base64 +- Measured latency for small requests: **~2,200 ns** with the `DIRECT` dispatch mode + +## Quick Navigation + +- [jni_app! & VesperaBridge](/documentation/theme/theme-1) — Rust setup, Java setup, native library loading +- [Dispatch Modes & Wire Format](/documentation/theme/theme-2) — all seven dispatch methods, binary wire layout, `SmartDispatchModeResolver` defaults +- [Streaming & Multi-App](/documentation/theme/theme-3) — streaming tuning, multi-app routing, virtual thread notes, 0.2.0 breaking changes + +## Two-Line Integration + +**Rust side:** + +```rust +pub fn create_app() -> vespera::axum::Router { + vespera!(title = "My API") +} + +vespera::jni_app!(create_app); +``` + +**Java side:** + +```java +@SpringBootApplication +@ComponentScan(basePackages = {"com.example.app", "com.devfive.vespera.bridge"}) +public class MyApp { + public static void main(String[] args) { + VesperaBridge.init("my_rust_lib"); + SpringApplication.run(MyApp.class, args); + } +} +``` + +That's it. `VesperaProxyController` is autoconfigured and forwards every HTTP request to Rust. Zero controller code, zero `application.yml` config, zero extra imports beyond the Spring Boot starter. diff --git a/apps/landing/src/app/documentation/[...name]/theme.theme-1.mdx b/apps/landing/src/app/documentation/[...name]/theme.theme-1.mdx index 7b4d68d7..7a13296b 100644 --- a/apps/landing/src/app/documentation/[...name]/theme.theme-1.mdx +++ b/apps/landing/src/app/documentation/[...name]/theme.theme-1.mdx @@ -1 +1,191 @@ -empty \ No newline at end of file +# jni_app! & VesperaBridge + +## Rust Setup + +### 1. Enable the JNI Feature + +```toml +[dependencies] +vespera = { version = "0.1", features = ["jni"] } +``` + +The `jni` feature implies `inprocess` — both are enabled automatically. + +### 2. Export Your App + +In your cdylib crate's `src/lib.rs`: + +```rust +use vespera::{axum, vespera}; + +pub fn create_app() -> axum::Router { + vespera!(title = "My API", version = "1.0.0") +} + +// Single app — generates JNI_OnLoad and the dispatch symbol +vespera::jni_app!(create_app); +``` + +`jni_app!` generates all JNI boilerplate: `JNI_OnLoad`, the Tokio runtime, and the seven dispatch symbols. You write zero JNI code. + +### 3. Build as a cdylib + +```toml +[lib] +crate-type = ["cdylib"] +``` + +```bash +cargo build --release +# Produces: target/release/libmy_rust_lib.so (Linux) +# target/release/my_rust_lib.dll (Windows) +# target/release/libmy_rust_lib.dylib (macOS) +``` + +--- + +## Java Setup + +### Maven + +```xml + + kr.devfive + vespera-bridge + 0.2.0 + +``` + +### Gradle (Kotlin DSL) + +```kotlin +dependencies { + implementation("kr.devfive:vespera-bridge:0.2.0") +} +``` + +### Gradle Plugin (Recommended) + +The `kr.devfive.vespera-bridge` Gradle plugin replaces ~22 lines of native-library-bundling boilerplate with a 5-line block: + +```kotlin +plugins { + id("kr.devfive.vespera-bridge") version "0.1.1" +} + +vespera { + crateName.set("my_rust_lib") + cargoRoot.set(rootProject.layout.projectDirectory.dir("../..")) + bridgeVersion.set("0.2.0") +} +``` + +The plugin auto-wires `bundleNativeLib` (cdylib → `resources/native/-/`), the `processResources` dependency, and the `vespera-bridge` implementation dependency. + +### Spring Boot Application + +```java +@SpringBootApplication +@ComponentScan(basePackages = {"com.example.app", "com.devfive.vespera.bridge"}) +public class MyApp { + public static void main(String[] args) { + VesperaBridge.init("my_rust_lib"); // loads cdylib (bundled or system path) + SpringApplication.run(MyApp.class, args); + } +} +``` + +`VesperaProxyController` is autoconfigured via Spring Boot's `AutoConfiguration.imports`. It registers a `@RequestMapping("/**")` catch-all that forwards every HTTP request to Rust. The routes published in Vespera's generated `openapi.json` are reachable at the same URLs through Spring. + +--- + +## Native Library Loading + +`VesperaBridge.init("crateName")` tries two paths in order: + +1. **Bundled** — looks up `native/{os}-{arch}/{libname}` inside the running JAR's classpath. If found, the file is extracted to a temp file (auto-deleted on JVM exit) and loaded via `System.load`. +2. **Fallback** — `System.loadLibrary("crateName")` searches `java.library.path`. + +Supported platform triples: `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64`. + +Place the cdylib at `src/main/resources/native/{os}-{arch}/` to bundle it inside the JAR for single-file deployment. + +--- + +## Zero-Config Defaults + +Out of the box the autoconfigure module wires up: + +| Concern | Default | Override | +|---------|---------|----------| +| App selection | Read `X-Vespera-App` request header; absent → default app | Property `vespera.bridge.app-header`, or custom `AppNameResolver` bean | +| Dispatch mode | `SmartDispatchModeResolver` since 0.2.0 — `DIRECT` for small/bodyless idempotent, `SYNC` for small non-idempotent, `BIDIRECTIONAL_STREAMING` for the rest | Property `vespera.bridge.dispatch-mode: bidirectional-streaming`, or custom `DispatchModeResolver` bean | +| URL pattern | `@RequestMapping("/**")` catch-all | Set `vespera.bridge.controller-enabled: false` and supply your own controller | + +--- + +## Customization + +### Tweak via application.yml + +```yaml +vespera: + bridge: + app-header: X-My-App # change the header that selects the app + controller-enabled: true # set false to disable the proxy controller +``` + +### Custom App-Selection Strategy + +```java +@Bean +public AppNameResolver myAppResolver() { + return request -> { + String uri = request.getRequestURI(); + if (uri.startsWith("/admin/")) return "admin"; + if (uri.startsWith("/public/")) return "public"; + return null; // default app + }; +} +``` + +Spring's `@ConditionalOnMissingBean` automatically disables `HeaderAppNameResolver` when you supply your own bean. + +### Custom Dispatch-Mode Policy + +```java +@Bean +public DispatchModeResolver myModeResolver() { + return request -> { + long contentLength = request.getContentLengthLong(); + if (contentLength >= 0 && contentLength < 4096 + && "application/json".equals(request.getContentType())) { + return DispatchMode.SYNC; + } + return DispatchMode.BIDIRECTIONAL_STREAMING; + }; +} +``` + +### BYO Controller + +```yaml +vespera: + bridge: + controller-enabled: false +``` + +```java +@RestController +public class MyController { + @PostMapping("/api/admin/{path}") + public ResponseEntity adminRoute(@PathVariable String path, @RequestBody byte[] body) { + byte[] wire = VesperaBridge.encodeRequest( + "admin", "POST", "/" + path, null, + Map.of("content-type", "application/json"), body); + byte[] resp = VesperaBridge.dispatchBytes(wire); + DecodedResponse d = VesperaBridge.decodeResponse(resp); + return ResponseEntity.status(d.status()).body(d.bodyBytes()); + } +} +``` diff --git a/apps/landing/src/app/documentation/[...name]/theme.theme-2.mdx b/apps/landing/src/app/documentation/[...name]/theme.theme-2.mdx index 7b4d68d7..bd5eb08e 100644 --- a/apps/landing/src/app/documentation/[...name]/theme.theme-2.mdx +++ b/apps/landing/src/app/documentation/[...name]/theme.theme-2.mdx @@ -1 +1,211 @@ -empty \ No newline at end of file +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from '@/components/mdx/components/Table' + +# Dispatch Modes & Wire Format + +## Binary Wire Format + +Both request and response use the same length-prefixed layout: + +``` +bytes 0..4 : u32 BE = header_json byte length N +bytes 4..4+N : UTF-8 JSON + (request) { "v":1, "method", "path", + "query"?, "headers"? } + (response) { "v":1, "status", "headers", + "metadata", "validation_errors"? } +bytes 4+N.. : raw body bytes (UTF-8 text or binary — + no encoding applied) +``` + +Key properties: +- No base64 — multipart uploads, PDFs, and images travel as raw bytes +- `"v":1` is the protocol version; mismatched versions return a `400` wire response +- `"validation_errors"` is an optional array hoisted from `422` JSON bodies — Java decoders read validation errors from the header without parsing the body +- All failure paths (malformed wire, Rust panic, no app registered) return a valid length-prefixed response, so the decoder never has to special-case errors + +## Dispatch Modes + +`VesperaBridge` exposes seven native methods — all sharing the same wire format, the same registered router, and the same panic-safe `catch_unwind` discipline: + + + + + Method + Mode + Java return + Memory + + + + + `dispatchBytes(byte[])` + sync + `byte[]` (header + body) + full body in memory + + + `dispatchAsync(CompletableFuture, byte[])` + async + `void` (future completes) + full body in memory + + + `dispatchStreaming(byte[], OutputStream)` + sync, response-streaming + `byte[]` (header only) + chunk-bounded response + + + `dispatchFullStreaming(byte[], InputStream, OutputStream)` + sync, bidirectional streaming + `byte[]` (header only) + chunk-bounded both ways + + + `dispatchStreamingWithHeader(byte[], Consumer, OutputStream)` + sync, response-streaming + `void` (header via callback) + chunk-bounded response + + + `dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream)` + sync, bidirectional streaming + `void` (header via callback) + chunk-bounded both ways + + + `dispatchDirect(ByteBuffer, int, ByteBuffer)` + sync, direct buffers + `int` (response length / overflow code) + no Java heap arrays + + +
+ +### Choosing a Mode + +- Small JSON RPC, single request/response → `dispatchBytes` +- Hot small/bounded payloads where JNI copy overhead matters → `dispatchDirect` / `dispatchDirectPooled` +- Async I/O coordination (parallel Java requests, non-blocking) → `dispatchAsync` + `CompletableFuture` +- Large download / streaming response (video, PDF, SSE) → `dispatchStreaming` + `OutputStream` +- Large upload + large download (file transfer, video transcoding) → `dispatchFullStreaming` + `InputStream` + `OutputStream` +- The `*WithHeader` variants let Spring-style controllers commit status/headers before the first body byte is written + +## SmartDispatchModeResolver (Default since 0.2.0) + +The autoconfigured default since vespera-bridge 0.2.0 picks the cheapest safe path per request. Measured on a `GET /health` round-trip through the real JNI boundary: + +| Request shape | Mode | ns / round-trip | +|---------------|------|-----------------| +| Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB) | `DIRECT` | ~2,200 | +| Small (≤ 256 KiB Content-Length) + non-idempotent (POST/PATCH) | `SYNC` | ~3,200 | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | ~24,100 | + +Trade-offs: +- **DIRECT** writes the wire response straight into a pooled per-thread direct `ByteBuffer` (64 KiB → `vespera.direct.maxBufferBytes`, default 4 MiB). Responses larger than the pooled buffer trigger a single retry that **re-runs the Rust handler** — which is why DIRECT is gated on idempotent methods only. +- **SYNC** fully buffers the response on the JVM heap. The 256 KiB request-size gate keeps the response size reasonable for JSON-RPC-shaped traffic. +- **BIDIRECTIONAL_STREAMING** is unchanged for large/unknown-length bodies — multi-GB upload + multi-GB download runs chunk-bounded, ~32 KiB resident each side. + +Restore the pre-0.2.0 default (every request that may carry a body streams both ways, ~24 µs uniform): + +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming +``` + +## Direct Buffer Dispatch + +`dispatchDirect(ByteBuffer in, int inLen, ByteBuffer out)` eliminates the two JNI `GetByteArrayRegion`/`SetByteArrayRegion` copies that `dispatchBytes` pays. The response is streamed straight into the out buffer — no intermediate `Vec`. Measured at **1.4–3.4× per round-trip** versus `dispatchBytes` depending on payload size. + +Contract: +- Both buffers MUST be direct (`ByteBuffer.allocateDirect`); heap buffers are rejected with `IllegalArgumentException` +- The request is read from absolute offsets `in[0..inLen]` — the buffer's position/limit are ignored; `inLen` is authoritative +- Return `>= 0`: a complete wire response occupies `out[0..n]` +- Return `< 0`: `-(requiredSize)` — the response did not fit; **retrying re-runs the Rust handler**, so only retry idempotent requests +- `Integer.MIN_VALUE`: response exceeds 2 GiB + +`dispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow)` wraps the raw call with per-thread reusable direct buffers (64 KiB initial, doubling up to `vespera.direct.maxBufferBytes`, default 4 MiB). + +## Direct API (Without the Proxy Controller) + +```java +import com.devfive.vespera.bridge.VesperaBridge; +import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse; + +// 1. Initialise once at startup +VesperaBridge.init("my_rust_lib"); + +// 2. Encode a request +byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", + "/documents/validate", + /* query */ null, + Map.of("content-type", "application/json"), + "{\"title\":\"…\"}".getBytes(StandardCharsets.UTF_8)); + +// 3. Dispatch through Rust +byte[] wireResponse = VesperaBridge.dispatchBytes(wireRequest); + +// 4. Decode +DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse); +System.out.println(resp.status()); // 200 +System.out.println(resp.headers()); // { "content-type": "application/json", … } +System.out.println(new String(resp.bodyBytes())); // copies the raw response body +``` + +> **0.2.0 breaking change:** `DecodedResponse.body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes). The owned `byte[]` materialisation moved to `DecodedResponse.bodyBytes()`. Callers that previously used `body()` as `byte[]` must switch to `bodyBytes()`. + +## Async Dispatch + +```java +CompletableFuture future = VesperaBridge.dispatch(wireRequest); + +future.thenAccept(wireResponse -> { + DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse); + System.out.println("Status: " + resp.status()); +}); +``` + +The future is **always** completed with a valid wire response, even on Rust panics or JNI conversion failures. You will never see a dangling future. + +## Streaming Dispatch + +```java +byte[] wireRequest = VesperaBridge.encodeRequest( + "GET", "/files/large.pdf", null, Map.of(), new byte[0]); + +try (ByteArrayOutputStream sink = new ByteArrayOutputStream()) { + byte[] headerOnly = VesperaBridge.dispatchStreaming(wireRequest, sink); + DecodedResponse meta = VesperaBridge.decodeResponse(headerOnly); + System.out.println("Status: " + meta.status()); + System.out.println("Body size: " + sink.size()); +} +``` + +## Bidirectional Streaming + +```java +try (InputStream upload = Files.newInputStream(Path.of("huge.mp4")); + OutputStream download = Files.newOutputStream(Path.of("transcoded.mp4"))) { + + byte[] wireHeader = VesperaBridge.encodeRequestHeader( + "POST", "/transcode", null, + Map.of("content-type", "video/mp4")); + + byte[] respHeader = VesperaBridge.dispatchFullStreaming( + wireHeader, upload, download); + + DecodedResponse meta = VesperaBridge.decodeResponse(respHeader); + System.out.println("Status: " + meta.status()); +} +``` + +A 1 GiB upload paired with a 1 GiB download runs in low-single-digit MiB resident memory on each side. Backpressure is enforced naturally — if Axum reads slowly, `InputStream.read()` blocks on the bounded channel. diff --git a/apps/landing/src/app/documentation/[...name]/theme.theme-3.mdx b/apps/landing/src/app/documentation/[...name]/theme.theme-3.mdx index e69de29b..2129c121 100644 --- a/apps/landing/src/app/documentation/[...name]/theme.theme-3.mdx +++ b/apps/landing/src/app/documentation/[...name]/theme.theme-3.mdx @@ -0,0 +1,177 @@ +# Streaming & Multi-App + +## Streaming Tuning + +Both streaming knobs are fixed for the process lifetime once the first dispatch runs. Configuration precedence (first hit wins): + +1. **Programmatic setter** — `VesperaBridge.configureStreaming(chunkBytes, channelCapacity)` (call before or after `init`) +2. **System properties** — `vespera.streaming.chunkBytes`, `vespera.streaming.channelCapacity` +3. **Environment variables** — `VESPERA_STREAMING_CHUNK_BYTES`, `VESPERA_STREAMING_CHANNEL_CAPACITY` +4. **Built-in defaults** — 256 KiB chunk size, 16 channel slots + +| Setting | System property | Env var | Default | Range | +|---------|----------------|---------|---------|-------| +| Chunk buffer size | `vespera.streaming.chunkBytes` | `VESPERA_STREAMING_CHUNK_BYTES` | 256 KiB | 4 KiB – 8 MiB | +| Request channel slots | `vespera.streaming.channelCapacity` | `VESPERA_STREAMING_CHANNEL_CAPACITY` | 16 | 1 – 1024 | +| Tokio worker threads | `vespera.runtime.workerThreads` | `VESPERA_RUNTIME_WORKERS` | logical CPUs | 1 – 1024 | + +### Java API + +Call before `VesperaBridge.init(...)` for guaranteed precedence: + +```java +VesperaBridge.configureStreaming( + 131072, // chunkBytes: 128 KiB (clamped to 4 KiB – 8 MiB) + 32 // channelCapacity: 32 slots (clamped to 1 – 1024) +); +VesperaBridge.init("my_rust_lib"); +``` + +When called before `init()`, values are stored as pending and applied immediately after the native library loads — before any dispatch can occur. This ensures the programmatic setter beats system properties and environment variables. + +Throws `IllegalArgumentException` if `chunkBytes` is outside `[4096, 8388608]` or `channelCapacity` is outside `[1, 1024]`. + +### System Properties + +```bash +java -Dvespera.streaming.chunkBytes=131072 \ + -Dvespera.streaming.channelCapacity=32 \ + -jar app.jar +``` + +### Environment Variables + +```bash +export VESPERA_STREAMING_CHUNK_BYTES=131072 +export VESPERA_STREAMING_CHANNEL_CAPACITY=32 +java -jar app.jar +``` + +### Tuning Tips + +- Larger chunks reduce the per-chunk JNI crossing cost (one `SetByteArrayRegion` + one `OutputStream.write` per chunk) at the price of per-stream memory. 256 KiB is a reasonable ceiling for throughput-oriented deployments. +- The Tokio worker-thread knob caps Rust's shared runtime — useful when the JVM's own pools (Tomcat request threads, virtual-thread carriers) compete with Tokio for the same cores, or when a container CPU limit is lower than the host's logical CPU count. + +--- + +## Multi-App Routing + +Multi-app routing is primarily a feature for external-dispatcher scenarios — JNI (Java host picks app per request via header), WebAssembly bridge, C FFI, or any in-process embedding where the host distinguishes between multiple independent Vespera API surfaces. + +### Rust Side + +```rust +pub fn create_app() -> axum::Router { vespera!(title = "Default") } +pub fn admin_app() -> axum::Router { vespera!(dir = "admin_routes", title = "Admin") } +pub fn public_app() -> axum::Router { vespera!(dir = "public_routes", title = "Public") } + +vespera::jni_apps! { + "_default" => create_app, + "admin" => admin_app, + "public" => public_app, +} +``` + +`jni_apps!` is the primary multi-app API. `jni_app!(create_app)` is syntactic sugar for a single default app. + +### Java Side + +The default `HeaderAppNameResolver` selects an app per request via the `X-Vespera-App` header: + +```bash +# Default app (no header) +curl http://localhost:8080/health + +# Admin app +curl -H "X-Vespera-App: admin" http://localhost:8080/dashboard + +# Public app +curl -H "X-Vespera-App: public" http://localhost:8080/info +``` + +Each app's URLs are independent — the same `/users` path can mean different things in `admin` vs `public` apps. Unknown app names return `404`; invalid app names (special characters, > 64 bytes) return `400`. + +### Custom App-Selection Strategy + +```java +@Bean +public AppNameResolver myAppResolver() { + // App name from the first path segment: + // /admin/dashboard → app "admin", path "/dashboard" + // /public/info → app "public", path "/info" + return request -> { + String uri = request.getRequestURI(); + if (uri.startsWith("/admin/")) return "admin"; + if (uri.startsWith("/public/")) return "public"; + return null; // default app + }; +} +``` + +--- + +## Virtual Thread (Project Loom) Limitation + +The pooled direct-buffer methods (`dispatchDirectPooled`) use `ThreadLocal` to maintain per-thread reusable buffers. In Java 21+, `ThreadLocal` binds to the **virtual thread** (not the carrier thread) — so in a virtual-thread-per-request server, each virtual thread allocates a fresh direct buffer and loses all pooling benefit. Direct memory accumulates until the virtual thread is garbage-collected, potentially causing memory pressure under high concurrency. + +**Recommendations for virtual-thread deployments:** + +- Set `vespera.bridge.dispatch-mode=bidirectional-streaming` to opt out of the smart default, so `DIRECT` is never chosen by the autoconfigured resolver. +- Or use `dispatchBytes`, `dispatchStreaming`, or `dispatchFullStreaming` directly instead of the pooled direct variants. +- Or run dispatch on a bounded platform-thread executor (e.g. a `ForkJoinPool` with a fixed parallelism cap). +- Or lower `vespera.direct.maxBufferBytes` to reduce per-thread allocation size. + +`DispatchMode.BIDIRECTIONAL_STREAMING` is safe for virtual threads and handles all payload sizes without pooling. + +--- + +## 0.2.0 Breaking Changes + +### 1. Default DispatchModeResolver Flipped to SmartDispatchModeResolver + +Pre-0.2.0 the autoconfigured default was `BidirectionalStreamingDispatchModeResolver` — every request that may carry a body streamed both ways, ~24.1 µs per round-trip uniform. Since 0.2.0 the default is `SmartDispatchModeResolver`. + +| Request shape | Pre-0.2.0 mode | 0.2.0+ mode | +|---------------|----------------|-------------| +| Small/bodyless idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB CL or no CL) | `STREAMING` / `BIDIRECTIONAL_STREAMING` | `DIRECT` | +| Small non-idempotent (POST/PATCH, ≤ 256 KiB CL) | `BIDIRECTIONAL_STREAMING` | `SYNC` | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | `BIDIRECTIONAL_STREAMING` | + +Opt out (restore the pre-0.2.0 default): + +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming +``` + +Or register a custom `DispatchModeResolver` bean — `@ConditionalOnMissingBean` ensures it wins over both the property and the autoconfigured default. + +### 2. DecodedResponse.body() Returns ByteBuffer + +`DecodedResponse.body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes). The owned `byte[]` materialisation moved to `DecodedResponse.bodyBytes()`. + +```java +// Before 0.2.0 +byte[] body = resp.body(); + +// After 0.2.0 +byte[] body = resp.bodyBytes(); // owned copy +ByteBuffer view = resp.body(); // zero-copy view +``` + +Callers that previously consumed `body()` as `byte[]` must switch to `bodyBytes()`. + +--- + +## Migrating from the JSON-Envelope Bridge (≤ 0.0.13) + +The pre-0.0.14 bridge used `dispatch(String) → String` with base64-encoded binary bodies. + +| Before | After | +|--------|-------| +| `VesperaBridge.dispatch(json)` | `encodeRequest(...)` → `dispatchBytes(...)` → `decodeResponse(...)` | +| `body_bytes_b64` field on the response JSON | raw body bytes after the wire header (no base64) | +| ~33% size overhead on binary bodies | zero overhead | + +Existing users of `VesperaProxyController` need no code change — the controller was rewritten to the new wire path internally. Direct callers of `VesperaBridge.dispatch(String)` must update; the old method was removed in 0.0.14. diff --git a/apps/landing/src/app/page.tsx b/apps/landing/src/app/page.tsx index 6db7c656..010cf545 100644 --- a/apps/landing/src/app/page.tsx +++ b/apps/landing/src/app/page.tsx @@ -11,6 +11,7 @@ import { import { Button } from '@/components/button' import { GnbIcon } from '@/components/header/gnb-icon' import { HeaderSentinel } from '@/components/header/header-sentinel' +import { Performance } from '@/components/performance' export const metadata: Metadata = { alternates: { @@ -21,24 +22,24 @@ export const metadata: Metadata = { const EXAMPLES = [ { id: '1', - title: 'How to Use', + title: '1. Drop in a route', description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/hero.webp', + 'Write a pub async fn in src/routes/ with #[vespera::route]. The file path becomes the URL — no router wiring, no manual registration.', + imageUrl: '/images/rust-code.png', }, { id: '2', - title: 'How to Use', + title: '2. Serve with one macro', description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/join-us-bg.webp', + 'vespera!() discovers every route and cron job at compile time and generates your OpenAPI 3.1 spec. Chain .serve(addr) and Swagger UI is live at /docs.', + imageUrl: '/images/hero.webp', }, { id: '3', - title: 'How to Use', + title: '3. Embed in Spring — optional', description: - 'Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum sodales non ut ex.', - imageUrl: '/images/code.webp', + 'Add vespera::jni_app! and call VesperaBridge.init() from Java. The same router runs inside the JVM over a binary wire — microsecond round-trips, no TCP.', + imageUrl: '/images/join-us-bg.webp', }, ] @@ -63,18 +64,20 @@ export default function HomePage() { > - Lorem ipsum dolor sit amet,
- consectetur adipiscing elit. + The fastest way to ship
+ documented Rust APIs.
- Etiam sit amet feugiat turpis. Proin nec ante a sem vestibulum - sodales non ut ex.
- Morbi diam turpis, fringilla vitae enim et, egestas consequat - nibh.
- Etiam auctor cursus urna sit amet elementum. + Vespera turns plain Axum handlers into a typed, validated API + with OpenAPI 3.1 generated at compile time.
+ File-based routing, automatic Swagger UI, and a binary JNI + bridge that embeds your router
+ inside Spring Boot with microsecond round-trips.
- + + + @@ -88,20 +91,40 @@ export default function HomePage() { - Title + FastAPI-grade DX, Rust-grade performance - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam - venenatis, elit in hendrerit porta, augue ante scelerisque diam,{' '} -
- ac egestas lacus est nec urna. Cras commodo risus hendrerit, - suscipit nibh at, porttitor dui. + Vespera turns your Axum routes into a typed, validated, embeddable API + with one macro. File-based routing, compile-time OpenAPI 3.1, and a + JNI bridge that lets Spring host your Rust router with microsecond + round-trips — no TCP, no JSON envelope.
- {[0, 1, 2, 3].map((i) => ( + {[ + { + title: 'Zero-config OpenAPI 3.1', + description: + 'Drop handlers into src/routes/, derive Schema on your types, and Vespera generates the full OpenAPI 3.1 spec at compile time. No annotations, no runtime registration, no hand-written JSON.', + }, + { + title: 'Type-safe validation', + description: + 'Wrap any extractor in Validated and garde runs before your handler. Failures become a structured 422 response automatically — under JNI, errors are hoisted into the wire header so Java decoders never special-case error shapes.', + }, + { + title: 'Embed Rust in Spring', + description: + 'JNI in-process dispatch with a length-prefixed binary wire format. Multipart, PDFs, and images travel as raw bytes — no TCP socket, no JSON envelope, no base64 — the same Axum routes Spring users hit directly.', + }, + { + title: 'Microsecond dispatch', + description: + 'Sync round-trip in ~2.9 µs, direct ByteBuffer path in ~2.2 µs, streaming throughput up to 14.5 GB/s — measured end-to-end across the real JNI boundary, not just on the Rust side.', + }, + ].map(({ title, description }) => ( - Feature title + {title} - Lorem ipsum dolor sit amet. Etiam sit amet feugiat turpis. - Proin nec ante a sem vestibulum sodales non ut ex.{' '} + {description} @@ -127,6 +149,8 @@ export default function HomePage() {
+ + - Title + Zero to documented API in three steps - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Nullam venenatis ac egestas lacus est nec urna.{' '} + No boilerplate, no YAML, no hand-written specs — the macro + does the wiring, you write handlers.{' '} - + + + @@ -234,8 +260,8 @@ export default function HomePage() { Join our community - Join our Discord and help build the future of frontend with - CSS-in-JS!{' '} + Join our Discord to talk Rust APIs, JNI embedding, and what + Vespera should build next.{' '} diff --git a/apps/landing/src/components/performance/index.tsx b/apps/landing/src/components/performance/index.tsx new file mode 100644 index 00000000..09521b5c --- /dev/null +++ b/apps/landing/src/components/performance/index.tsx @@ -0,0 +1,121 @@ +import { css, Flex, Text, VStack } from '@devup-ui/react' +import Link from 'next/link' + +interface Stat { + value: string + unit: string + label: string + detail: string +} + +const STATS: Stat[] = [ + { + value: '2.2', + unit: 'µs', + label: 'Direct JNI dispatch', + detail: 'Per round-trip via pooled direct ByteBuffers', + }, + { + value: '2.9', + unit: 'µs', + label: 'Sync dispatch', + detail: 'Length-prefixed binary wire, no JSON envelope', + }, + { + value: '14.5', + unit: 'GB/s', + label: 'Streaming throughput', + detail: '256 KiB chunks, 3.3× faster than v0.x', + }, + { + value: '20', + unit: '%', + label: 'Faster sync dispatch', + detail: 'v0.2 zero-copy decode vs v0.1.1, same wire format', + }, +] + +export function Performance() { + return ( + + + + + Microsecond dispatch, gigabyte/s streaming + + + Vespera embeds your Axum router inside the JVM via JNI — zero TCP, zero + JSON envelope, raw bytes end-to-end. Numbers below are measured through the + real JNI boundary on AMD Ryzen 9 9950X, JDK 21. + + + + + {STATS.map((stat) => ( + + + + {stat.value} + + + {stat.unit} + + + + {stat.label} + + + {stat.detail} + + + ))} + + + + Latency measured on small GET /health round-trips through the real JNI + boundary; streaming throughput measured with a 64 MiB payload. Full + methodology and raw runs in the{' '} + + JNI benchmark report + + . + + + + ) +} diff --git a/apps/landing/src/constants/index.ts b/apps/landing/src/constants/index.ts index 6f768438..c5292052 100644 --- a/apps/landing/src/constants/index.ts +++ b/apps/landing/src/constants/index.ts @@ -7,36 +7,36 @@ export interface SideMenuItem { export const SIDE_MENU_ITEMS: Record = { documentation: [ { - label: '개요', + label: 'Overview', value: 'overview', }, - { label: '설치', value: 'installation' }, + { label: 'Installation', value: 'installation' }, { - label: '개념', + label: 'Core Concepts', value: 'concept', children: [ - { label: '개념 1', value: 'concept-1' }, - { label: '개념 2', value: 'concept-2' }, - { label: '개념 3', value: 'concept-3' }, + { label: 'File-Based Routing', value: 'concept-1' }, + { label: 'Schema & OpenAPI', value: 'concept-2' }, + { label: 'Validated & 422', value: 'concept-3' }, ], }, - { label: '특징', value: 'features' }, + { label: 'Features', value: 'features' }, { - label: 'API', + label: 'API Reference', value: 'api', children: [ - { label: 'API 1', value: 'api-1' }, - { label: 'API 2', value: 'api-2' }, - { label: 'API 3', value: 'api-3' }, + { label: 'vespera! Macro', value: 'api-1' }, + { label: 'Route & Extractors', value: 'api-2' }, + { label: 'schema_type! & More', value: 'api-3' }, ], }, { - label: '테마', + label: 'JNI / Java', value: 'theme', children: [ - { label: '테마 1', value: 'theme-1' }, - { label: '테마 2', value: 'theme-2' }, - { label: '테마 3', value: 'theme-3' }, + { label: 'jni_app! & VesperaBridge', value: 'theme-1' }, + { label: 'Dispatch Modes & Wire', value: 'theme-2' }, + { label: 'Streaming & Multi-App', value: 'theme-3' }, ], }, ], diff --git a/benches/README.md b/benches/README.md new file mode 100644 index 00000000..2818e159 --- /dev/null +++ b/benches/README.md @@ -0,0 +1,77 @@ +# Compile-time benchmarks + +A reproducible harness for measuring the **compile-time cost of vespera's +proc-macros** (`vespera!`, `schema_type!`, `#[derive(Schema)]`). This is the +compile-time analogue of the runtime criterion benches +(`crates/vespera_inprocess/benches/dispatch.rs`) and the deterministic +allocation gate (`crates/vespera_inprocess/tests/alloc_budget.rs`). + +| Crate | Role | +|---|---| +| [`macro-compile-bench`](./macro-compile-bench) | **Fixture** — a deliberately schema- and cross-reference-heavy `vespera!` app. Hub schemas (`User`, `Product`, `Order`) are referenced by many routes, so the per-reference schema-generation cost that macro optimizations target is exercised. | +| [`compile-bench-runner`](./compile-bench-runner) | **Harness** — a std-only orchestrator that measures the `macro_expand_crate` rustc pass and reports min/median/mean/stddev with baseline A/B comparison. | + +## What it measures + +The harness extracts the **`macro_expand_crate`** pass from `rustc -Z +time-passes`, which **isolates macro expansion** from the rest of compilation +(name resolution, type-check, codegen, LTO). This is the right signal for +proc-macro work: optimizing `vespera_macro` only changes expansion time, which +is a small fraction of a crate's total build, so measuring total wall-clock +would bury the change under noise. + +It runs on a **stable** toolchain via `RUSTC_BOOTSTRAP=1` (no nightly needed), +so it works in CI. + +## Usage + +```bash +# Save a baseline on the current (e.g. unmodified) macro code: +cargo run -p compile-bench-runner -- --runs 8 --save-baseline before + +# ... make changes to crates/vespera_macro ... + +# Compare against the baseline: +cargo run -p compile-bench-runner -- --runs 8 --baseline before +``` + +Options: + +| Flag | Default | Meaning | +|---|---|---| +| `--target ` | `macro-compile-bench` | crate to measure (must be a lib that expands the macros) | +| `--pass ` | `macro_expand_crate` | which `-Z time-passes` pass to extract | +| `--runs ` | `8` | measured iterations | +| `--save-baseline ` | — | write samples to `compile-bench-runner/baselines/.txt` | +| `--baseline ` | — | compare current run against `baselines/.txt` | + +You can also point it at the bundled example for a heavier, real-world workload: + +```bash +cargo run -p compile-bench-runner -- --target axum-example --runs 8 --save-baseline ax +``` + +## Methodology & noise + +- Each iteration runs `cargo clean -p ` to force a **full + re-expansion**, then `cargo rustc … -- -Z time-passes`. +- Compile time has only **positive** noise (a busy machine only ever *adds* + time), so **`min` is the robust point estimate**; median/mean/sd are also + reported. Gross outliers (> 3× median, e.g. antivirus/FS hiccups on Windows) + are dropped before stats. +- The fixture's `macro_expand_crate` is stable to within a few percent + (~3–4% sd). The A/B verdict requires a change to exceed run-to-run noise + (≥ 2%) before reporting `IMPROVED` / `REGRESSED`. +- **Run on a quiet machine.** Close other heavy processes; the harness reports + the relative stddev so you can judge whether a measurement was clean. + +> Note: a stale baseline measured under different machine load can produce a +> false delta. For a rigorous before/after, measure both arms **back-to-back** +> in one sitting (save baseline → change → compare) rather than comparing +> against a baseline captured hours earlier. + +## Baselines are local + +`compile-bench-runner/baselines/*.txt` hold absolute timings that are specific +to the machine/toolchain that produced them, so they are **git-ignored**. +Capture your own before/after on the same machine in one session. diff --git a/benches/compile-bench-runner/Cargo.toml b/benches/compile-bench-runner/Cargo.toml new file mode 100644 index 00000000..15e87c34 --- /dev/null +++ b/benches/compile-bench-runner/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "compile-bench-runner" +version = "0.1.0" +edition = "2024" +publish = false + +# Compile-time benchmark HARNESS. A dependency-free (std-only) orchestrator +# that drives `cargo rustc -- -Z time-passes` over a target crate and reports +# the `macro_expand_crate` pass time with min/median/mean/stddev and +# baseline A/B comparison. Kept as a SEPARATE crate from the fixture so that +# `cargo clean -p ` (run between measured iterations) never touches +# the harness binary that is currently executing. + +[[bin]] +name = "compile-bench-runner" +path = "src/main.rs" diff --git a/benches/compile-bench-runner/baselines/.gitignore b/benches/compile-bench-runner/baselines/.gitignore new file mode 100644 index 00000000..73e3826a --- /dev/null +++ b/benches/compile-bench-runner/baselines/.gitignore @@ -0,0 +1,5 @@ +# Compile-time baselines are absolute timings specific to the machine and +# toolchain that produced them — never commit them. Capture your own +# before/after on the same machine in one session (see ../../README.md). +* +!.gitignore diff --git a/benches/compile-bench-runner/src/main.rs b/benches/compile-bench-runner/src/main.rs new file mode 100644 index 00000000..7be1909c --- /dev/null +++ b/benches/compile-bench-runner/src/main.rs @@ -0,0 +1,267 @@ +//! Compile-time benchmark harness for vespera's proc-macros. +//! +//! Measures the `macro_expand_crate` rustc pass of a target fixture crate, +//! which isolates the cost of expanding `vespera!`, `schema_type!`, and +//! `#[derive(Schema)]` from the rest of compilation (type-check, codegen, +//! LTO). Runs on **stable** via `RUSTC_BOOTSTRAP=1` (no nightly required), so +//! it works in CI. +//! +//! ```text +//! cargo run -p compile-bench-runner --release -- [OPTIONS] +//! --target crate to measure (default: macro-compile-bench) +//! --pass -Z time-passes pass name (default: macro_expand_crate) +//! --runs measured iterations (default: 8) +//! --save-baseline write samples to baselines/.txt +//! --baseline compare this run against baselines/.txt +//! ``` +//! +//! Methodology: each iteration runs `cargo clean -p ` to force a full +//! re-expansion, then `cargo rustc … -- -Z time-passes` and parses the pass +//! time. Compile time has only *positive* noise (a busy machine only ever +//! adds time), so `min` is the robust point estimate; `median`/`mean`/`sd` +//! are reported too. Gross outliers (> 3x median, e.g. AV/FS hiccups) are +//! dropped before stats. + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +struct Args { + target: String, + pass: String, + runs: usize, + save_baseline: Option, + baseline: Option, +} + +fn print_help() { + eprint!( + "compile-bench-runner — vespera proc-macro compile-time benchmark\n\n\ + USAGE: cargo run -p compile-bench-runner --release -- [OPTIONS]\n\ + --target crate to measure (default: macro-compile-bench)\n\ + --pass -Z time-passes pass (default: macro_expand_crate)\n\ + --runs measured iterations (default: 8)\n\ + --save-baseline save samples to baselines/.txt\n\ + --baseline compare against baselines/.txt\n\ + -h, --help this help\n" + ); +} + +fn parse_args() -> Args { + let mut a = Args { + target: "macro-compile-bench".to_owned(), + pass: "macro_expand_crate".to_owned(), + runs: 8, + save_baseline: None, + baseline: None, + }; + let mut it = env::args().skip(1); + while let Some(arg) = it.next() { + let mut next = |flag: &str| { + it.next() + .unwrap_or_else(|| fatal(&format!("{flag} needs a value"))) + }; + match arg.as_str() { + "--target" => a.target = next("--target"), + "--pass" => a.pass = next("--pass"), + "--runs" => { + a.runs = next("--runs") + .parse() + .unwrap_or_else(|_| fatal("--runs must be an integer")); + } + "--save-baseline" => a.save_baseline = Some(next("--save-baseline")), + "--baseline" => a.baseline = Some(next("--baseline")), + "-h" | "--help" => { + print_help(); + std::process::exit(0); + } + other => fatal(&format!("unknown argument: {other} (try --help)")), + } + } + if a.runs == 0 { + fatal("--runs must be >= 1"); + } + a +} + +fn fatal(msg: &str) -> ! { + eprintln!("error: {msg}"); + std::process::exit(2); +} + +/// A `cargo` command pre-seeded with `RUSTC_BOOTSTRAP=1` so `-Z time-passes` +/// is accepted on a stable toolchain. +fn cargo() -> Command { + let mut c = Command::new(env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned())); + c.env("RUSTC_BOOTSTRAP", "1"); + c +} + +/// Extract the seconds for `pass` from `-Z time-passes` stderr. +/// Lines look like: `time: 0.090; rss: 24MB -> 36MB ( +12MB)\tmacro_expand_crate`. +fn extract_pass_time(stderr: &str, pass: &str) -> Option { + for line in stderr.lines() { + let line = line.trim_end(); + if line.contains("time:") && line.split_whitespace().next_back() == Some(pass) { + let after = line.split("time:").nth(1)?; + return after.split(';').next()?.trim().parse::().ok(); + } + } + None +} + +fn measure_once(target: &str, pass: &str) -> Option { + // Force a full re-expansion of the fixture lib (deps stay built). + let _ = cargo().args(["clean", "-p", target]).status(); + let out = cargo() + .args([ + "rustc", + "--quiet", + "-p", + target, + "--lib", + "--", + "-Z", + "time-passes", + ]) + .output() + .ok()?; + let stderr = String::from_utf8_lossy(&out.stderr); + extract_pass_time(&stderr, pass) +} + +fn median(sorted: &[f64]) -> f64 { + let n = sorted.len(); + if n == 0 { + return f64::NAN; + } + if n % 2 == 1 { + sorted[n / 2] + } else { + (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 + } +} + +fn mean(v: &[f64]) -> f64 { + v.iter().sum::() / v.len() as f64 +} + +fn stddev(v: &[f64], m: f64) -> f64 { + if v.len() < 2 { + return 0.0; + } + (v.iter().map(|x| (x - m).powi(2)).sum::() / (v.len() - 1) as f64).sqrt() +} + +fn baselines_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("baselines") +} + +fn main() { + let args = parse_args(); + + eprintln!("[warm] building `{}` (and deps) once...", args.target); + let warm = cargo() + .args(["build", "--quiet", "-p", &args.target, "--lib"]) + .status(); + if !matches!(warm, Ok(s) if s.success()) { + fatal(&format!("warm build of `{}` failed", args.target)); + } + + eprintln!( + "[measure] {} runs of `{}` on `{}`", + args.runs, args.pass, args.target + ); + let mut samples = Vec::new(); + for i in 0..args.runs { + match measure_once(&args.target, &args.pass) { + Some(t) => { + eprintln!(" run {:>2}: {t:.4}s", i + 1); + samples.push(t); + } + None => eprintln!( + " run {:>2}: pass `{}` not found in output", + i + 1, + args.pass + ), + } + } + if samples.is_empty() { + fatal("no samples collected (is the target a lib that uses vespera macros?)"); + } + + samples.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let med0 = median(&samples); + let clean: Vec = samples + .iter() + .copied() + .filter(|&t| t <= med0 * 3.0) + .collect(); + let clean = if clean.is_empty() { + samples.clone() + } else { + clean + }; + + let min = clean[0]; + let med = median(&clean); + let mn = mean(&clean); + let sd = stddev(&clean, mn); + let rel_sd = if mn > 0.0 { 100.0 * sd / mn } else { 0.0 }; + + println!(); + println!( + "== {} on `{}` ({} clean / {} total runs) ==", + args.pass, + args.target, + clean.len(), + samples.len() + ); + println!(" min={min:.4}s median={med:.4}s mean={mn:.4}s sd={sd:.4}s ({rel_sd:.1}%)"); + + if let Some(name) = &args.save_baseline { + let dir = baselines_dir(); + let _ = fs::create_dir_all(&dir); + let path = dir.join(format!("{name}.txt")); + let body: String = clean.iter().map(|t| format!("{t}\n")).collect(); + match fs::write(&path, body) { + Ok(()) => println!(" saved baseline `{name}` -> {}", path.display()), + Err(e) => eprintln!(" failed to save baseline `{name}`: {e}"), + } + } + + if let Some(name) = &args.baseline { + let path = baselines_dir().join(format!("{name}.txt")); + match fs::read_to_string(&path) { + Ok(s) => { + let mut base: Vec = s.lines().filter_map(|l| l.trim().parse().ok()).collect(); + if base.is_empty() { + eprintln!(" baseline `{name}` is empty"); + } else { + base.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let bmin = base[0]; + let bmed = median(&base); + let d_min = 100.0 * (min - bmin) / bmin; + let d_med = 100.0 * (med - bmed) / bmed; + // Noise-aware verdict on `min` (the robust estimator): + // require the change to exceed run-to-run noise (>= 2%). + let noise = rel_sd.max(2.0); + let verdict = if d_min.abs() <= noise { + "NO CHANGE (within noise)" + } else if d_min < 0.0 { + "IMPROVED" + } else { + "REGRESSED" + }; + println!(); + println!("== vs baseline `{name}` =="); + println!(" min: {bmin:.4}s -> {min:.4}s ({d_min:+.1}%)"); + println!(" median: {bmed:.4}s -> {med:.4}s ({d_med:+.1}%)"); + println!(" verdict: {verdict} (noise ~{noise:.1}%)"); + } + } + Err(e) => eprintln!(" baseline `{name}` not found ({e}); use --save-baseline first"), + } + } +} diff --git a/benches/macro-compile-bench/Cargo.toml b/benches/macro-compile-bench/Cargo.toml new file mode 100644 index 00000000..2da4a4d6 --- /dev/null +++ b/benches/macro-compile-bench/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "macro-compile-bench" +version = "0.1.0" +edition = "2024" +publish = false + +# Compile-time benchmark FIXTURE: a deliberately schema- and +# cross-reference-heavy `vespera!` workload. The `compile-bench-runner` +# harness measures the `macro_expand_crate` rustc pass of THIS crate's lib +# to gauge proc-macro expansion cost. It is intentionally not a production +# example, so it depends only on `vespera` + `serde`. + +[dependencies] +vespera = { path = "../../crates/vespera" } +serde = { version = "1", features = ["derive"] } diff --git a/benches/macro-compile-bench/src/lib.rs b/benches/macro-compile-bench/src/lib.rs new file mode 100644 index 00000000..66ffcf79 --- /dev/null +++ b/benches/macro-compile-bench/src/lib.rs @@ -0,0 +1,30 @@ +//! Macro compile-time benchmark **fixture**. +//! +//! A deliberately schema- and cross-reference-heavy `vespera!` application +//! whose sole purpose is to give the [`compile-bench-runner`] harness a +//! stable, representative proc-macro expansion workload to measure. Hub +//! schemas (`User`, `Product`, `Order`) are referenced by many routes so the +//! per-reference schema-generation cost — exactly what compile-time macro +//! optimizations target — is exercised. +//! +//! The harness measures the `macro_expand_crate` rustc pass of this crate's +//! `lib`, which isolates `vespera!` / `#[derive(Schema)]` expansion from the +//! rest of compilation (type-check, codegen, LTO). +//! +//! This is benchmark scaffolding, not a production example; lints are relaxed +//! (e.g. `ErrorBody` is referenced only from a `responses = [...]` attribute, +//! which does not count as an import use). +#![allow(clippy::all, clippy::pedantic, unused)] + +pub mod models; +mod routes; + +use vespera::{axum, vespera}; + +/// Expand `vespera!` over `src/routes/` — the call the compile-time harness +/// measures. No `openapi = ...` output is configured, so building this crate +/// performs the expansion without writing files. +#[must_use] +pub fn create_app() -> axum::Router { + vespera!(title = "Macro Compile Bench", version = "1.0.0") +} diff --git a/benches/macro-compile-bench/src/models/mod.rs b/benches/macro-compile-bench/src/models/mod.rs new file mode 100644 index 00000000..98d9a8c5 --- /dev/null +++ b/benches/macro-compile-bench/src/models/mod.rs @@ -0,0 +1,2 @@ +//! Benchmark schemas (one module, cross-referenced on purpose). +pub mod schemas; diff --git a/benches/macro-compile-bench/src/models/schemas.rs b/benches/macro-compile-bench/src/models/schemas.rs new file mode 100644 index 00000000..eb7bc415 --- /dev/null +++ b/benches/macro-compile-bench/src/models/schemas.rs @@ -0,0 +1,191 @@ +//! All benchmark schemas, deliberately cross-referenced so the OpenAPI +//! generator resolves the same DTO many times (the per-reference cost the +//! compile-time benchmark is meant to surface). `Default` is derived so route +//! handlers stay one-liners — only the type *signatures* drive expansion cost. + +use serde::{Deserialize, Serialize}; +use vespera::Schema; + +// ── Users domain ───────────────────────────────────────────────────────── + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Address { + pub street: String, + pub city: String, + pub country: String, + pub postal_code: String, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Permission { + pub id: u32, + pub action: String, + pub resource: String, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Role { + pub id: u32, + pub name: String, + pub permissions: Vec, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Profile { + pub display_name: String, + pub bio: Option, + pub avatar_url: Option, + pub address: Address, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct User { + pub id: u64, + pub username: String, + pub email: String, + pub profile: Profile, + pub roles: Vec, + pub created_at: String, +} + +// ── Catalog domain ─────────────────────────────────────────────────────── + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Category { + pub id: i64, + pub name: String, + pub slug: String, + /// Self-referential: exercises circular-schema handling. + pub parent: Option>, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Tag { + pub id: i64, + pub label: String, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Product { + pub id: u64, + pub name: String, + pub description: String, + pub price: f64, + pub category: Category, + pub tags: Vec, + pub in_stock: bool, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Warehouse { + pub id: u32, + pub name: String, + pub location: Address, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Inventory { + pub product: Product, + pub warehouse: Warehouse, + pub quantity: u32, + pub reserved: u32, +} + +// ── Orders domain ──────────────────────────────────────────────────────── + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "snake_case")] +pub enum OrderStatus { + #[default] + Pending, + Paid, + Shipped, + Delivered, + Cancelled, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct OrderItem { + pub product: Product, + pub quantity: u32, + pub unit_price: f64, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Order { + pub id: u64, + pub customer: User, + pub items: Vec, + pub status: OrderStatus, + pub total: f64, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Payment { + pub id: u64, + pub order_id: u64, + pub method: String, + pub amount: f64, + pub paid: bool, +} + +// ── Generic envelopes (Wrapper — exercises the generic schema path) ───── + +#[derive(Clone, Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct Paginated { + pub items: Vec, + pub total: u64, + pub page: u32, + pub per_page: u32, +} + +#[derive(Clone, Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct ApiResponse { + pub data: T, + pub success: bool, + pub message: Option, +} + +#[derive(Default, Clone, Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct ErrorBody { + pub code: u32, + pub message: String, +} + +impl Paginated { + /// One empty page — keeps handlers free of `T` construction. + pub fn empty() -> Self { + Self { + items: Vec::new(), + total: 0, + page: 1, + per_page: 20, + } + } +} + +impl ApiResponse { + pub fn ok(data: T) -> Self { + Self { + data, + success: true, + message: None, + } + } +} diff --git a/benches/macro-compile-bench/src/routes/catalog.rs b/benches/macro-compile-bench/src/routes/catalog.rs new file mode 100644 index 00000000..fd096619 --- /dev/null +++ b/benches/macro-compile-bench/src/routes/catalog.rs @@ -0,0 +1,47 @@ +use vespera::axum::{Json, extract::Path}; + +use crate::models::schemas::{ + ApiResponse, Category, Inventory, Paginated, Product, Tag, Warehouse, +}; + +/// List products (paginated). +#[vespera::route(get, tags = ["catalog"])] +pub async fn list_products() -> Json> { + Json(Paginated::empty()) +} + +/// Get a product. +#[vespera::route(get, path = "/{id}", tags = ["catalog"])] +pub async fn get_product(Path(_id): Path) -> Json> { + Json(ApiResponse::ok(Product::default())) +} + +/// Create a product. +#[vespera::route(post, tags = ["catalog"])] +pub async fn create_product(Json(body): Json) -> Json> { + Json(ApiResponse::ok(body)) +} + +/// Category tree (paginated, self-referential schema). +#[vespera::route(get, path = "/categories", tags = ["catalog"])] +pub async fn list_categories() -> Json> { + Json(Paginated::empty()) +} + +/// A product's tags. +#[vespera::route(get, path = "/{id}/tags", tags = ["catalog"])] +pub async fn product_tags(Path(_id): Path) -> Json> { + Json(Vec::new()) +} + +/// Inventory (paginated). +#[vespera::route(get, path = "/inventory", tags = ["catalog"])] +pub async fn list_inventory() -> Json> { + Json(Paginated::empty()) +} + +/// Warehouses. +#[vespera::route(get, path = "/warehouses", tags = ["catalog"])] +pub async fn list_warehouses() -> Json> { + Json(Vec::new()) +} diff --git a/benches/macro-compile-bench/src/routes/mod.rs b/benches/macro-compile-bench/src/routes/mod.rs new file mode 100644 index 00000000..e7c3c2f4 --- /dev/null +++ b/benches/macro-compile-bench/src/routes/mod.rs @@ -0,0 +1,5 @@ +//! File-based route modules. `vespera!` scans this folder; each `pub mod` +//! must be declared so the generated router can reference the handlers. +pub mod catalog; +pub mod orders; +pub mod users; diff --git a/benches/macro-compile-bench/src/routes/orders.rs b/benches/macro-compile-bench/src/routes/orders.rs new file mode 100644 index 00000000..aa6fc191 --- /dev/null +++ b/benches/macro-compile-bench/src/routes/orders.rs @@ -0,0 +1,50 @@ +use vespera::axum::{Json, extract::Path}; + +use crate::models::schemas::{ + ApiResponse, Order, OrderItem, OrderStatus, Paginated, Payment, User, +}; + +/// List orders (paginated). +#[vespera::route(get, tags = ["orders"])] +pub async fn list_orders() -> Json> { + Json(Paginated::empty()) +} + +/// Get an order. +#[vespera::route(get, path = "/{id}", tags = ["orders"])] +pub async fn get_order(Path(_id): Path) -> Json> { + Json(ApiResponse::ok(Order::default())) +} + +/// Create an order. +#[vespera::route(post, tags = ["orders"])] +pub async fn create_order(Json(body): Json) -> Json> { + Json(ApiResponse::ok(body)) +} + +/// An order's items. +#[vespera::route(get, path = "/{id}/items", tags = ["orders"])] +pub async fn order_items(Path(_id): Path) -> Json> { + Json(Vec::new()) +} + +/// An order's customer. +#[vespera::route(get, path = "/{id}/customer", tags = ["orders"])] +pub async fn order_customer(Path(_id): Path) -> Json> { + Json(ApiResponse::ok(User::default())) +} + +/// An order's payments. +#[vespera::route(get, path = "/{id}/payments", tags = ["orders"])] +pub async fn order_payments(Path(_id): Path) -> Json> { + Json(Vec::new()) +} + +/// Update an order's status. +#[vespera::route(patch, path = "/{id}/status", tags = ["orders"])] +pub async fn update_status( + Path(_id): Path, + Json(_status): Json, +) -> Json> { + Json(ApiResponse::ok(Order::default())) +} diff --git a/benches/macro-compile-bench/src/routes/users.rs b/benches/macro-compile-bench/src/routes/users.rs new file mode 100644 index 00000000..208909b9 --- /dev/null +++ b/benches/macro-compile-bench/src/routes/users.rs @@ -0,0 +1,52 @@ +use serde::Deserialize; +use vespera::Schema; +use vespera::axum::{ + Json, + extract::{Path, Query}, +}; + +use crate::models::schemas::{ApiResponse, ErrorBody, Paginated, Profile, Role, User}; + +#[derive(Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct ListUsersQuery { + pub page: u32, + pub per_page: u32, + pub search: Option, +} + +/// List users (paginated). +#[vespera::route(get, tags = ["users"])] +pub async fn list_users(Query(_q): Query) -> Json> { + Json(Paginated::empty()) +} + +/// Get one user. +#[vespera::route(get, path = "/{id}", tags = ["users"], responses = [(404, ErrorBody)])] +pub async fn get_user(Path(_id): Path) -> Json> { + Json(ApiResponse::ok(User::default())) +} + +/// Create a user. +#[vespera::route(post, tags = ["users"])] +pub async fn create_user(Json(body): Json) -> Json> { + Json(ApiResponse::ok(body)) +} + +/// Update a user. +#[vespera::route(put, path = "/{id}", tags = ["users"])] +pub async fn update_user(Path(_id): Path, Json(body): Json) -> Json> { + Json(ApiResponse::ok(body)) +} + +/// A user's roles. +#[vespera::route(get, path = "/{id}/roles", tags = ["users"])] +pub async fn user_roles(Path(_id): Path) -> Json> { + Json(Vec::new()) +} + +/// A user's profile. +#[vespera::route(get, path = "/{id}/profile", tags = ["users"])] +pub async fn user_profile(Path(_id): Path) -> Json { + Json(Profile::default()) +} diff --git a/bun.lock b/bun.lock index 649ee60e..95d82811 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "bun-test-env-dom": "^1.0", "eslint-plugin-devup": "^2.0.19", "husky": "^9.1", - "oxlint": "^1.66.0", + "oxlint": "^1.69.0", }, }, "apps/landing": { @@ -19,12 +19,12 @@ "dependencies": { "@devup-api/fetch": "^0.1", "@devup-api/react-query": "^0.1", - "@devup-ui/components": "^0.1.46", + "@devup-ui/components": "^0.1.47", "@devup-ui/react": "^1", "@devup-ui/reset-css": "^1", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", - "@next/mdx": "^16.2.6", + "@next/mdx": "^16.2.9", "clsx": "^2.1.1", "next": "^16", "react": "^19", @@ -32,15 +32,16 @@ "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", - "shiki": "^4.1.0", + "shiki": "^4.2.0", "unified": "^11.0.5", }, "devDependencies": { "@devup-api/next-plugin": "^0.1", "@devup-ui/next-plugin": "^1", - "@types/mdx": "^2.0.13", + "@types/mdx": "^2.0.14", "@types/node": "^25", "@types/react": "^19", "@types/react-syntax-highlighter": "^15.5.13", @@ -100,25 +101,25 @@ "@devup-api/webpack-plugin": ["@devup-api/webpack-plugin@0.1.13", "", { "dependencies": { "@devup-api/core": "^0.1.18", "@devup-api/generator": "^0.1.24", "@devup-api/utils": "^0.1.10" } }, "sha512-dQMqcMMdNUtzUHdaVYm29aIAU2S3+1EXLnWI3zsbVfF8X8isWqLlmwPS5aioY7iGDIYW4nL3C4gkIrhvT2pgpA=="], - "@devup-ui/bun-plugin": ["@devup-ui/bun-plugin@1.0.9", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.7", "@devup-ui/wasm": "^1.0.74" } }, "sha512-Rj50un5MzTUiKdS7rlDh8DKrwhI4s4O+L1HtSr+Pw+/bo0mSMRRM8pr11umd7gUAXIlh0qgllwe3iagP9gZh6g=="], + "@devup-ui/bun-plugin": ["@devup-ui/bun-plugin@1.0.10", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.8", "@devup-ui/wasm": "^1.0.75" } }, "sha512-GvCtLyCtS6FMXM6rg+s34N4XRFLfOdtzcMuLe61vsCloGhWn/XChWmQtnKJ0wDU7XfFUrgJCRW5BhsnV10hKOA=="], - "@devup-ui/components": ["@devup-ui/components@0.1.46", "", { "dependencies": { "@devup-ui/react": "^1.0.37", "clsx": "^2.1", "react": "^19.2.6" } }, "sha512-vZGMsACbB8YlBdrSLLq+3Lp2MoWw3vxoL6bYeepVqGiHLQaEZcyG1Iv1uymy7hAZYRlX7lgttJMhtVTyfyVdKA=="], + "@devup-ui/components": ["@devup-ui/components@0.1.47", "", { "dependencies": { "@devup-ui/react": "^1.0.37", "clsx": "^2.1", "react": "^19.2.7" } }, "sha512-B/V2fTbSUIFObF/Zz4gyGhDmuY3vmbej9678494VrmrAM/5JeaN/X+0quWGIpjwPy/rhvAW6nNLEf0XJPZQ7ew=="], - "@devup-ui/eslint-plugin": ["@devup-ui/eslint-plugin@1.0.15", "", { "dependencies": { "@typescript-eslint/utils": "^8.59", "typescript-eslint": "^8.59" } }, "sha512-vSOqvMTETHeF45X1JUxkkEkzoHTTgl8u/bJ3D9sybAoWNxvhcus5aDCOP1WHvJPQ1IG8/EMilxmrCyWNdkHJnA=="], + "@devup-ui/eslint-plugin": ["@devup-ui/eslint-plugin@1.0.16", "", { "dependencies": { "@typescript-eslint/utils": "^8.60", "typescript-eslint": "^8.60" } }, "sha512-gXhEVO9c4qGfR6HcCXsnRZHZlepDBZ1BnA0M2pB1/9asXSqWoJmt75xE0beXtt7wgrBHO/Z5gh+iX8Xu3e2ewQ=="], - "@devup-ui/next-plugin": ["@devup-ui/next-plugin@1.0.77", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.7", "@devup-ui/wasm": "^1.0.74", "@devup-ui/webpack-plugin": "^1.0.60" }, "peerDependencies": { "next": "*" } }, "sha512-Ty2Jgv1AA2x0pttw3SF0qflB/Mfsx8+JtFm/j5VXwp/UjbMBkKSA19IR9sGRN9n+4DqpG5aOl7lJJmCNvmW6VQ=="], + "@devup-ui/next-plugin": ["@devup-ui/next-plugin@1.0.78", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.8", "@devup-ui/wasm": "^1.0.75", "@devup-ui/webpack-plugin": "^1.0.61" }, "peerDependencies": { "next": "*" } }, "sha512-87PRiX5eP1J61F75PFmDMdEW4+aGFGLMPdgjcWdk2/y52LyMXJWQB3Vbtj1Z6fGRPFQOTIBUOWd9GVO7084YKA=="], - "@devup-ui/plugin-utils": ["@devup-ui/plugin-utils@1.0.7", "", {}, "sha512-KIVxYZCtkuLS29sDO/JRSWjO1fCQw/TnBD1J5u1KsLo134Q+8RogebWM/OeEJmMmGuiB9uiz06uzjG4h3BXLVg=="], + "@devup-ui/plugin-utils": ["@devup-ui/plugin-utils@1.0.8", "", {}, "sha512-Fyqmw4ZIkddNAT/GUE5+ur9tGelgAFAstE2j3Dfb+ypSGrhK9E2Ui9/0jBwI49GTBVbTG6fIDTnFgq0WpyJjRQ=="], "@devup-ui/react": ["@devup-ui/react@1.0.37", "", { "dependencies": { "csstype-extra": "latest", "react": "^19.2" } }, "sha512-zeHO2ke7X5vnM8w9vl4knDmXameG0X8OCb5E+qZPS2G4tsFJ98B3LKhioHTtnTs8YxxFaErRjUoeXylG4AiMpg=="], "@devup-ui/reset-css": ["@devup-ui/reset-css@1.0.24", "", { "dependencies": { "@devup-ui/react": "^1.0.36" } }, "sha512-yz2Pkbh5KyhqvHExajmXkwVUTBhh64XN4TyE6jgs7gogYE7ab8glPHtsBPEARTIPhK0MjLorDJswNVdMrbDw7w=="], - "@devup-ui/wasm": ["@devup-ui/wasm@1.0.74", "", {}, "sha512-pxlUTj2A/cZrf3KuFas1d2Xtfch998JPiYL7M8r227PZyG7CfcBBdniM7AcQCEx7mQrZ8NMM3DIldp2ZnD+1CA=="], + "@devup-ui/wasm": ["@devup-ui/wasm@1.0.75", "", {}, "sha512-MqANK1YxKqrYYxpFN8jb89nVzbLOGrlgh/oshfCwm9VaMFdx6qbWxAETlkHkXmkOp5UAhFOlgJbFLVm0MT1t2g=="], - "@devup-ui/webpack-plugin": ["@devup-ui/webpack-plugin@1.0.60", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.7", "@devup-ui/wasm": "^1.0.74" } }, "sha512-e62sqaU7KNsmB76BmY+T8exWuBZ09i9L0li5wxqA7WS4bUDZKRV7eN4jIZ2/RBZ1tdWfmTcXqaEYoPv8pjUA8g=="], + "@devup-ui/webpack-plugin": ["@devup-ui/webpack-plugin@1.0.61", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.8", "@devup-ui/wasm": "^1.0.75" } }, "sha512-YbGiXC0MxQ52cnO0Uw4EUJJoyHAf+f031hoHyn0IhetpN/wPEbOy/g4Uv+b4sZxWUlMrO2RL6TZsLfPIw7w+rQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -134,9 +135,9 @@ "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.10.3", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.10.3" } }, "sha512-rw10ox5gAdKT5UScrrhLRE8y9t2xzvRx2lUNwbXlPogJixYGciElqywuLlcmX+Rgcif0sF2wWUwqUEob1BKZTA=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -216,25 +217,25 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], - "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], + "@next/env": ["@next/env@16.2.9", "", {}, "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg=="], - "@next/mdx": ["@next/mdx@16.2.6", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-0hdoSkzRbyud1dNRRDiyqD9FrxR2wwdiW+ffhYx+n+fXrFOJ7Nwpi8o7nUz2LiiM44BB9M0eIO1Evy3BBrS50A=="], + "@next/mdx": ["@next/mdx@16.2.9", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-SdweShKGCuN639JjyFSMQ8uldo+I+254+HucpjwdbFfaWHqUNN6dnQ1Of6laahnFyo48CcfDXEc2OBCS/Wfngw=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], "@npmcli/config": ["@npmcli/config@8.3.4", "", { "dependencies": { "@npmcli/map-workspaces": "^3.0.2", "@npmcli/package-json": "^5.1.1", "ci-info": "^4.0.0", "ini": "^4.1.2", "nopt": "^7.2.1", "proc-log": "^4.2.0", "semver": "^7.3.5", "walk-up-path": "^3.0.1" } }, "sha512-01rtHedemDNhUXdicU7s+QYz/3JyV5Naj84cvdXGH4mgCdL+agmSYaLF4LUG4vMCLzhBO8YtS0gPpH1FGvbgAw=="], @@ -248,71 +249,71 @@ "@npmcli/promise-spawn": ["@npmcli/promise-spawn@7.0.2", "", { "dependencies": { "which": "^4.0.0" } }, "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.66.0", "", { "os": "android", "cpu": "arm" }, "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.69.0", "", { "os": "android", "cpu": "arm" }, "sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.66.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.69.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.66.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.69.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.66.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.69.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.66.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.69.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.69.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.69.0", "", { "os": "linux", "cpu": "arm" }, "sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.69.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.69.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.66.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.69.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.69.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.69.0", "", { "os": "linux", "cpu": "none" }, "sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.66.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.69.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.69.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.69.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.66.0", "", { "os": "none", "cpu": "arm64" }, "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.69.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.66.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.69.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.66.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.69.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.66.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.69.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], - "@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="], + "@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], - "@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="], + "@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="], + "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], - "@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="], + "@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], - "@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="], + "@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.100.14", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-NbpiBCmeHTRuVHeV5+U+1bzmxyTW5Dzp2sCeE6Hx+ZJTJWFK9dsm8VZmRc7LQP9/ZORsF620PvgUk67AwiBo4A=="], + "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.101.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-wsfg821y4yw21J7nKI2oM5yyGSz3vASXqgWbmWCXZpnyY9ObLrBCcXivwZKj4YHF2fUWiqoOIRX2pbE79cf6gQ=="], - "@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.0", "", {}, "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow=="], - "@tanstack/react-query": ["@tanstack/react-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw=="], + "@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -344,13 +345,13 @@ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + "@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], @@ -362,31 +363,31 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.4", "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4" } }, "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0" } }, "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.4", "@typescript-eslint/tsconfig-utils": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -424,7 +425,7 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.37", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig=="], "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], @@ -432,6 +433,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bun-test-env-dom": ["bun-test-env-dom@1.0.3", "", { "dependencies": { "@happy-dom/global-registrator": ">=20.0", "@testing-library/dom": ">=10.4", "@testing-library/jest-dom": ">=6.9", "@testing-library/react": ">=16.3", "@testing-library/user-event": ">=14.6" } }, "sha512-Ozepvzk1s/bJSxABEjbI+Ztnm3CN1b0vRSvf0Qa0rTnuO7S0wKN2cUTsXdyIJuqE6OnlAhyoe2NGqkdeemz5/Q=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -442,7 +445,7 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -512,7 +515,7 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.372", "", {}, "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -528,7 +531,7 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-iterator-helpers": ["es-iterator-helpers@1.3.2", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw=="], + "es-iterator-helpers": ["es-iterator-helpers@1.3.3", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g=="], "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], @@ -546,17 +549,17 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], + "eslint": ["eslint@10.5.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], - "eslint-mdx": ["eslint-mdx@3.7.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "espree": "^9.6.1 || ^10.4.0", "estree-util-visit": "^2.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.11.8", "unified": "^11.0.5", "unified-engine": "^11.2.2", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "eslint": ">=8.0.0", "remark-lint-file-extension": "*" }, "optionalPeers": ["remark-lint-file-extension"] }, "sha512-QpPdJ6EeFthHuIrfgnWneZgwwFNOLFj/nf2jg/tOTBoiUnqNTxUUpTGAn0ZFHYEh5htVVoe5kjvD02oKtxZGeA=="], + "eslint-mdx": ["eslint-mdx@3.8.1", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "espree": "^9.6.1 || ^10.4.0 || ^11.2.0", "estree-util-visit": "^2.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.11.8", "unified": "^11.0.5", "unified-engine": "^11.2.2", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "eslint": ">=8.0.0", "remark-lint-file-extension": "*" }, "optionalPeers": ["remark-lint-file-extension"] }, "sha512-hnsqWwMOHqUANwxWEGt8XbwABPEr5sTOolAzqyUDFdlERpqjFE/icylb+mJl60VICL+kLbbvXWbnFLWZdTqJ2g=="], "eslint-plugin-devup": ["eslint-plugin-devup@2.0.19", "", { "dependencies": { "@devup-ui/eslint-plugin": ">=1.0.14", "@eslint/js": ">=10.0", "@tanstack/eslint-plugin-query": ">=5.100.6", "eslint": ">=10.2", "eslint-config-prettier": ">=10", "eslint-plugin-mdx": ">=3.7.0", "eslint-plugin-prettier": ">=5.5.5", "eslint-plugin-react": ">=7.37.5", "eslint-plugin-react-hooks": ">=7", "eslint-plugin-simple-import-sort": ">=13.0.0", "eslint-plugin-unused-imports": ">=4.4.1", "prettier": ">=3", "typescript-eslint": ">=8.59" } }, "sha512-E1CwZp4kjy/py/xztR1cXOF/FuzEuGc2GaYEK3cCaAtVna0rTT9TwxPKcTpGQIJvjlZHNxEl5BoeJdARC8GGPQ=="], - "eslint-plugin-mdx": ["eslint-plugin-mdx@3.7.0", "", { "dependencies": { "eslint-mdx": "^3.7.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.11.8", "unified": "^11.0.5", "vfile": "^6.0.3" }, "peerDependencies": { "eslint": ">=8.0.0" } }, "sha512-JXaaQPnKqyti/QSOSQDThLV1EemHm/Fe2l/nMKH0vmhvmABtN/yV/9+GtKgh8UTZwrwuTfQq1HW5eR8HXneNLA=="], + "eslint-plugin-mdx": ["eslint-plugin-mdx@3.8.1", "", { "dependencies": { "eslint-mdx": "^3.8.1", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.11.8", "unified": "^11.0.5", "vfile": "^6.0.3" }, "peerDependencies": { "eslint": ">=8.0.0" } }, "sha512-4OLgotfBxUDc1f6ihXSagT/1+JCCUABA/2r6Kzl6gqFftg4dCV0wBfdwFo6X6UO/FzTHr3g6mVt+6prRXffc/Q=="], - "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="], + "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.6", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.13" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], @@ -620,7 +623,7 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="], "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], @@ -644,7 +647,7 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], + "happy-dom": ["happy-dom@20.10.3", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], @@ -656,7 +659,7 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], @@ -730,6 +733,8 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="], + "is-empty": ["is-empty@1.2.0", "", {}, "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -816,10 +821,26 @@ "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], @@ -840,6 +861,20 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], @@ -904,11 +939,11 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], + "next": ["next@16.2.9", "", { "dependencies": { "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.9", "@next/swc-darwin-x64": "16.2.9", "@next/swc-linux-arm64-gnu": "16.2.9", "@next/swc-linux-arm64-musl": "16.2.9", "@next/swc-linux-x64-gnu": "16.2.9", "@next/swc-linux-x64-musl": "16.2.9", "@next/swc-win32-arm64-msvc": "16.2.9", "@next/swc-win32-x64-msvc": "16.2.9", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww=="], "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], - "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -944,7 +979,7 @@ "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], - "oxlint": ["oxlint@1.66.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.66.0", "@oxlint/binding-android-arm64": "1.66.0", "@oxlint/binding-darwin-arm64": "1.66.0", "@oxlint/binding-darwin-x64": "1.66.0", "@oxlint/binding-freebsd-x64": "1.66.0", "@oxlint/binding-linux-arm-gnueabihf": "1.66.0", "@oxlint/binding-linux-arm-musleabihf": "1.66.0", "@oxlint/binding-linux-arm64-gnu": "1.66.0", "@oxlint/binding-linux-arm64-musl": "1.66.0", "@oxlint/binding-linux-ppc64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-musl": "1.66.0", "@oxlint/binding-linux-s390x-gnu": "1.66.0", "@oxlint/binding-linux-x64-gnu": "1.66.0", "@oxlint/binding-linux-x64-musl": "1.66.0", "@oxlint/binding-openharmony-arm64": "1.66.0", "@oxlint/binding-win32-arm64-msvc": "1.66.0", "@oxlint/binding-win32-ia32-msvc": "1.66.0", "@oxlint/binding-win32-x64-msvc": "1.66.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw=="], + "oxlint": ["oxlint@1.69.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.69.0", "@oxlint/binding-android-arm64": "1.69.0", "@oxlint/binding-darwin-arm64": "1.69.0", "@oxlint/binding-darwin-x64": "1.69.0", "@oxlint/binding-freebsd-x64": "1.69.0", "@oxlint/binding-linux-arm-gnueabihf": "1.69.0", "@oxlint/binding-linux-arm-musleabihf": "1.69.0", "@oxlint/binding-linux-arm64-gnu": "1.69.0", "@oxlint/binding-linux-arm64-musl": "1.69.0", "@oxlint/binding-linux-ppc64-gnu": "1.69.0", "@oxlint/binding-linux-riscv64-gnu": "1.69.0", "@oxlint/binding-linux-riscv64-musl": "1.69.0", "@oxlint/binding-linux-s390x-gnu": "1.69.0", "@oxlint/binding-linux-x64-gnu": "1.69.0", "@oxlint/binding-linux-x64-musl": "1.69.0", "@oxlint/binding-openharmony-arm64": "1.69.0", "@oxlint/binding-win32-arm64-msvc": "1.69.0", "@oxlint/binding-win32-ia32-msvc": "1.69.0", "@oxlint/binding-win32-x64-msvc": "1.69.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -978,7 +1013,7 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="], "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], @@ -992,13 +1027,13 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -1038,6 +1073,8 @@ "rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -1074,9 +1111,9 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="], + "shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -1110,9 +1147,9 @@ "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], - "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="], - "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + "string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="], "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], @@ -1136,9 +1173,9 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], + "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1158,13 +1195,13 @@ "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], - "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="], + "typescript-eslint": ["typescript-eslint@8.61.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.61.0", "@typescript-eslint/parser": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], @@ -1224,7 +1261,7 @@ "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], - "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], @@ -1250,29 +1287,25 @@ "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - "@npmcli/config/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "@npmcli/config/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "@npmcli/git/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@npmcli/git/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "@npmcli/git/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "@npmcli/git/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "@npmcli/map-workspaces/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "@npmcli/package-json/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "@npmcli/package-json/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], - "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], - - "eslint-mdx/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -1280,13 +1313,15 @@ "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "normalize-package-data/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "npm-install-checks/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "normalize-package-data/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], - "npm-package-arg/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "npm-install-checks/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], - "npm-pick-manifest/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "npm-package-arg/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + + "npm-pick-manifest/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -1296,7 +1331,7 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "sharp/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1304,7 +1339,7 @@ "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "unified-engine/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + "unified-engine/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], "unified-engine/ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="], @@ -1326,9 +1361,7 @@ "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "eslint-mdx/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], diff --git a/crates/vespera/Cargo.toml b/crates/vespera/Cargo.toml index c8816469..87e8b4ff 100644 --- a/crates/vespera/Cargo.toml +++ b/crates/vespera/Cargo.toml @@ -11,6 +11,13 @@ repository.workspace = true # `impl garde::Validate` blocks and the `Validated` extractor is # available. Opt out with `default-features = false` if you need a # leaner build without the `garde` runtime dependency. +# +# `mimalloc` is also default-on, but it is a **weak no-op unless the +# `jni` feature is also enabled** (see the `mimalloc` feature below): a +# pure axum/OpenAPI build never compiles the mimalloc C library. JNI +# cdylib builds get the faster global allocator automatically (~9–14% +# on the dispatch hot path); set `default-features = false` to supply +# your own `#[global_allocator]` instead. default = [ "axum-extra/typed-header", "axum-extra/form", @@ -18,10 +25,18 @@ default = [ "axum-extra/multipart", "axum-extra/cookie", "validation", + "mimalloc", ] -cron = ["dep:tokio-cron-scheduler"] +cron = ["dep:tokio-cron-scheduler", "vespera_macro/cron"] inprocess = ["dep:vespera_inprocess"] jni = ["inprocess", "dep:vespera_jni"] +# mimalloc as the cdylib's global allocator (see vespera_jni docs). +# Default-on, but the `vespera_jni?/mimalloc` weak-dep syntax means it +# only does anything when the `jni` feature has pulled in vespera_jni — +# so non-JNI builds neither set a global allocator nor compile the +# mimalloc C library. Disable via `default-features = false` to bring +# your own allocator in a JNI cdylib. +mimalloc = ["vespera_jni?/mimalloc"] # Runtime validation: `#[derive(Schema)]` additionally emits # `impl garde::Validate` and the `Validated` extractor is enabled. # The `garde` crate is bundled internally and never named by user code. @@ -34,14 +49,17 @@ axum = { version = "0.8", features = ["multipart"] } axum-extra = { version = "0.12" } chrono = { version = "0.4", features = ["serde"] } tempfile = "3" +serde = { version = "1", features = ["derive"] } serde_json = "1" tower-layer = "0.3" tower-service = "0.3" tokio-cron-scheduler = { version = "0.15", optional = true } # Used by the `Serve` extension trait to bind a TcpListener and drive -# axum::serve. Default-on because virtually every axum user already -# has tokio in their dependency graph. -tokio = { version = "1", features = ["net", "rt"] } +# axum::serve, and by the multipart extractor to keep temp-file I/O +# off the async workers (`fs` + `io-util` for tokio::fs writes, +# `rt` for spawn_blocking). Default-on because virtually every axum +# user already has tokio in their dependency graph. +tokio = { version = "1", features = ["net", "rt", "fs", "io-util"] } vespera_inprocess = { workspace = true, optional = true } vespera_jni = { workspace = true, optional = true } # Hidden behind `validation` feature; re-exported via the private @@ -60,6 +78,28 @@ tower = { version = "0.5", features = ["util"] } # `vespera_inprocess::{register_app, dispatch_from_json}` directly so # they don't need the `inprocess` cargo feature to be enabled. vespera_inprocess = { workspace = true } +# Byte-snapshot testing for 422 validation envelope contract +insta = "1.48" +# `Validated` 422-envelope serialization before/after A/B bench. +criterion = { version = "0.8", features = ["html_reports"] } +# `derive` for the `#[derive(Validate)]` fixture; `email`/`url` for its +# field validators (the workspace `garde` is `default-features = false`). +garde = { version = "0.23", features = ["derive", "email", "url"] } +serde_json = "1" +# Compile-fail (UI) tests that assert the `#[route(responses=[...])]` and +# `#[cron("...")]` macros reject malformed input with a clean compile error +# instead of silently dropping it / panicking at runtime. See tests/ui/. +trybuild = "1" + +[[bench]] +name = "validation" +harness = false + +# multipart `4xx`/`422` error-envelope serialization before/after A/B bench +# (same borrowing-`Serialize` win as `validation`, on `TypedMultipartError`). +[[bench]] +name = "multipart_error" +harness = false [lints] workspace = true diff --git a/crates/vespera/benches/multipart_error.rs b/crates/vespera/benches/multipart_error.rs new file mode 100644 index 00000000..0d341b81 --- /dev/null +++ b/crates/vespera/benches/multipart_error.rs @@ -0,0 +1,129 @@ +//! Before/after A/B benchmark for the multipart `4xx`/`422` error-envelope +//! serialization (the cold but attacker-reachable malformed-input path). +//! +//! Both arms serialize the **same** [`TypedMultipartError`] to the **same** +//! bytes (`{"errors":[{"message":...,"path":...}]}`): +//! +//! - `before`: the original implementation — materialize the public message +//! with `error.to_string()` (one heap `String` per error) and serialize an +//! owned-`&str` envelope. +//! - `after`: the shipped implementation — a borrowing `Serialize` chain that +//! streams the error's own `Display` straight into `serde_json` via +//! `collect_str` (zero per-error `String` allocation), mirroring the +//! `Validated` 422 serializer in `multipart.rs`. +//! +//! The delta is the per-error `String` allocation the change removes. Both +//! arms assert byte-identical output so the bench can never silently drift +//! from the real envelope contract. + +use std::borrow::Cow; + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use serde::{Serialize, Serializer, ser::SerializeStruct}; +use vespera::multipart::TypedMultipartError; + +/// A realistic client-caused multipart error (the common 422 case): a scalar +/// field whose value failed to parse. Its `Display` carries the field name, +/// the wanted type, and the parse error — representative envelope work. +fn fixture() -> TypedMultipartError { + TypedMultipartError::WrongFieldType { + field_name: "age".to_owned(), + wanted: Cow::Borrowed("u8"), + source: "number too large to fit in target type".to_owned(), + } +} + +/// The offending field name doubles as the envelope `path`. +const PATH: &str = "age"; + +// ── AFTER: shipped borrowing Serialize chain (mirror of multipart.rs) ─ + +fn serialize_after(err: &TypedMultipartError) -> Vec { + struct Message<'a>(&'a TypedMultipartError); + impl Serialize for Message<'_> { + fn serialize(&self, s: S) -> Result { + // Client-caused variant → stream its `Display` with no `String`. + s.collect_str(self.0) + } + } + struct OneError<'a> { + err: &'a TypedMultipartError, + path: &'a str, + } + impl Serialize for OneError<'_> { + fn serialize(&self, s: S) -> Result { + let mut st = s.serialize_struct("MultipartOneError", 2)?; + st.serialize_field("message", &Message(self.err))?; + st.serialize_field("path", self.path)?; + st.end() + } + } + struct Envelope<'a> { + err: &'a TypedMultipartError, + path: &'a str, + } + impl Serialize for Envelope<'_> { + fn serialize(&self, s: S) -> Result { + let mut st = s.serialize_struct("MultipartErrorEnvelope", 1)?; + st.serialize_field( + "errors", + &[OneError { + err: self.err, + path: self.path, + }], + )?; + st.end() + } + } + serde_json::to_vec(&Envelope { err, path: PATH }).expect("infallible") +} + +// ── BEFORE: original owned-`String` message implementation ─────────── + +fn serialize_before(err: &TypedMultipartError) -> Vec { + #[derive(Serialize)] + struct OneError<'a> { + message: &'a str, + path: &'a str, + } + #[derive(Serialize)] + struct Envelope<'a> { + errors: [OneError<'a>; 1], + } + let message = err.to_string(); + serde_json::to_vec(&Envelope { + errors: [OneError { + message: &message, + path: PATH, + }], + }) + .expect("infallible") +} + +fn bench_multipart_error_envelope(c: &mut Criterion) { + let err = fixture(); + + // Guard: the two implementations MUST produce identical bytes, so the + // A/B compares the same observable work — never a shortcut. + assert_eq!( + serialize_before(&err), + serialize_after(&err), + "before/after multipart error-envelope bytes diverged" + ); + + let mut group = c.benchmark_group("multipart_error_envelope"); + group.bench_with_input( + BenchmarkId::new("owned_string_before", "WrongFieldType"), + &err, + |b, e| b.iter(|| serialize_before(std::hint::black_box(e))), + ); + group.bench_with_input( + BenchmarkId::new("borrowing_serialize_after", "WrongFieldType"), + &err, + |b, e| b.iter(|| serialize_after(std::hint::black_box(e))), + ); + group.finish(); +} + +criterion_group!(benches, bench_multipart_error_envelope); +criterion_main!(benches); diff --git a/crates/vespera/benches/validation.rs b/crates/vespera/benches/validation.rs new file mode 100644 index 00000000..80dea794 --- /dev/null +++ b/crates/vespera/benches/validation.rs @@ -0,0 +1,144 @@ +//! VESPERA-04 before/after A/B benchmark for the `422 Unprocessable +//! Entity` validation-envelope serialization. +//! +//! Both implementations serialize the **same** [`garde::Report`] to the +//! **same** bytes (`{"errors":[{"message":...,"path":...},...]}`): +//! +//! - `before`: the original implementation — collect every error into an +//! owned `Vec` (two `String` allocations per error) +//! and then `serde_json::to_vec`. +//! - `after`: the shipped implementation — a fully-borrowing custom +//! `Serialize` chain over `&garde::Report` (zero per-error `String` +//! allocation, `collect_str` straight into the serializer). +//! +//! The delta is the per-error allocation cost VESPERA-04 removes. Both +//! arms assert byte-identical output so the bench can never silently +//! drift from the real envelope contract. + +use std::fmt::Display; + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use garde::Validate; +use serde::{Serialize, Serializer, ser::SerializeStruct}; + +// ── Fixture: a struct whose validation fails on every field ────────── + +#[derive(Validate)] +struct Sample { + #[garde(length(min = 3, max = 32))] + username: String, + #[garde(email)] + email: String, + #[garde(range(min = 18, max = 120))] + age: u8, + #[garde(length(min = 10))] + bio: String, + #[garde(url)] + homepage: String, +} + +/// Produce a [`garde::Report`] with `n` failing fields by validating a +/// deliberately-invalid `Sample` and truncating the report's iteration +/// in the benchmarked closures (we just validate the whole struct; it +/// yields 5 errors — representative of a realistic multi-error 422). +fn failing_report() -> garde::Report { + let sample = Sample { + username: "x".to_owned(), // too short + email: "not-an-email".to_owned(), // invalid + age: 200, // out of range + bio: "short".to_owned(), // too short + homepage: "nope".to_owned(), // invalid url + }; + sample.validate().expect_err("sample must fail validation") +} + +// ── AFTER: shipped borrowing Serialize chain (mirror of validated.rs) ─ + +fn serialize_after(report: &garde::Report) -> Vec { + struct DisplayValue(T); + impl Serialize for DisplayValue { + fn serialize(&self, s: S) -> Result { + s.collect_str(&self.0) + } + } + struct Envelope<'a>(&'a garde::Report); + impl Serialize for Envelope<'_> { + fn serialize(&self, s: S) -> Result { + let mut env = s.serialize_struct("ValidationEnvelope", 1)?; + env.serialize_field("errors", &Errors(self.0))?; + env.end() + } + } + struct Errors<'a>(&'a garde::Report); + impl Serialize for Errors<'_> { + fn serialize(&self, s: S) -> Result { + s.collect_seq(self.0.iter().map(|(path, err)| OneError { path, err })) + } + } + struct OneError<'a> { + path: &'a garde::Path, + err: &'a garde::Error, + } + impl Serialize for OneError<'_> { + fn serialize(&self, s: S) -> Result { + let mut e = s.serialize_struct("ValidationError", 2)?; + e.serialize_field("message", &DisplayValue(self.err.message()))?; + e.serialize_field("path", &DisplayValue(self.path))?; + e.end() + } + } + serde_json::to_vec(&Envelope(report)).expect("infallible") +} + +// ── BEFORE: original owned-Vec implementation ──────────────── + +fn serialize_before(report: &garde::Report) -> Vec { + #[derive(Serialize)] + struct ValidationErrorOut { + message: String, + path: String, + } + #[derive(Serialize)] + struct Envelope { + errors: Vec, + } + let errors: Vec = report + .iter() + .map(|(path, err)| ValidationErrorOut { + message: err.message().to_string(), + path: path.to_string(), + }) + .collect(); + serde_json::to_vec(&Envelope { errors }).expect("infallible") +} + +fn bench_validation_envelope(c: &mut Criterion) { + let report = failing_report(); + + // Guard: the two implementations MUST produce identical bytes, so + // the A/B compares the same observable work — never a shortcut. + assert_eq!( + serialize_before(&report), + serialize_after(&report), + "before/after 422 envelope bytes diverged" + ); + + let n_errors = report.iter().count(); + let mut group = c.benchmark_group("validation_envelope"); + + group.bench_with_input( + BenchmarkId::new("owned_vec_string_before", n_errors), + &report, + |b, report| b.iter(|| serialize_before(std::hint::black_box(report))), + ); + group.bench_with_input( + BenchmarkId::new("borrowing_serialize_after", n_errors), + &report, + |b, report| b.iter(|| serialize_after(std::hint::black_box(report))), + ); + + group.finish(); +} + +criterion_group!(benches, bench_validation_envelope); +criterion_main!(benches); diff --git a/crates/vespera/src/lib.rs b/crates/vespera/src/lib.rs index f233d092..159600e3 100644 --- a/crates/vespera/src/lib.rs +++ b/crates/vespera/src/lib.rs @@ -67,16 +67,40 @@ where base: axum::Router, /// Routers to merge after `with_state()` is called merge_fns: Vec axum::Router<()>>, + /// Layers deferred until **after** child routers are merged. + /// + /// Axum's `Router::layer` only wraps the routes present at call + /// time, so applying a layer eagerly to `base` would leave + /// `merge`d child routes un-layered (CORS / auth / trace silently + /// skipped on merged routes). Storing the layer as a closure and + /// replaying it in `with_state()` after the merge guarantees it + /// covers every route. Each closure captures only the layer value + /// (`L: Send + Sync`), so the boxed trait object stays `Send + Sync` + /// and `VesperaRouter` keeps its previous auto-trait bounds. + layers: Vec axum::Router + Send + Sync>>, } impl VesperaRouter where S: Clone + Send + Sync + 'static, { - /// Create a new `VesperaRouter` with a base router and routers to merge + /// Create a `VesperaRouter` from a base router and the child-app router + /// factories to merge into it. + /// + /// This is invoked by the `vespera!` macro when the `merge = [...]` + /// parameter is used; it is rarely constructed directly. Both the merge of + /// the child routers and any [`layer`](Self::layer) added afterwards are + /// **deferred** until [`with_state`](Self::with_state): Axum can only merge + /// routers that share a state type, so the base router's state must be + /// applied first. When a `vespera!` app has no `merge` entries the macro + /// returns a plain `axum::Router` instead of this wrapper. #[must_use] pub fn new(base: axum::Router, merge_fns: Vec axum::Router<()>>) -> Self { - Self { base, merge_fns } + Self { + base, + merge_fns, + layers: Vec::new(), + } } /// Provide the state for the router and merge all child routers. @@ -96,12 +120,24 @@ where router = router.merge(merge_fn()); } + // Finally replay the deferred layers AFTER the merge so they wrap + // both the base routes and every merged child route. Applied in + // insertion order, preserving Axum's "last layer is outermost" + // semantics identical to chained `Router::layer` calls. + for apply in self.layers { + router = apply(router); + } + router } /// Add a layer to the router. + /// + /// The layer is **deferred** and applied in [`with_state`](Self::with_state) + /// after child routers are merged, so it covers merged routes as well as + /// the base router. #[must_use] - pub fn layer(self, layer: L) -> Self + pub fn layer(mut self, layer: L) -> Self where L: tower_layer::Layer + Clone + Send + Sync + 'static, L::Service: tower_service::Service + Clone + Send + Sync + 'static, @@ -111,10 +147,9 @@ where Into + 'static, >::Future: Send + 'static, { - Self { - base: self.base.layer(layer), - merge_fns: self.merge_fns, - } + self.layers + .push(Box::new(move |router: axum::Router| router.layer(layer))); + self } } @@ -137,7 +172,9 @@ pub mod __validation; #[cfg(feature = "validation")] mod validated; #[cfg(feature = "validation")] -pub use validated::{ValidatePayload, Validated}; +pub use validated::{ + ValidatePayload, ValidatePayloadWith, Validated, ValidatedWith, ValidationContext, +}; /// In-process dispatch — drive an axum Router without a TCP socket. #[cfg(feature = "inprocess")] diff --git a/crates/vespera/src/multipart.rs b/crates/vespera/src/multipart.rs index 6f8f8ea3..16857ad7 100644 --- a/crates/vespera/src/multipart.rs +++ b/crates/vespera/src/multipart.rs @@ -13,7 +13,12 @@ //! - [`TryFromMultipartWithState`] — Trait for parsing a full multipart request //! - [`TryFromFieldWithState`] — Trait for parsing a single multipart field -use std::fmt; +use std::{ + borrow::Cow, + cell::RefCell, + fmt, + sync::atomic::{AtomicUsize, Ordering}, +}; use axum::extract::multipart::{Field, MultipartError, MultipartRejection}; use axum::extract::{FromRequest, Request}; @@ -47,7 +52,7 @@ pub enum TypedMultipartError { /// Name of the field. field_name: String, /// The expected type name. - wanted: String, + wanted: Cow<'static, str>, /// Description of the parse error. source: String, }, @@ -77,6 +82,18 @@ pub enum TypedMultipartError { /// The configured limit in bytes. limit_bytes: usize, }, + /// The cumulative bytes read across all fields exceeded the request cap. + RequestTooLarge { + /// Name of the field whose chunk crossed the aggregate cap. + field_name: String, + /// The configured aggregate limit in bytes. + limit_bytes: usize, + }, + /// The multipart request contained more parts than the configured cap. + TooManyFields { + /// The configured maximum number of fields. + limit_fields: usize, + }, /// A catch-all for other errors during multipart processing. Other { /// Description of the error. @@ -84,6 +101,27 @@ pub enum TypedMultipartError { }, } +/// Maximum characters of a reflected, attacker-controlled value (an invalid +/// enum variant parsed from a multipart text field) echoed back in an error. +/// +/// The error `Display` feeds the serialized 4xx envelope via `collect_str` +/// ([`MultipartMessage`]), so bounding it here bounds BOTH `to_string()` and +/// the wire body — preventing a hostile field from amplifying the error +/// envelope (and its serialization cost) with a huge value. +const MAX_REFLECTED_VALUE_CHARS: usize = 128; + +/// Truncate a reflected value to [`MAX_REFLECTED_VALUE_CHARS`] on a `char` +/// boundary (never mid-UTF-8), appending a marker when shortened. Borrows +/// the original when it is already within the limit (the common case). +fn truncate_reflected_value(value: &str) -> std::borrow::Cow<'_, str> { + match value.char_indices().nth(MAX_REFLECTED_VALUE_CHARS) { + None => std::borrow::Cow::Borrowed(value), + Some((byte_idx, _)) => { + std::borrow::Cow::Owned(format!("{}... (truncated)", &value[..byte_idx])) + } + } +} + impl fmt::Display for TypedMultipartError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -113,7 +151,11 @@ impl fmt::Display for TypedMultipartError { write!(f, "Unknown field: `{field_name}`") } Self::InvalidEnumValue { field_name, value } => { - write!(f, "Invalid enum value `{value}` for field `{field_name}`") + write!( + f, + "Invalid enum value `{}` for field `{field_name}`", + truncate_reflected_value(value) + ) } Self::NamelessField => write!(f, "Encountered a field without a name"), Self::FieldTooLarge { @@ -125,28 +167,171 @@ impl fmt::Display for TypedMultipartError { "Field `{field_name}` exceeds size limit of {limit_bytes} bytes" ) } + Self::RequestTooLarge { + field_name, + limit_bytes, + } => write!( + f, + "Multipart request exceeds aggregate size limit of {limit_bytes} bytes while reading field `{field_name}`" + ), + Self::TooManyFields { limit_fields } => { + write!( + f, + "Multipart request exceeds field count limit of {limit_fields}" + ) + } Self::Other { source } => write!(f, "{source}"), } } } -impl std::error::Error for TypedMultipartError {} +impl std::error::Error for TypedMultipartError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidRequest { source } => Some(source), + Self::InvalidRequestBody { source } => Some(source), + Self::MissingField { .. } + | Self::WrongFieldType { .. } + | Self::DuplicateField { .. } + | Self::UnknownField { .. } + | Self::InvalidEnumValue { .. } + | Self::NamelessField + | Self::FieldTooLarge { .. } + | Self::RequestTooLarge { .. } + | Self::TooManyFields { .. } + | Self::Other { .. } => None, + } + } +} + +impl TypedMultipartError { + /// Build an invalid-enum error while bounding the attacker-controlled value stored in it. + #[must_use] + pub fn invalid_enum_value(field_name: String, value: &str) -> Self { + Self::InvalidEnumValue { + field_name, + value: truncate_reflected_value(value).into_owned(), + } + } + + /// The offending field name when the error carries one — used as the + /// `path` in the JSON error envelope. + fn field_name(&self) -> Option<&str> { + match self { + Self::MissingField { field_name } + | Self::WrongFieldType { field_name, .. } + | Self::DuplicateField { field_name } + | Self::UnknownField { field_name } + | Self::InvalidEnumValue { field_name, .. } + | Self::FieldTooLarge { field_name, .. } + | Self::RequestTooLarge { field_name, .. } => Some(field_name), + Self::InvalidRequest { .. } + | Self::InvalidRequestBody { .. } + | Self::NamelessField + | Self::TooManyFields { .. } + | Self::Other { .. } => None, + } + } + + /// Serialize the canonical `4xx`/`422` JSON error envelope + /// (`{"errors":[{"message":...,"path":...}]}`) for this error — byte- + /// identical to `Validated`'s envelope so JNI hoisting and clients + /// treat both uniformly. + /// + /// The message streams through [`MultipartMessage`]: `Other` (the only + /// `500`, whose source can leak temp-file paths / OS text) yields a stable + /// generic string; every other (client-caused) variant streams its own + /// `Display` with NO intermediate `String`. `path` is the offending field + /// name when known, else empty. Infallible in practice; the fallback keeps + /// this request-time path panic-free instead of unwinding in a handler. + fn error_body(&self) -> Vec { + serde_json::to_vec(&MultipartErrorEnvelope { + errors: [MultipartOneError { + message: MultipartMessage(self), + path: self.field_name().unwrap_or(""), + }], + }) + .unwrap_or_else(|_| br#"{"errors":[{"message":"serialization error","path":""}]}"#.to_vec()) + } +} + +/// Stable, source-free public message for the only `500` variant (`Other`), +/// whose wrapped `source` can leak temp-file paths / OS error text. Every +/// other variant is client-caused and safe to expose verbatim. +const MULTIPART_INTERNAL_ERROR_MSG: &str = "internal error while processing multipart request"; + +/// Streams a multipart error's public message straight into the serializer +/// with NO intermediate `String`: `Other` becomes [`MULTIPART_INTERNAL_ERROR_MSG`]; +/// every other (client-caused) variant streams its own `Display` via +/// `collect_str`. Byte-identical to the previous `to_string()`-then-serialize +/// path (serde escapes a `collect_str` stream exactly like an equal `&str`) +/// but allocation-free on the common client-error path — mirroring +/// `Validated`'s 422 serializer. +struct MultipartMessage<'a>(&'a TypedMultipartError); + +impl serde::Serialize for MultipartMessage<'_> { + fn serialize(&self, serializer: S) -> Result { + if matches!(self.0, TypedMultipartError::Other { .. }) { + serializer.serialize_str(MULTIPART_INTERNAL_ERROR_MSG) + } else { + serializer.collect_str(self.0) + } + } +} + +/// Canonical JSON error envelope, byte-identical to `Validated`'s 422 +/// envelope — `{"errors":[{"message":...,"path":...}]}` (message before path) +/// — so multipart failures are consumed uniformly and, under JNI, the 422 +/// body hoists into the wire header exactly like a `Validated` rejection. +/// Serialized through a borrowing `Serialize` (no `serde_json::Value` +/// map/array/object intermediate). +#[derive(serde::Serialize)] +struct MultipartOneError<'a> { + message: MultipartMessage<'a>, + path: &'a str, +} + +#[derive(serde::Serialize)] +struct MultipartErrorEnvelope<'a> { + errors: [MultipartOneError<'a>; 1], +} impl IntoResponse for TypedMultipartError { fn into_response(self) -> Response { let status = match &self { - Self::InvalidRequest { .. } - | Self::InvalidRequestBody { .. } - | Self::MissingField { .. } + // Preserve the SOURCE rejection / stream status so an over-limit + // multipart body surfaces as `413 Payload Too Large` (axum's body + // limit), an unsupported media type as `415`, etc. — instead of + // collapsing every transport-level failure to a generic `400`. + Self::InvalidRequest { source } => source.status(), + Self::InvalidRequestBody { source } => source.status(), + Self::MissingField { .. } | Self::DuplicateField { .. } | Self::UnknownField { .. } | Self::InvalidEnumValue { .. } | Self::NamelessField => StatusCode::BAD_REQUEST, - Self::WrongFieldType { .. } => StatusCode::UNSUPPORTED_MEDIA_TYPE, - Self::FieldTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, + // Scalar conversion failures are malformed field values, not an + // unsupported multipart media type. Keep this aligned with + // `Validated`'s validation-failure status. + Self::WrongFieldType { .. } => StatusCode::UNPROCESSABLE_ENTITY, + Self::FieldTooLarge { .. } + | Self::RequestTooLarge { .. } + | Self::TooManyFields { .. } => StatusCode::PAYLOAD_TOO_LARGE, Self::Other { .. } => StatusCode::INTERNAL_SERVER_ERROR, }; - (status, self.to_string()).into_response() + // Serialize the canonical JSON error envelope (see `error_body` / + // module-scope `MultipartErrorEnvelope`); the status varies (400/413/ + // 422/500) but the body shape is identical. + let body = self.error_body(); + ( + status, + [( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + )], + body, + ) + .into_response() } } @@ -179,14 +364,121 @@ pub trait TryFromMultipartWithState: Sized { ) -> impl std::future::Future> + Send; } +/// A multipart [`Field`] wrapper that meters every byte read against the +/// request-wide `max_total_bytes` aggregate cap — **non-cooperatively**. +/// +/// `#[derive(Multipart)]` hands each [`TryFromFieldWithState`] parser a +/// `MeteredField` instead of a raw [`Field`], so a **custom** field parser that +/// reads bytes via [`MeteredField::chunk`] / [`MeteredField::bytes`] is +/// accounted automatically: it can no longer read unboundedly past the +/// configured `max_total_bytes` the way a raw `field.chunk()` could. The +/// metadata accessors delegate to the wrapped field. +pub struct MeteredField<'a> { + inner: Field<'a>, +} + +impl<'a> MeteredField<'a> { + /// Wrap a raw axum field. Public + `#[doc(hidden)]`: the + /// `#[derive(Multipart)]` loop constructs this in the user's crate; it is + /// not part of the stable hand-written API. + #[doc(hidden)] + #[must_use] + pub fn __from_field(inner: Field<'a>) -> Self { + Self { inner } + } + + /// The field's form name, if present. + #[must_use] + pub fn name(&self) -> Option<&str> { + self.inner.name() + } + + /// The original client filename, if present. + #[must_use] + pub fn file_name(&self) -> Option<&str> { + self.inner.file_name() + } + + /// The field's declared content type, if present. + #[must_use] + pub fn content_type(&self) -> Option<&str> { + self.inner.content_type() + } + + /// Read the next chunk, metering its length against the request-wide + /// `max_total_bytes` cap **before** yielding it. Returns + /// [`TypedMultipartError::RequestTooLarge`] once the running total crosses + /// the cap. + pub async fn chunk(&mut self) -> Result, TypedMultipartError> { + let next = self.inner.chunk().await?; + if let Some(chunk) = &next { + register_multipart_bytes(self.inner.name().unwrap_or_default(), chunk.len())?; + } + Ok(next) + } + + /// Read the whole field into owned bytes, metering every chunk against the + /// aggregate cap. + pub async fn bytes(self) -> Result { + self.bytes_with_limit_inner(None, 0) + .await + .map(axum::body::Bytes::from) + } + + /// Read the whole field into owned bytes with a hard per-field limit. + /// + /// The limit is checked before copying each chunk into the accumulator, and + /// every chunk is still counted against the request-wide aggregate cap. + pub async fn bytes_with_limit( + self, + limit_bytes: usize, + initial_capacity: usize, + ) -> Result { + self.bytes_with_limit_inner(Some(limit_bytes), initial_capacity) + .await + .map(axum::body::Bytes::from) + } + + async fn bytes_with_limit_inner( + mut self, + limit: Option, + initial_capacity: usize, + ) -> Result, TypedMultipartError> { + let capacity = limit.map_or(initial_capacity, |limit| initial_capacity.min(limit)); + let mut acc: Vec = Vec::with_capacity(capacity); + while let Some(chunk) = self.chunk().await? { + if let Some(limit) = limit + && acc.len().saturating_add(chunk.len()) > limit + { + return Err(TypedMultipartError::FieldTooLarge { + field_name: self.name().unwrap_or_default().to_string(), + limit_bytes: limit, + }); + } + acc.extend_from_slice(&chunk); + } + Ok(acc) + } +} + +impl From<&MeteredField<'_>> for FieldMetadata { + fn from(field: &MeteredField<'_>) -> Self { + Self::from(&field.inner) + } +} + /// Parse a single multipart field into a value. /// /// Built-in implementations exist for `String`, `bool`, all integer and float /// types, `char`, `tempfile::NamedTempFile`, and `FieldData`. pub trait TryFromFieldWithState: Sized { /// Parse a single field into `Self`, optionally enforcing a byte-size limit. + /// + /// The field arrives as a [`MeteredField`]: every byte read through it + /// counts against the request-wide `max_total_bytes` aggregate cap, so even + /// a hand-written custom parser cannot bypass the limit. fn try_from_field_with_state( - field: Field<'_>, + field: MeteredField<'_>, limit_bytes: Option, state: &S, ) -> impl std::future::Future> + Send; @@ -205,8 +497,28 @@ pub struct FieldMetadata { pub file_name: Option, /// The MIME content type of the field. pub content_type: Option, - /// All HTTP headers associated with this multipart part. - pub headers: axum::http::HeaderMap, + /// Full HTTP headers associated with this multipart part, when explicitly captured. + /// + /// Vespera's built-in parsers only need `name`, `file_name`, and `content_type`, + /// so the default `FieldData` path no longer clones the whole `HeaderMap` for + /// every field. Use [`FieldMetadata::with_headers`] when constructing metadata + /// manually and the complete part header map is part of your API contract. + pub headers: Option, +} + +impl FieldMetadata { + /// Return the captured full multipart part headers, if they were collected. + #[must_use] + pub const fn headers(&self) -> Option<&axum::http::HeaderMap> { + self.headers.as_ref() + } + + /// Attach a full header snapshot to existing metadata. + #[must_use] + pub fn with_headers(mut self, headers: axum::http::HeaderMap) -> Self { + self.headers = Some(headers); + self + } } impl From<&Field<'_>> for FieldMetadata { @@ -215,7 +527,7 @@ impl From<&Field<'_>> for FieldMetadata { name: field.name().map(String::from), file_name: field.file_name().map(String::from), content_type: field.content_type().map(String::from), - headers: field.headers().clone(), + headers: None, } } } @@ -252,7 +564,7 @@ where S: Send + Sync, { async fn try_from_field_with_state( - field: Field<'_>, + field: MeteredField<'_>, limit_bytes: Option, state: &S, ) -> Result { @@ -303,6 +615,185 @@ impl std::ops::DerefMut for TypedMultipart { } } +/// Default aggregate cap for a typed multipart request body. +/// +/// Sized as a **bounded safety budget**, not a generous allowance: it is the +/// guard that still applies when applications disable or raise axum's +/// [`DefaultBodyLimit`](axum::extract::DefaultBodyLimit) (notably the +/// in-process / JNI upload path, where axum's HTTP-layer limit never runs). At +/// 64 MiB a single request can no longer pin hundreds of MiB of buffered text +/// fields / temp-file I/O — the practical DoS budget the previous 512 MiB +/// default handed every caller. Applications that legitimately accept larger +/// typed uploads opt in explicitly via [`TypedMultipartWithLimits`] or +/// [`set_default_multipart_limits`]; genuinely large payloads should stream. +pub const DEFAULT_MULTIPART_MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024; // 64 MiB + +/// Default maximum number of parts in a typed multipart request. +pub const DEFAULT_MULTIPART_MAX_FIELDS: usize = 1024; + +static DEFAULT_MULTIPART_TOTAL_LIMIT: AtomicUsize = + AtomicUsize::new(DEFAULT_MULTIPART_MAX_TOTAL_BYTES); +static DEFAULT_MULTIPART_FIELD_LIMIT: AtomicUsize = AtomicUsize::new(DEFAULT_MULTIPART_MAX_FIELDS); + +/// Aggregate resource policy for [`TypedMultipart`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MultipartLimits { + /// Maximum cumulative bytes accepted across all parsed fields. + pub max_total_bytes: usize, + /// Maximum number of parsed fields accepted in one request. + pub max_fields: usize, +} + +impl MultipartLimits { + /// Construct an aggregate multipart policy. + #[must_use] + pub const fn new(max_total_bytes: usize, max_fields: usize) -> Self { + Self { + max_total_bytes, + max_fields, + } + } +} + +/// Return the process-wide default aggregate multipart policy. +#[must_use] +pub fn default_multipart_limits() -> MultipartLimits { + MultipartLimits::new( + DEFAULT_MULTIPART_TOTAL_LIMIT.load(Ordering::Relaxed), + DEFAULT_MULTIPART_FIELD_LIMIT.load(Ordering::Relaxed), + ) +} + +/// Set the process-wide default aggregate multipart policy. +/// +/// Prefer calling this during application startup, before request handling. For +/// per-route policies use [`TypedMultipartWithLimits`], which avoids global +/// process state and is therefore safer in tests and multi-tenant apps. +pub fn set_default_multipart_limits(limits: MultipartLimits) -> MultipartLimits { + MultipartLimits::new( + DEFAULT_MULTIPART_TOTAL_LIMIT.swap(limits.max_total_bytes, Ordering::Relaxed), + DEFAULT_MULTIPART_FIELD_LIMIT.swap(limits.max_fields, Ordering::Relaxed), + ) +} + +#[derive(Debug)] +struct MultipartAggregateState { + limits: MultipartLimits, + total_bytes: usize, + fields: usize, +} + +impl MultipartAggregateState { + const fn new(limits: MultipartLimits) -> Self { + Self { + limits, + total_bytes: 0, + fields: 0, + } + } +} + +tokio::task_local! { + static MULTIPART_AGGREGATE: RefCell; +} + +/// Count one multipart PART against the request-wide `max_fields` limit. +/// +/// Invoked by the derived `TryFromMultipart` loop **once per wire part** — +/// before the field name is resolved — so EVERY part (known, unknown, or +/// nameless) is counted exactly once. Counting inside the per-known-field +/// parsers instead let unknown parts in non-strict mode (the `_ => {}` +/// dispatch arm) slip past the cap entirely, so a request with thousands of +/// unknown parts could burn unbounded parser/boundary-scan work without ever +/// tripping `TooManyFields`. +pub fn register_multipart_part() -> Result<(), TypedMultipartError> { + MULTIPART_AGGREGATE + .try_with(|state| { + let mut state = state.borrow_mut(); + state.fields = state.fields.saturating_add(1); + if state.fields > state.limits.max_fields { + return Err(TypedMultipartError::TooManyFields { + limit_fields: state.limits.max_fields, + }); + } + Ok(()) + }) + // The derived impl can be unit-tested outside the extractor scope; with + // no request aggregate present, counting no-ops rather than failing. + .unwrap_or(Ok(())) +} + +/// Count `chunk_len` bytes of one multipart field against the request-wide +/// `max_total_bytes` aggregate limit, returning [`TypedMultipartError::RequestTooLarge`] +/// once the running total crosses the cap. +/// +/// The public counterpart of [`register_multipart_part`] for the **byte** +/// dimension of [`MultipartLimits`]. Vespera's built-in field parsers +/// ([`read_field_data`] / the `NamedTempFile` path) already call this once per +/// `field.chunk()`, so typed multipart structs are accounted automatically. +/// +/// Built-in field parsers — and any **custom [`TryFromFieldWithState`]** +/// implementation — read a field's bytes through [`MeteredField::chunk`] / +/// [`MeteredField::bytes`], which call this automatically once per chunk. A +/// custom parser therefore **cannot** bypass the aggregate cap: [`MeteredField`] +/// owns the only access to the field's bytes (the raw axum [`Field`] is never +/// exposed), so every byte is counted regardless of how the parser is written. +/// The per-field `limit_bytes` passed to the trait method still bounds that one +/// field; this call enforces the request-wide total. Mirrors the cooperative +/// contract of [`register_multipart_part`]: outside the extractor's task-local +/// scope (e.g. a direct unit test of a derived parser) it no-ops rather than +/// failing. +pub fn register_multipart_bytes( + field_name: &str, + chunk_len: usize, +) -> Result<(), TypedMultipartError> { + MULTIPART_AGGREGATE + .try_with(|state| { + let mut state = state.borrow_mut(); + state.total_bytes = state.total_bytes.saturating_add(chunk_len); + if state.total_bytes > state.limits.max_total_bytes { + return Err(TypedMultipartError::RequestTooLarge { + field_name: field_name.to_owned(), + limit_bytes: state.limits.max_total_bytes, + }); + } + Ok(()) + }) + .unwrap_or(Ok(())) +} + +/// Axum extractor variant with const aggregate multipart limits. +/// +/// Use this when a route needs a tighter or looser request-level policy than +/// the process default. Per-field `#[form_data(limit = "...")]` caps still +/// apply independently: the effective policy is whichever per-field or +/// aggregate limit is exceeded first. +pub struct TypedMultipartWithLimits< + T, + const MAX_TOTAL_BYTES: usize, + const MAX_FIELDS: usize = DEFAULT_MULTIPART_MAX_FIELDS, +>(pub T); + +async fn parse_typed_multipart_with_limits( + req: Request, + state: &S, + limits: MultipartLimits, +) -> Result +where + T: TryFromMultipartWithState, + S: Send + Sync + 'static, +{ + let mut multipart = axum::extract::Multipart::from_request(req, state) + .await + .map_err(TypedMultipartError::from)?; + MULTIPART_AGGREGATE + .scope( + RefCell::new(MultipartAggregateState::new(limits)), + async move { T::try_from_multipart_with_state(&mut multipart, state).await }, + ) + .await +} + impl FromRequest for TypedMultipart where T: TryFromMultipartWithState, @@ -311,10 +802,27 @@ where type Rejection = TypedMultipartError; async fn from_request(req: Request, state: &S) -> Result { - let mut multipart = axum::extract::Multipart::from_request(req, state) - .await - .map_err(TypedMultipartError::from)?; - let value = T::try_from_multipart_with_state(&mut multipart, state).await?; + let value = + parse_typed_multipart_with_limits(req, state, default_multipart_limits()).await?; + Ok(Self(value)) + } +} + +impl FromRequest + for TypedMultipartWithLimits +where + T: TryFromMultipartWithState, + S: Send + Sync + 'static, +{ + type Rejection = TypedMultipartError; + + async fn from_request(req: Request, state: &S) -> Result { + let value = parse_typed_multipart_with_limits( + req, + state, + MultipartLimits::new(MAX_TOTAL_BYTES, MAX_FIELDS), + ) + .await?; Ok(Self(value)) } } @@ -325,387 +833,187 @@ where // ─── Helpers ──────────────────────────────────────────────────────────────── -/// Read all bytes from a multipart field, enforcing an optional size limit. +/// Read all bytes from a multipart field into an owned `Vec`, +/// enforcing an optional size limit. /// -/// When a limit is set, bytes are read incrementally via `chunk()` and the -/// cumulative size is checked after each chunk. Without a limit, `bytes()` is -/// called for a single-allocation read. +/// Bytes are accumulated chunk-by-chunk directly into the returned +/// `Vec` — the same buffer `String::from_utf8` later reuses without a +/// copy. This deliberately avoids the previous +/// `field.bytes().await?.to_vec()` on the unlimited path, which built +/// an owned `Bytes` and then copied it into a *second* allocation, +/// doubling peak memory for large text/scalar fields. (Returning +/// `Bytes` instead would only shift that second copy onto the `String` +/// parser, so direct `Vec` accumulation is the allocation-minimal +/// shape for every current caller.) +/// +/// When a limit is set the cumulative size is checked after each chunk +/// and an over-limit chunk is rejected *before* it is copied in. async fn read_field_data( - mut field: Field<'_>, + field: MeteredField<'_>, limit: Option, + initial_capacity: usize, ) -> Result<(String, Vec), TypedMultipartError> { + // Part counting now happens once per part in the derived loop + // (`register_multipart_part`), so the field parsers no longer count. + // Initial capacity is independent from the hard byte limit: tiny scalar + // fields keep the 256B cap without preallocating 256B per bool/number. let field_name = field.name().unwrap_or_default().to_string(); + let buf = field + .bytes_with_limit_inner(limit, initial_capacity) + .await?; + Ok((field_name, buf)) +} - let data = if let Some(limit) = limit { - let mut buf = Vec::new(); - while let Some(chunk) = field.chunk().await? { - buf.extend_from_slice(&chunk); - if buf.len() > limit { - return Err(TypedMultipartError::FieldTooLarge { - field_name, - limit_bytes: limit, - }); - } - } - buf - } else { - field.bytes().await?.to_vec() - }; - - Ok((field_name, data)) +/// Default cap for tiny scalar multipart fields when no explicit +/// `#[form_data(limit = "...")]` is supplied. 256 bytes is far beyond any +/// legitimate bool/number/char payload while preventing unbounded buffering. +const DEFAULT_TINY_SCALAR_LIMIT_BYTES: usize = 256; +const TINY_SCALAR_INITIAL_CAPACITY_BYTES: usize = 16; +const STRING_INITIAL_CAPACITY_BYTES: usize = 64; + +/// Resolve the buffering cap for a tiny scalar field: the explicit +/// per-field `#[form_data(limit = "...")]` if present, otherwise the +/// conservative [`DEFAULT_TINY_SCALAR_LIMIT_BYTES`] default. A cap is +/// always applied — scalars never buffer unbounded input. +fn tiny_scalar_limit(limit_bytes: Option) -> usize { + limit_bytes.unwrap_or(DEFAULT_TINY_SCALAR_LIMIT_BYTES) } /// Parse a string as a boolean using clap-style conventions. /// +/// Surrounding ASCII whitespace is ignored, so a multipart text value that +/// arrives with incidental padding (e.g. a trailing newline) parses like the +/// trimmed token — matching the numeric field impls, which `text.trim().parse()`. +/// /// Accepted truthy values: `true`, `yes`, `y`, `1`, `on` /// Accepted falsy values: `false`, `no`, `n`, `0`, `off` fn str_to_bool(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "true" | "yes" | "y" | "1" | "on" => Some(true), - "false" | "no" | "n" | "0" | "off" => Some(false), - _ => None, + const TRUTHY: [&str; 5] = ["true", "yes", "y", "1", "on"]; + const FALSY: [&str; 5] = ["false", "no", "n", "0", "off"]; + let s = s.trim(); + if TRUTHY.iter().any(|t| s.eq_ignore_ascii_case(t)) { + Some(true) + } else if FALSY.iter().any(|f| s.eq_ignore_ascii_case(f)) { + Some(false) + } else { + None } } // ─── String ───────────────────────────────────────────────────────────────── -impl TryFromFieldWithState for String { - async fn try_from_field_with_state( - field: Field<'_>, - limit_bytes: Option, - _state: &S, - ) -> Result { - let (field_name, data) = read_field_data(field, limit_bytes).await?; - Self::from_utf8(data).map_err(|e| TypedMultipartError::WrongFieldType { - field_name, - wanted: "String".to_string(), - source: e.to_string(), - }) - } -} - -// ─── bool ─────────────────────────────────────────────────────────────────── - -impl TryFromFieldWithState for bool { - async fn try_from_field_with_state( - field: Field<'_>, - limit_bytes: Option, - _state: &S, - ) -> Result { - let (field_name, data) = read_field_data(field, limit_bytes).await?; - let text = std::str::from_utf8(&data).map_err(|e| TypedMultipartError::WrongFieldType { - field_name: field_name.clone(), - wanted: "bool".to_string(), - source: e.to_string(), - })?; - str_to_bool(text).ok_or_else(|| TypedMultipartError::WrongFieldType { - field_name, - wanted: "bool".to_string(), - source: format!("invalid boolean value: `{text}`"), - }) - } +/// Default buffering cap for an **unannotated** `String` multipart field. +/// +/// Generous enough for any realistic text field (form text, JSON blobs, +/// small base64) yet converts the former *unbounded* accumulation into a +/// bounded one — closing a per-request memory-exhaustion vector where a +/// client could stream gigabytes into a single text field. Opt out per +/// field with `#[form_data(limit = "unlimited")]`, or raise / lower it with +/// an explicit `#[form_data(limit = "...")]`. +const DEFAULT_STRING_FIELD_LIMIT_BYTES: usize = 1024 * 1024; // 1 MiB + +/// Default streaming cap for an **unannotated** `NamedTempFile` multipart field. +/// +/// The cap is intentionally larger than text fields: unannotated temp-file uploads +/// are real file uploads, but still need a denial-of-service guard by default. +/// Explicit `#[form_data(limit = "unlimited")]` continues to opt out by passing +/// `usize::MAX` through the derive-generated parser. Applications can tune the +/// process-wide default before handling requests with +/// [`set_default_temp_file_field_limit_bytes`]. +/// +/// Note: `"unlimited"` lifts only this **per-field** cap. The request-wide +/// aggregate budget ([`DEFAULT_MULTIPART_MAX_TOTAL_BYTES`], 64 MiB by default) +/// still applies, so a single `"unlimited"` field is bounded by the aggregate +/// rather than being truly unbounded. To raise the aggregate, use +/// [`TypedMultipartWithLimits`] (per-route) or [`set_default_multipart_limits`] +/// (process-wide); genuinely large uploads should stream instead. +pub const DEFAULT_TEMP_FILE_FIELD_LIMIT_BYTES: usize = 16 * 1024 * 1024; // 16 MiB + +static DEFAULT_TEMP_FILE_FIELD_LIMIT: AtomicUsize = + AtomicUsize::new(DEFAULT_TEMP_FILE_FIELD_LIMIT_BYTES); + +/// Return the current process-wide default cap for unannotated `NamedTempFile` fields. +#[must_use] +pub fn default_temp_file_field_limit_bytes() -> usize { + DEFAULT_TEMP_FILE_FIELD_LIMIT.load(Ordering::Relaxed) } -// ─── Numeric types ────────────────────────────────────────────────────────── - -macro_rules! impl_try_from_field_for_number { - ($($ty:ty),* $(,)?) => { - $( - impl TryFromFieldWithState for $ty { - async fn try_from_field_with_state( - field: Field<'_>, - limit_bytes: Option, - _state: &S, - ) -> Result { - let (field_name, data) = read_field_data(field, limit_bytes).await?; - let text = std::str::from_utf8(&data).map_err(|e| { - TypedMultipartError::WrongFieldType { - field_name: field_name.clone(), - wanted: stringify!($ty).to_string(), - source: e.to_string(), - } - })?; - text.trim().parse::<$ty>().map_err(|e| { - TypedMultipartError::WrongFieldType { - field_name, - wanted: stringify!($ty).to_string(), - source: e.to_string(), - } - }) - } - } - )* - }; +/// Set the process-wide default cap for unannotated `NamedTempFile` fields. +/// +/// Call this during application startup, before request handling begins. Per-field +/// `#[form_data(limit = "...")]` annotations still take precedence, including the +/// explicit `"unlimited"` opt-out. The previous cap is returned to support tests or +/// embedders that need to restore their process setting. +pub fn set_default_temp_file_field_limit_bytes(limit_bytes: usize) -> usize { + DEFAULT_TEMP_FILE_FIELD_LIMIT.swap(limit_bytes, Ordering::Relaxed) } -impl_try_from_field_for_number!( - i8, i16, i32, i64, i128, u8, u16, u32, u64, u128, isize, usize, f32, f64, -); - -// ─── char ─────────────────────────────────────────────────────────────────── - -impl TryFromFieldWithState for char { - async fn try_from_field_with_state( - field: Field<'_>, - limit_bytes: Option, - _state: &S, - ) -> Result { - let (field_name, data) = read_field_data(field, limit_bytes).await?; - let text = std::str::from_utf8(&data).map_err(|e| TypedMultipartError::WrongFieldType { - field_name: field_name.clone(), - wanted: "char".to_string(), - source: e.to_string(), - })?; - let mut chars = text.chars(); - match (chars.next(), chars.next()) { - (Some(c), None) => Ok(c), - _ => Err(TypedMultipartError::WrongFieldType { - field_name, - wanted: "char".to_string(), - source: "expected exactly one character".to_string(), - }), - } - } -} +// Scalar field parsers (`String`, `bool`, integers/floats, `char`) live in a +// sidecar module so `multipart.rs` stays within the 1000-line source cap. +mod scalar_parsers; // ─── NamedTempFile ────────────────────────────────────────────────────────── impl TryFromFieldWithState for tempfile::NamedTempFile { async fn try_from_field_with_state( - mut field: Field<'_>, + mut field: MeteredField<'_>, limit_bytes: Option, _state: &S, ) -> Result { - let field_name = field.name().unwrap_or_default().to_string(); - let mut temp = Self::new().map_err(|e| TypedMultipartError::Other { + // Part counting happens once per part in the derived loop + // (`register_multipart_part`); the temp-file parser no longer counts. + // Temp-file creation AND reopen() are both blocking syscalls — + // run them together on the blocking pool so neither stalls the + // async worker (the reopen previously ran inline on the async + // task). `NamedTempFile` (not `tokio::fs::File`) is retained so + // cleanup-on-drop semantics survive; the reopened std handle is + // wrapped in `tokio::fs` below so large writes also route to the + // blocking pool. `temp` keeps ownership of the path + delete-on- + // drop guard. + let (temp, std_file) = tokio::task::spawn_blocking(|| { + let temp = Self::new()?; + let std_file = temp.reopen()?; + Ok::<_, std::io::Error>((temp, std_file)) + }) + .await + .map_err(|e| TypedMultipartError::Other { + source: e.to_string(), + })? + .map_err(|e| TypedMultipartError::Other { source: e.to_string(), })?; + let mut file = tokio::fs::File::from_std(std_file); + let limit_bytes = limit_bytes.unwrap_or_else(default_temp_file_field_limit_bytes); let mut total = 0usize; while let Some(chunk) = field.chunk().await? { - total += chunk.len(); - if let Some(limit) = limit_bytes - && total > limit - { + // `MeteredField::chunk` already counts the chunk against the + // request-wide `max_total_bytes` aggregate cap (no double-count). + // `saturating_add` (matching `read_field_data`) prevents a + // pathological chunk size from wrapping `total` and slipping + // past the limit check below. + total = total.saturating_add(chunk.len()); + if total > limit_bytes { return Err(TypedMultipartError::FieldTooLarge { - field_name, - limit_bytes: limit, + field_name: field.name().unwrap_or_default().to_string(), + limit_bytes, }); } - std::io::Write::write_all(&mut temp, &chunk).map_err(|e| { - TypedMultipartError::Other { + tokio::io::AsyncWriteExt::write_all(&mut file, &chunk) + .await + .map_err(|e| TypedMultipartError::Other { source: e.to_string(), - } - })?; + })?; } + tokio::io::AsyncWriteExt::flush(&mut file) + .await + .map_err(|e| TypedMultipartError::Other { + source: e.to_string(), + })?; Ok(temp) } } #[cfg(test)] -mod tests { - use super::*; - use axum::http::StatusCode; - use axum::response::IntoResponse; - - #[test] - fn test_str_to_bool_truthy() { - for val in &[ - "true", "True", "TRUE", "yes", "Yes", "y", "Y", "1", "on", "ON", - ] { - assert_eq!(str_to_bool(val), Some(true), "expected true for `{val}`"); - } - } - - #[test] - fn test_str_to_bool_falsy() { - for val in &[ - "false", "False", "FALSE", "no", "No", "n", "N", "0", "off", "OFF", - ] { - assert_eq!(str_to_bool(val), Some(false), "expected false for `{val}`"); - } - } - - #[test] - fn test_str_to_bool_invalid() { - for val in &["maybe", "2", "", "yep", "nah"] { - assert_eq!(str_to_bool(val), None, "expected None for `{val}`"); - } - } - - // ─── Display tests for all error variants ─────────────────────────── - - #[test] - fn test_error_display() { - let err = TypedMultipartError::MissingField { - field_name: "name".to_string(), - }; - assert_eq!(err.to_string(), "Missing field: `name`"); - - let err = TypedMultipartError::FieldTooLarge { - field_name: "file".to_string(), - limit_bytes: 1024, - }; - assert_eq!( - err.to_string(), - "Field `file` exceeds size limit of 1024 bytes" - ); - - let err = TypedMultipartError::WrongFieldType { - field_name: "age".to_string(), - wanted: "i32".to_string(), - source: "invalid digit".to_string(), - }; - assert_eq!( - err.to_string(), - "Wrong type for field `age` (expected i32): invalid digit" - ); - } - - #[test] - fn test_error_display_duplicate_field() { - let err = TypedMultipartError::DuplicateField { - field_name: "email".to_string(), - }; - assert_eq!(err.to_string(), "Duplicate field: `email`"); - } - - #[test] - fn test_error_display_unknown_field() { - let err = TypedMultipartError::UnknownField { - field_name: "foo".to_string(), - }; - assert_eq!(err.to_string(), "Unknown field: `foo`"); - } - - #[test] - fn test_error_display_invalid_enum_value() { - let err = TypedMultipartError::InvalidEnumValue { - field_name: "status".to_string(), - value: "maybe".to_string(), - }; - assert_eq!( - err.to_string(), - "Invalid enum value `maybe` for field `status`" - ); - } - - #[test] - fn test_error_display_nameless_field() { - let err = TypedMultipartError::NamelessField; - assert_eq!(err.to_string(), "Encountered a field without a name"); - } - - #[test] - fn test_error_display_other() { - let err = TypedMultipartError::Other { - source: "something went wrong".to_string(), - }; - assert_eq!(err.to_string(), "something went wrong"); - } - - // ─── IntoResponse status code tests ───────────────────────────────── - - #[test] - fn test_into_response_duplicate_field() { - let err = TypedMultipartError::DuplicateField { - field_name: "x".to_string(), - }; - let resp = err.into_response(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - } - - #[test] - fn test_into_response_unknown_field() { - let err = TypedMultipartError::UnknownField { - field_name: "x".to_string(), - }; - let resp = err.into_response(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - } - - #[test] - fn test_into_response_invalid_enum_value() { - let err = TypedMultipartError::InvalidEnumValue { - field_name: "x".to_string(), - value: "bad".to_string(), - }; - let resp = err.into_response(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - } - - #[test] - fn test_into_response_nameless_field() { - let err = TypedMultipartError::NamelessField; - let resp = err.into_response(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - } - - #[test] - fn test_into_response_wrong_field_type() { - let err = TypedMultipartError::WrongFieldType { - field_name: "age".to_string(), - wanted: "i32".to_string(), - source: "err".to_string(), - }; - let resp = err.into_response(); - assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - } - - #[test] - fn test_into_response_field_too_large() { - let err = TypedMultipartError::FieldTooLarge { - field_name: "file".to_string(), - limit_bytes: 100, - }; - let resp = err.into_response(); - assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); - } - - #[test] - fn test_into_response_other() { - let err = TypedMultipartError::Other { - source: "err".to_string(), - }; - let resp = err.into_response(); - assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); - } - - #[test] - fn test_into_response_missing_field() { - let err = TypedMultipartError::MissingField { - field_name: "x".to_string(), - }; - let resp = err.into_response(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - } - - // ─── Error trait ──────────────────────────────────────────────────── - - #[test] - fn test_error_trait_is_implemented() { - let err: Box = Box::new(TypedMultipartError::Other { - source: "test".to_string(), - }); - assert_eq!(err.to_string(), "test"); - } - - // ─── TypedMultipart Deref / DerefMut ──────────────────────────────── - - #[test] - fn test_typed_multipart_deref() { - let tm = TypedMultipart("hello".to_string()); - // Deref: &TypedMultipart → &String - assert_eq!(&*tm, "hello"); - assert_eq!(tm.len(), 5); // auto-deref to String method - } - - #[test] - fn test_typed_multipart_deref_mut() { - let mut tm = TypedMultipart(vec![1, 2, 3]); - // DerefMut: &mut TypedMultipart> → &mut Vec - tm.push(4); - assert_eq!(&*tm, &[1, 2, 3, 4]); - } -} +mod tests; diff --git a/crates/vespera/src/multipart/scalar_parsers.rs b/crates/vespera/src/multipart/scalar_parsers.rs new file mode 100644 index 00000000..596a8db3 --- /dev/null +++ b/crates/vespera/src/multipart/scalar_parsers.rs @@ -0,0 +1,137 @@ +//! [`TryFromFieldWithState`] implementations for scalar multipart fields +//! (`String`, `bool`, the integer / float types, and `char`). +//! +//! Split out of `multipart.rs` to keep that file within the repository's +//! 1000-line source cap. `use super::*` keeps the shared helpers in scope — +//! [`read_field_data`], [`MeteredField`], `tiny_scalar_limit`, `str_to_bool`, +//! the capacity / limit constants, and [`TypedMultipartError`]. + +use std::borrow::Cow; + +use super::{ + DEFAULT_STRING_FIELD_LIMIT_BYTES, MeteredField, STRING_INITIAL_CAPACITY_BYTES, + TINY_SCALAR_INITIAL_CAPACITY_BYTES, TryFromFieldWithState, TypedMultipartError, + read_field_data, str_to_bool, tiny_scalar_limit, truncate_reflected_value, +}; + +impl TryFromFieldWithState for String { + async fn try_from_field_with_state( + field: MeteredField<'_>, + limit_bytes: Option, + _state: &S, + ) -> Result { + // An ABSENT limit (`None`) applies the generous default cap; an + // explicit `#[form_data(limit = "unlimited")]` arrives as + // `Some(usize::MAX)` (set by the derive macro) and stays unbounded; + // an explicit byte size wins as `Some(n)`. + let limit = limit_bytes.unwrap_or(DEFAULT_STRING_FIELD_LIMIT_BYTES); + let (field_name, data) = + read_field_data(field, Some(limit), STRING_INITIAL_CAPACITY_BYTES).await?; + Self::from_utf8(data).map_err(|e| TypedMultipartError::WrongFieldType { + field_name, + wanted: Cow::Borrowed("String"), + source: e.to_string(), + }) + } +} + +// ─── bool ─────────────────────────────────────────────────────────────────── + +impl TryFromFieldWithState for bool { + async fn try_from_field_with_state( + field: MeteredField<'_>, + limit_bytes: Option, + _state: &S, + ) -> Result { + let (field_name, data) = read_field_data( + field, + Some(tiny_scalar_limit(limit_bytes)), + TINY_SCALAR_INITIAL_CAPACITY_BYTES, + ) + .await?; + let text = std::str::from_utf8(&data).map_err(|e| TypedMultipartError::WrongFieldType { + field_name: field_name.clone(), + wanted: Cow::Borrowed("bool"), + source: e.to_string(), + })?; + str_to_bool(text).ok_or_else(|| TypedMultipartError::WrongFieldType { + field_name, + wanted: Cow::Borrowed("bool"), + source: format!( + "invalid boolean value: `{}`", + truncate_reflected_value(text) + ), + }) + } +} + +// ─── Numeric types ────────────────────────────────────────────────────────── + +macro_rules! impl_try_from_field_for_number { + ($($ty:ty),* $(,)?) => { + $( + impl TryFromFieldWithState for $ty { + async fn try_from_field_with_state( + field: MeteredField<'_>, + limit_bytes: Option, + _state: &S, + ) -> Result { + let (field_name, data) = read_field_data( + field, + Some(tiny_scalar_limit(limit_bytes)), + TINY_SCALAR_INITIAL_CAPACITY_BYTES, + ).await?; + let text = std::str::from_utf8(&data).map_err(|e| { + TypedMultipartError::WrongFieldType { + field_name: field_name.clone(), + wanted: Cow::Borrowed(stringify!($ty)), + source: e.to_string(), + } + })?; + text.trim().parse::<$ty>().map_err(|e| { + TypedMultipartError::WrongFieldType { + field_name, + wanted: Cow::Borrowed(stringify!($ty)), + source: e.to_string(), + } + }) + } + } + )* + }; +} + +impl_try_from_field_for_number!( + i8, i16, i32, i64, i128, u8, u16, u32, u64, u128, isize, usize, f32, f64, +); + +// ─── char ─────────────────────────────────────────────────────────────────── + +impl TryFromFieldWithState for char { + async fn try_from_field_with_state( + field: MeteredField<'_>, + limit_bytes: Option, + _state: &S, + ) -> Result { + let (field_name, data) = read_field_data( + field, + Some(tiny_scalar_limit(limit_bytes)), + TINY_SCALAR_INITIAL_CAPACITY_BYTES, + ) + .await?; + let text = std::str::from_utf8(&data).map_err(|e| TypedMultipartError::WrongFieldType { + field_name: field_name.clone(), + wanted: Cow::Borrowed("char"), + source: e.to_string(), + })?; + let mut chars = text.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => Ok(c), + _ => Err(TypedMultipartError::WrongFieldType { + field_name, + wanted: Cow::Borrowed("char"), + source: "expected exactly one character".to_string(), + }), + } + } +} diff --git a/crates/vespera/src/multipart/tests.rs b/crates/vespera/src/multipart/tests.rs new file mode 100644 index 00000000..5fb240bf --- /dev/null +++ b/crates/vespera/src/multipart/tests.rs @@ -0,0 +1,358 @@ +use super::*; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +#[test] +fn test_str_to_bool_truthy() { + for val in &[ + "true", "True", "TRUE", "yes", "Yes", "y", "Y", "1", "on", "ON", + ] { + assert_eq!(str_to_bool(val), Some(true), "expected true for `{val}`"); + } +} + +#[test] +fn test_str_to_bool_falsy() { + for val in &[ + "false", "False", "FALSE", "no", "No", "n", "N", "0", "off", "OFF", + ] { + assert_eq!(str_to_bool(val), Some(false), "expected false for `{val}`"); + } +} + +#[test] +fn test_str_to_bool_invalid() { + for val in &["maybe", "2", "", "yep", "nah"] { + assert_eq!(str_to_bool(val), None, "expected None for `{val}`"); + } +} + +#[test] +fn test_str_to_bool_trims_surrounding_whitespace() { + // Multipart text values can arrive with incidental surrounding whitespace + // (e.g. a trailing newline from a client); bool must tolerate it exactly as + // the numeric field impls do (`text.trim().parse()`), so a padded token + // parses like the bare token instead of being rejected. + assert_eq!(str_to_bool(" true "), Some(true)); + assert_eq!(str_to_bool("true\n"), Some(true)); + assert_eq!(str_to_bool("\tyes\r\n"), Some(true)); + assert_eq!(str_to_bool(" false"), Some(false)); + assert_eq!(str_to_bool("off\n"), Some(false)); + // Trim only touches the ends — internal whitespace stays invalid, and a + // whitespace-only value is still `None`. + assert_eq!(str_to_bool("tr ue"), None); + assert_eq!(str_to_bool(" "), None); +} + +#[test] +fn field_metadata_full_headers_are_optional_by_default() { + let metadata = FieldMetadata { + name: Some("file".to_owned()), + file_name: Some("data.bin".to_owned()), + content_type: Some("application/octet-stream".to_owned()), + headers: None, + }; + + assert!(metadata.headers().is_none()); +} + +#[test] +fn temp_file_default_limit_is_bounded_and_configurable() { + assert_eq!( + default_temp_file_field_limit_bytes(), + DEFAULT_TEMP_FILE_FIELD_LIMIT_BYTES + ); + assert_eq!(DEFAULT_TEMP_FILE_FIELD_LIMIT_BYTES, 16 * 1024 * 1024); + + let previous = set_default_temp_file_field_limit_bytes(2 * 1024 * 1024); + assert_eq!(previous, DEFAULT_TEMP_FILE_FIELD_LIMIT_BYTES); + assert_eq!(default_temp_file_field_limit_bytes(), 2 * 1024 * 1024); + + let restored = set_default_temp_file_field_limit_bytes(previous); + assert_eq!(restored, 2 * 1024 * 1024); + assert_eq!( + default_temp_file_field_limit_bytes(), + DEFAULT_TEMP_FILE_FIELD_LIMIT_BYTES + ); +} + +#[test] +fn register_multipart_bytes_lets_custom_parsers_enforce_aggregate_cap() { + // A custom `TryFromFieldWithState` impl that consumes a field's bytes itself + // can now call the public `register_multipart_bytes` to participate in the + // request-wide `max_total_bytes` cap — previously impossible (the counter was + // private), so a single custom-parsed field could read unboundedly past the + // configured `MultipartLimits`. + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .expect("current-thread runtime"); + let outcome = rt.block_on(async { + let limits = MultipartLimits::new(10, DEFAULT_MULTIPART_MAX_FIELDS); + MULTIPART_AGGREGATE + .scope(RefCell::new(MultipartAggregateState::new(limits)), async { + // Under the cap: two 4-byte chunks accepted (8 <= 10). + register_multipart_bytes("custom", 4)?; + register_multipart_bytes("custom", 4)?; + // Crossing the cap (8 + 4 = 12 > 10) trips RequestTooLarge. + register_multipart_bytes("custom", 4) + }) + .await + }); + assert!( + matches!( + outcome, + Err(TypedMultipartError::RequestTooLarge { + limit_bytes: 10, + .. + }) + ), + "custom-parser byte accounting must trip the aggregate cap, got {outcome:?}" + ); + + // Cooperative contract (mirrors `register_multipart_part`): outside the + // extractor's task-local scope it no-ops rather than erroring, so a derived + // parser can be unit-tested without a live request aggregate. + assert!(register_multipart_bytes("custom", usize::MAX).is_ok()); +} + +// ─── Display tests for all error variants ─────────────────────────── + +#[test] +fn test_error_display() { + let err = TypedMultipartError::MissingField { + field_name: "name".to_string(), + }; + assert_eq!(err.to_string(), "Missing field: `name`"); + + let err = TypedMultipartError::FieldTooLarge { + field_name: "file".to_string(), + limit_bytes: 1024, + }; + assert_eq!( + err.to_string(), + "Field `file` exceeds size limit of 1024 bytes" + ); + + let err = TypedMultipartError::WrongFieldType { + field_name: "age".to_string(), + wanted: Cow::Borrowed("i32"), + source: "invalid digit".to_string(), + }; + assert_eq!( + err.to_string(), + "Wrong type for field `age` (expected i32): invalid digit" + ); +} + +#[test] +fn test_error_display_duplicate_field() { + let err = TypedMultipartError::DuplicateField { + field_name: "email".to_string(), + }; + assert_eq!(err.to_string(), "Duplicate field: `email`"); +} + +#[test] +fn other_error_body_hides_internal_source() { + // The internal source (e.g. a temp-file path / OS error) must NOT + // leak into the public 500 response body — assert on the ACTUAL + // serialized envelope (the production path), not an intermediate. + let err = TypedMultipartError::Other { + source: "/tmp/vespera-upload-7f3a.part: No such file or directory".to_string(), + }; + let body = String::from_utf8(err.error_body()).expect("envelope is UTF-8"); + assert_eq!( + body, + r#"{"errors":[{"message":"internal error while processing multipart request","path":""}]}"# + ); + assert!( + !body.contains("/tmp/"), + "internal source path leaked into response body" + ); + // Display still exposes the source for server-side logging. + assert!(err.to_string().contains("/tmp/")); + // Non-Other variants stream their (client-safe) Display message verbatim, + // byte-identical to the prior `to_string()` path. + let missing = TypedMultipartError::MissingField { + field_name: "avatar".to_string(), + }; + let missing_body = String::from_utf8(missing.error_body()).expect("envelope is UTF-8"); + assert_eq!( + missing_body, + r#"{"errors":[{"message":"Missing field: `avatar`","path":"avatar"}]}"# + ); +} + +#[test] +fn test_error_display_unknown_field() { + let err = TypedMultipartError::UnknownField { + field_name: "foo".to_string(), + }; + assert_eq!(err.to_string(), "Unknown field: `foo`"); +} + +#[test] +fn test_error_display_invalid_enum_value() { + let err = TypedMultipartError::InvalidEnumValue { + field_name: "status".to_string(), + value: "maybe".to_string(), + }; + assert_eq!( + err.to_string(), + "Invalid enum value `maybe` for field `status`" + ); +} + +#[test] +fn invalid_enum_value_constructor_stores_bounded_value() { + // A clearly-oversized attacker value (far beyond the cap), so the bounded + // reflection is unambiguously shorter than the input. The real security + // property is the CONSTANT ceiling (`cap + marker`), which holds no matter + // how huge the input is — a value only marginally over the cap can render a + // few chars longer than the input once the marker is appended, but it is + // still bounded, so that constant bound is what we assert. + let oversized = "가".repeat(MAX_REFLECTED_VALUE_CHARS * 4); + let err = TypedMultipartError::invalid_enum_value("status".to_string(), &oversized); + + match err { + TypedMultipartError::InvalidEnumValue { value, .. } => { + assert!(value.ends_with("... (truncated)")); + assert!( + value.chars().count() + <= MAX_REFLECTED_VALUE_CHARS + "... (truncated)".chars().count() + ); + assert!(value.chars().count() < oversized.chars().count()); + } + _ => panic!("expected InvalidEnumValue"), + } +} + +#[test] +fn invalid_bool_message_reflects_bounded_value() { + let oversized = "x".repeat(MAX_REFLECTED_VALUE_CHARS + 10); + let message = format!( + "invalid boolean value: `{}`", + truncate_reflected_value(&oversized) + ); + + assert!(message.contains("... (truncated)")); + assert!(!message.contains(&oversized)); +} + +#[test] +fn test_error_display_nameless_field() { + let err = TypedMultipartError::NamelessField; + assert_eq!(err.to_string(), "Encountered a field without a name"); +} + +#[test] +fn test_error_display_other() { + let err = TypedMultipartError::Other { + source: "something went wrong".to_string(), + }; + assert_eq!(err.to_string(), "something went wrong"); +} + +// ─── IntoResponse status code tests ───────────────────────────────── + +#[test] +fn test_into_response_duplicate_field() { + let err = TypedMultipartError::DuplicateField { + field_name: "x".to_string(), + }; + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[test] +fn test_into_response_unknown_field() { + let err = TypedMultipartError::UnknownField { + field_name: "x".to_string(), + }; + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[test] +fn test_into_response_invalid_enum_value() { + let err = TypedMultipartError::InvalidEnumValue { + field_name: "x".to_string(), + value: "bad".to_string(), + }; + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[test] +fn test_into_response_nameless_field() { + let err = TypedMultipartError::NamelessField; + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[test] +fn test_into_response_wrong_field_type() { + let err = TypedMultipartError::WrongFieldType { + field_name: "age".to_string(), + wanted: Cow::Borrowed("i32"), + source: "err".to_string(), + }; + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[test] +fn test_into_response_field_too_large() { + let err = TypedMultipartError::FieldTooLarge { + field_name: "file".to_string(), + limit_bytes: 100, + }; + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); +} + +#[test] +fn test_into_response_other() { + let err = TypedMultipartError::Other { + source: "err".to_string(), + }; + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[test] +fn test_into_response_missing_field() { + let err = TypedMultipartError::MissingField { + field_name: "x".to_string(), + }; + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +// ─── Error trait ──────────────────────────────────────────────────── + +#[test] +fn test_error_trait_is_implemented() { + let err: Box = Box::new(TypedMultipartError::Other { + source: "test".to_string(), + }); + assert_eq!(err.to_string(), "test"); +} + +// ─── TypedMultipart Deref / DerefMut ──────────────────────────────── + +#[test] +fn test_typed_multipart_deref() { + let tm = TypedMultipart("hello".to_string()); + // Deref: &TypedMultipart → &String + assert_eq!(&*tm, "hello"); + assert_eq!(tm.len(), 5); // auto-deref to String method +} + +#[test] +fn test_typed_multipart_deref_mut() { + let mut tm = TypedMultipart(vec![1, 2, 3]); + // DerefMut: &mut TypedMultipart> → &mut Vec + tm.push(4); + assert_eq!(&*tm, &[1, 2, 3, 4]); +} diff --git a/crates/vespera/src/serve.rs b/crates/vespera/src/serve.rs index e1f126c9..e6230136 100644 --- a/crates/vespera/src/serve.rs +++ b/crates/vespera/src/serve.rs @@ -2,14 +2,22 @@ //! with a one-liner. //! //! ```no_run -//! use vespera::{vespera, Serve}; +//! use vespera::Serve; //! //! #[tokio::main] //! async fn main() -> std::io::Result<()> { -//! vespera!(title = "My API").serve("0.0.0.0:3000").await +//! vespera::axum::Router::new().serve("0.0.0.0:3000").await //! } //! ``` //! +//! Pairs naturally with the [`vespera!`](vespera_macro::vespera) macro +//! (marked `ignore` because the macro scans the caller's `src/routes/` +//! at compile time, which doesn't exist in a doctest sandbox): +//! +//! ```ignore +//! vespera!(title = "My API").serve("0.0.0.0:3000").await +//! ``` +//! //! Equivalent to: //! //! ```ignore @@ -39,3 +47,21 @@ impl Serve for axum::Router { axum::serve(listener, self).await } } + +/// Lets a **stateless** merged app from `vespera!(merge = [...])` — +/// which returns a [`crate::VesperaRouter<()>`] rather than a plain +/// `axum::Router` — start with the same one-liner, without the user +/// having to remember the `.with_state(())` finalizer first: +/// +/// ```ignore +/// vespera!(merge = [other::App]).serve("0.0.0.0:3000").await +/// ``` +/// +/// Finalizing with `()` runs the deferred child-router merge and layer +/// replay (see [`crate::VesperaRouter::with_state`]) before binding, so +/// merged routes and layers are present when the listener starts. +impl Serve for crate::VesperaRouter<()> { + async fn serve(self, addr: impl ToSocketAddrs) -> io::Result<()> { + self.with_state(()).serve(addr).await + } +} diff --git a/crates/vespera/src/validated.rs b/crates/vespera/src/validated.rs index c447f31e..1d56856b 100644 --- a/crates/vespera/src/validated.rs +++ b/crates/vespera/src/validated.rs @@ -4,10 +4,12 @@ //! //! ```ignore //! use vespera::{Validated, Schema, axum::Json}; +//! use garde::Validate; //! -//! #[derive(serde::Deserialize, Schema)] +//! #[derive(serde::Deserialize, Schema, Validate)] //! struct CreateUser { //! #[schema(min_length = 3, max_length = 32)] +//! #[garde(length(min = 3, max = 32))] //! username: String, //! } //! @@ -23,7 +25,7 @@ //! with a JSON body of shape: //! //! ```json -//! { "errors": [ { "path": "username", "message": "..." }, ... ] } +//! { "errors": [ { "message": "...", "path": "username" }, ... ] } //! ``` use ::axum::{ @@ -33,6 +35,12 @@ use ::axum::{ response::{IntoResponse, Response}, }; use ::garde::Validate; +use ::serde::{Serialize, Serializer, ser::SerializeStruct}; +use std::{ + fmt::Display, + marker::PhantomData, + ops::{Deref, DerefMut}, +}; /// Extractor wrapper that validates the inner extractor's output via /// [`garde::Validate`] before handing it to the handler. @@ -52,6 +60,131 @@ pub trait ValidatePayload { fn payload(&self) -> &Self::Inner; } +/// Provide the context used by [`ValidatedWith`] from axum state. +/// +/// The blanket `impl ValidationContext for C` covers the common case +/// where `Router::with_state(ctx)` stores the validation context directly. App +/// state structs can implement this trait to expose a borrowed context field +/// without cloning per request. +pub trait ValidationContext { + /// Borrow the context used by `garde::Validate::validate_with`. + fn validation_context(&self) -> &C; +} + +impl ValidationContext for C { + fn validation_context(&self) -> &C { + self + } +} + +/// Helper trait that pulls a context-aware validatable payload out of common +/// axum extractors. +pub trait ValidatePayloadWith { + /// The inner type that implements [`garde::Validate`] with context `C`. + type Inner: Validate; + /// Borrow the inner value for validation. + fn payload(&self) -> &Self::Inner; +} + +impl ValidatePayloadWith for Json +where + U: Validate, +{ + type Inner = U; + fn payload(&self) -> &U { + &self.0 + } +} + +impl ValidatePayloadWith for ::axum::Form +where + U: Validate, +{ + type Inner = U; + fn payload(&self) -> &U { + &self.0 + } +} + +impl ValidatePayloadWith for ::axum::extract::Query +where + U: Validate, +{ + type Inner = U; + fn payload(&self) -> &U { + &self.0 + } +} + +impl ValidatePayloadWith for ::axum::extract::Path +where + U: Validate, +{ + type Inner = U; + fn payload(&self) -> &U { + &self.0 + } +} + +impl ValidatePayloadWith for crate::multipart::TypedMultipart +where + U: Validate, +{ + type Inner = U; + fn payload(&self) -> &U { + &self.0 + } +} + +/// Context-aware validation extractor. +/// +/// `Validated` remains the zero-context fast path. Use +/// `ValidatedWith` when the payload derives `garde::Validate` with +/// `#[garde(context(C))]`; the context is borrowed from axum state through +/// [`ValidationContext`]. +#[derive(Debug, Clone, Copy)] +pub struct ValidatedWith(pub T, PhantomData C>); + +impl ValidatedWith { + /// Wrap an already-extracted value. Mostly useful in tests. + #[must_use] + pub const fn new(value: T) -> Self { + Self(value, PhantomData) + } + + /// Consume the wrapper and return the extracted value. + #[must_use] + pub fn into_inner(self) -> T { + self.0 + } + + /// Borrow the extracted value. + #[must_use] + pub const fn get(&self) -> &T { + &self.0 + } + + /// Mutably borrow the extracted value. + #[must_use] + pub const fn get_mut(&mut self) -> &mut T { + &mut self.0 + } +} + +impl Deref for ValidatedWith { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ValidatedWith { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + impl ValidatePayload for Json where U: Validate, @@ -92,6 +225,16 @@ where } } +impl ValidatePayload for crate::multipart::TypedMultipart +where + U: Validate, +{ + type Inner = U; + fn payload(&self) -> &U { + &self.0 + } +} + impl FromRequest for Validated where S: Send + Sync, @@ -110,34 +253,131 @@ where } } +impl FromRequest for ValidatedWith +where + S: Send + Sync + ValidationContext, + C: Send + Sync + 'static, + T: FromRequest + ValidatePayloadWith + Send, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + let extracted = T::from_request(req, state) + .await + .map_err(IntoResponse::into_response)?; + match extracted + .payload() + .validate_with(state.validation_context()) + { + Ok(()) => Ok(Self::new(extracted)), + Err(report) => Err(build_validation_response(&report)), + } + } +} + /// Build the canonical `422 Unprocessable Entity` response from a /// [`garde::Report`]. /// /// Body shape: /// ```json -/// { "errors": [ { "path": "field.name", "message": "..." } ] } +/// { "errors": [ { "message": "...", "path": "field.name" } ] } /// ``` /// -/// We build the JSON via `serde_json::json!` (no extra `serde` derive -/// dep needed) so this module compiles with the bare `serde_json` -/// re-export already present on the `vespera` crate. +/// Field order inside each error object is `message` then `path` — +/// matching the alphabetical order produced by the previous +/// `serde_json::json!` implementation (which used a `BTreeMap` backend). +/// The envelope shape is a public contract locked by snapshot tests and +/// the JNI wire header hoisting logic in `vespera_inprocess`. fn build_validation_response(report: &::garde::Report) -> Response { - let errors: Vec<::serde_json::Value> = report - .iter() - .map(|(path, err)| { - ::serde_json::json!({ - "path": path.to_string(), - "message": err.message(), - }) - }) - .collect(); - let envelope = ::serde_json::json!({ "errors": errors }); - let body = envelope.to_string(); + struct DisplayValue(T); + + impl Serialize for DisplayValue + where + T: Display, + { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(&self.0) + } + } + + struct ValidationEnvelope<'a> { + report: &'a ::garde::Report, + } + + impl Serialize for ValidationEnvelope<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut envelope = serializer.serialize_struct("ValidationEnvelope", 1)?; + envelope.serialize_field( + "errors", + &ValidationErrors { + report: self.report, + }, + )?; + envelope.end() + } + } + + struct ValidationErrors<'a> { + report: &'a ::garde::Report, + } + + impl Serialize for ValidationErrors<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_seq( + self.report + .iter() + .map(|(path, err)| ValidationError { path, err }), + ) + } + } + + struct ValidationError<'a> { + path: &'a ::garde::Path, + err: &'a ::garde::Error, + } + + impl Serialize for ValidationError<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut error = serializer.serialize_struct("ValidationError", 2)?; + // Keep field order byte-identical to the snapshot-locked envelope. + error.serialize_field("message", &DisplayValue(self.err.message()))?; + error.serialize_field("path", &DisplayValue(self.path))?; + error.end() + } + } + + // Serialize straight to bytes: skips the UTF-8 re-validation that + // `to_string` performs over `to_vec`'s output, and the body is handed + // to axum as raw bytes (content-type is overridden to + // application/json below regardless). Byte-identical to the previous + // `to_string` body. + // Serializing the envelope is practically infallible (no I/O, string + // keys), but this is a request-time boundary: on the unreachable failure + // path emit a minimal valid 422 envelope rather than panicking. + let body = ::serde_json::to_vec(&ValidationEnvelope { report }).unwrap_or_else(|_| { + // Field order MUST match the normal serialization above (`message` + // then `path`) so this unreachable fallback still honours the + // snapshot-locked envelope byte shape rather than emitting a + // path-first object that drifts from the documented contract. + br#"{"errors":[{"message":"request validation failed","path":""}]}"#.to_vec() + }); let mut response = (StatusCode::UNPROCESSABLE_ENTITY, body).into_response(); response.headers_mut().insert( CONTENT_TYPE, - "application/json".parse().expect("static value parses"), + ::axum::http::HeaderValue::from_static("application/json"), ); response } diff --git a/crates/vespera/tests/multipart_wire.rs b/crates/vespera/tests/multipart_wire.rs index 1965017e..3971c99d 100644 --- a/crates/vespera/tests/multipart_wire.rs +++ b/crates/vespera/tests/multipart_wire.rs @@ -18,18 +18,37 @@ use ::std::io::{Read, Seek, SeekFrom}; use ::std::sync::Once; use ::tokio::runtime::Builder; use ::vespera::axum::Json; +use ::vespera::multipart::{DEFAULT_TEMP_FILE_FIELD_LIMIT_BYTES, TypedMultipartWithLimits}; use ::vespera::multipart::{FieldData, TypedMultipart}; use ::vespera::tempfile::NamedTempFile; -use ::vespera::{Multipart, Schema}; +use ::vespera::{Multipart, Schema, Validated}; use ::vespera_inprocess::{dispatch_from_bytes, register_app}; #[derive(Multipart, Schema)] #[allow(dead_code)] struct UploadReq { name: String, + // This round-trip test intentionally accepts any size (it exercises the + // 256 KiB tempfile path with the body limit disabled), so it opts out of + // the now-mandatory file-field cap explicitly rather than inheriting one. + #[form_data(limit = "unlimited")] file: FieldData, } +#[derive(Multipart, Schema)] +#[allow(dead_code)] +struct CappedUploadReq { + name: String, + file: FieldData, +} + +#[derive(Multipart, Schema, garde::Validate)] +#[allow(dead_code)] +struct ValidatedMultipartReq { + #[garde(length(min = 3))] + name: String, +} + #[derive(Serialize, Schema)] struct UploadResult { name: String, @@ -55,13 +74,96 @@ async fn upload_handler(TypedMultipart(mut req): TypedMultipart) -> J }) } +async fn capped_upload_handler( + TypedMultipart(mut req): TypedMultipart, +) -> Json { + let mut buf = Vec::new(); + let f = req.file.contents.as_file_mut(); + f.seek(SeekFrom::Start(0)).expect("rewind temp file"); + f.read_to_end(&mut buf).expect("read temp file"); + Json(UploadResult { + name: req.name, + file_size: u64::try_from(buf.len()).expect("file size fits in u64"), + file_first_byte: *buf.first().unwrap_or(&0), + file_last_byte: *buf.last().unwrap_or(&0), + }) +} + +async fn validated_multipart_handler( + Validated(TypedMultipart(req)): Validated>, +) -> Json { + Json(TextResult { + text_len: u64::try_from(req.name.len()).unwrap_or(u64::MAX), + }) +} + +/// Unannotated `String` field — inherits the default 1 MiB cap. +#[derive(Multipart, Schema)] +#[allow(dead_code)] +struct TextReq { + text: String, +} + +/// Explicit `unlimited` opt-out — the field stays genuinely unbounded. +#[derive(Multipart, Schema)] +#[allow(dead_code)] +struct UnlimitedTextReq { + #[form_data(limit = "unlimited")] + text: String, +} + +#[derive(Serialize, Schema)] +struct TextResult { + text_len: u64, +} + +async fn text_handler(TypedMultipart(req): TypedMultipart) -> Json { + Json(TextResult { + text_len: u64::try_from(req.text.len()).unwrap_or(u64::MAX), + }) +} + +async fn text_aggregate_total_handler( + TypedMultipartWithLimits(req): TypedMultipartWithLimits, +) -> Json { + Json(TextResult { + text_len: u64::try_from(req.text.len()).unwrap_or(u64::MAX), + }) +} + +async fn text_aggregate_field_count_handler( + TypedMultipartWithLimits(req): TypedMultipartWithLimits, +) -> Json { + Json(TextResult { + text_len: u64::try_from(req.text.len()).unwrap_or(u64::MAX), + }) +} + +async fn text_unlimited_handler( + TypedMultipart(req): TypedMultipart, +) -> Json { + Json(TextResult { + text_len: u64::try_from(req.text.len()).unwrap_or(u64::MAX), + }) +} + fn multipart_router() -> Router { Router::new() .route("/upload", post(upload_handler)) + .route("/capped-upload", post(capped_upload_handler)) + .route("/validated-multipart", post(validated_multipart_handler)) + .route("/text", post(text_handler)) + .route("/text-aggregate-total", post(text_aggregate_total_handler)) + .route( + "/text-aggregate-field-count", + post(text_aggregate_field_count_handler), + ) + .route("/text-unlimited", post(text_unlimited_handler)) // Disable the 2 MiB default so the 256 KiB test below isn't // truncated — and so end-users can document a sensible policy // explicitly rather than inheriting an axum default that's - // surprising in an in-process / JNI context. + // surprising in an in-process / JNI context. The default String + // field cap is enforced by vespera itself, independent of this. .layer(DefaultBodyLimit::disable()) } @@ -75,6 +177,16 @@ fn encode_multipart_wire( name: &str, file_name: &str, file_bytes: &[u8], +) -> Vec { + encode_multipart_upload_wire("/upload", boundary, name, file_name, file_bytes) +} + +fn encode_multipart_upload_wire( + path: &str, + boundary: &str, + name: &str, + file_name: &str, + file_bytes: &[u8], ) -> Vec { let mut body = Vec::new(); body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); @@ -98,7 +210,7 @@ fn encode_multipart_wire( let header_json = ::serde_json::json!({ "v": 1, "method": "POST", - "path": "/upload", + "path": path, "headers": headers, }); let header_bytes = ::serde_json::to_vec(&header_json).expect("header serialise"); @@ -202,3 +314,241 @@ fn typed_multipart_non_utf8_bytes_preserved() { assert_eq!(json["file_first_byte"], 0); assert_eq!(json["file_last_byte"], 0xEF); } + +/// Encode a single named text field as a `multipart/form-data` wire request. +fn encode_multipart_text(boundary: &str, path: &str, field: &str, value: &[u8]) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{field}\"\r\n\r\n").as_bytes(), + ); + body.extend_from_slice(value); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + + let mut headers = HashMap::new(); + headers.insert( + "content-type".to_owned(), + format!("multipart/form-data; boundary={boundary}"), + ); + let header_json = ::serde_json::json!({ + "v": 1, + "method": "POST", + "path": path, + "headers": headers, + }); + let header_bytes = ::serde_json::to_vec(&header_json).expect("header serialise"); + let header_len = u32::try_from(header_bytes.len()).expect("header fits in u32"); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(&body); + wire +} + +#[test] +fn string_field_over_default_cap_rejected_413() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + // 1 MiB + 1 byte of text in an UNANNOTATED `String` field exceeds the + // default 1 MiB cap → 413 (FieldTooLarge), instead of buffering the + // whole payload unbounded. + let payload = vec![b'x'; 1024 * 1024 + 1]; + let wire = encode_multipart_text("----TextCapBoundary", "/text", "text", &payload); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, _body) = decode_wire(&resp); + assert_eq!( + header["status"].as_u64(), + Some(413), + "oversized unannotated String field must be rejected with 413, got header={header:#}" + ); +} + +#[test] +fn string_field_under_default_cap_ok() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let payload = vec![b'x'; 1024]; // 1 KiB — well under the 1 MiB cap + let wire = encode_multipart_text("----TextOkBoundary", "/text", "text", &payload); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body) = decode_wire(&resp); + assert_eq!(header["status"].as_u64(), Some(200), "header={header:#}"); + let json: Value = ::serde_json::from_slice(&body).expect("response is JSON"); + assert_eq!(json["text_len"].as_u64(), Some(1024)); +} + +#[test] +fn typed_multipart_aggregate_total_cap_rejected_413() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let wire = encode_multipart_text( + "----AggregateTotalBoundary", + "/text-aggregate-total", + "text", + b"ninebytes", + ); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body) = decode_wire(&resp); + assert_eq!(header["status"].as_u64(), Some(413), "header={header:#}"); + let json: Value = ::serde_json::from_slice(&body).expect("response is JSON"); + assert_eq!(json["errors"][0]["path"], "text"); +} + +#[test] +fn typed_multipart_aggregate_field_count_cap_rejected_413() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let wire = encode_multipart_text( + "----AggregateFieldsBoundary", + "/text-aggregate-field-count", + "text", + b"ok", + ); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, _body) = decode_wire(&resp); + assert_eq!(header["status"].as_u64(), Some(413), "header={header:#}"); +} + +#[test] +fn typed_multipart_unknown_fields_count_toward_max_fields() { + // Regression: in non-strict mode an UNKNOWN part (the generated `_ => {}` + // dispatch arm) must still count against `max_fields`. Before the fix, + // counting happened only inside the per-known-field parsers, so a flood of + // unknown parts bypassed the cap entirely (a DoS-adjacent gap) and this + // request would instead fail later with a 400 missing-field error. With + // `MAX_FIELDS = 0`, even one part — known OR unknown — must be rejected 413. + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let wire = encode_multipart_text( + "----UnknownFieldCountBoundary", + "/text-aggregate-field-count", + "definitely_not_a_known_field", + b"x", + ); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, _body) = decode_wire(&resp); + assert_eq!( + header["status"].as_u64(), + Some(413), + "an unknown multipart part must count against max_fields, got header={header:#}" + ); +} + +#[test] +fn typed_multipart_aggregate_under_limit_passes() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let wire = encode_multipart_text( + "----AggregateOkBoundary", + "/text-aggregate-total", + "text", + b"eight888", + ); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body) = decode_wire(&resp); + assert_eq!(header["status"].as_u64(), Some(200), "header={header:#}"); + let json: Value = ::serde_json::from_slice(&body).expect("response is JSON"); + assert_eq!(json["text_len"].as_u64(), Some(8)); +} + +#[test] +fn string_field_unlimited_optout_allows_large() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + // 1 MiB + 1 byte through the explicit `#[form_data(limit = "unlimited")]` + // opt-out must pass — the `Some(usize::MAX)` sentinel keeps the field + // genuinely unbounded, proving the opt-out survived the cap change. + let payload = vec![b'y'; 1024 * 1024 + 1]; + let wire = encode_multipart_text( + "----TextUnlimitedBoundary", + "/text-unlimited", + "text", + &payload, + ); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body) = decode_wire(&resp); + assert_eq!( + header["status"].as_u64(), + Some(200), + "unlimited opt-out must allow >1 MiB text, got header={header:#}" + ); + let json: Value = ::serde_json::from_slice(&body).expect("response is JSON"); + assert_eq!(json["text_len"].as_u64(), Some(1024 * 1024 + 1)); +} + +#[test] +fn named_temp_file_over_default_cap_rejected_413() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let payload = vec![b'z'; DEFAULT_TEMP_FILE_FIELD_LIMIT_BYTES + 1]; + let wire = encode_multipart_upload_wire( + "/capped-upload", + "----TempFileCapBoundary", + "bob", + "too-large.bin", + &payload, + ); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, _body) = decode_wire(&resp); + assert_eq!( + header["status"].as_u64(), + Some(413), + "oversized unannotated tempfile field must be rejected with 413, got header={header:#}" + ); +} + +#[test] +fn validated_typed_multipart_rejects_garde_failure_422() { + install_router_once(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let wire = encode_multipart_text( + "----ValidatedMultipartBoundary", + "/validated-multipart", + "name", + b"xy", + ); + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body) = decode_wire(&resp); + assert_eq!( + header["status"].as_u64(), + Some(422), + "garde failure must be rejected with 422, got header={header:#}" + ); + let json: Value = ::serde_json::from_slice(&body).expect("response is JSON"); + assert_eq!(json["errors"][0]["path"], "name"); +} diff --git a/crates/vespera/tests/snapshots/validated_extractor__validated_422_envelope_multi_error.snap b/crates/vespera/tests/snapshots/validated_extractor__validated_422_envelope_multi_error.snap new file mode 100644 index 00000000..c0ee5a61 --- /dev/null +++ b/crates/vespera/tests/snapshots/validated_extractor__validated_422_envelope_multi_error.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera/tests/validated_extractor.rs +expression: body_str +--- +{"errors":[{"message":"length is lower than 3","path":"title"},{"message":"length is lower than 1","path":"content"}]} diff --git a/crates/vespera/tests/trybuild_diagnostics.rs b/crates/vespera/tests/trybuild_diagnostics.rs new file mode 100644 index 00000000..6ebcb28e --- /dev/null +++ b/crates/vespera/tests/trybuild_diagnostics.rs @@ -0,0 +1,19 @@ +//! Compile-fail (UI) tests for the macro diagnostics: malformed +//! `#[route(responses = [...])]` and `#[cron("...")]` input must fail at +//! COMPILE time with a clear message — instead of being silently dropped +//! (incomplete OpenAPI) or panicking the `JobScheduler` at application startup. +//! +//! The `.stderr` snapshots are toolchain-sensitive; regenerate with: +//! TRYBUILD=overwrite cargo test -p vespera --features cron --test trybuild_diagnostics + +#[test] +fn ui_diagnostics() { + let t = trybuild::TestCases::new(); + // `responses` validation lives in `RouteArgs::parse` (always compiled). + t.compile_fail("tests/ui/route_responses_invalid.rs"); + // The cron-syntax validator only compiles into the proc-macro under the + // `cron` feature (enabled transitively by `vespera`'s `cron` feature), so + // only assert the cron diagnostic when that feature is on. + #[cfg(feature = "cron")] + t.compile_fail("tests/ui/cron_invalid.rs"); +} diff --git a/crates/vespera/tests/ui/cron_invalid.rs b/crates/vespera/tests/ui/cron_invalid.rs new file mode 100644 index 00000000..20153d36 --- /dev/null +++ b/crates/vespera/tests/ui/cron_invalid.rs @@ -0,0 +1,12 @@ +//! Compile-fail: a malformed `#[cron("...")]` expression must be a clean, +//! span-attached compile error — not a `JobScheduler` panic at application +//! startup (the pre-fix behaviour, where `Job::new_async(expr).expect(...)` +//! ran only once the app booted). +//! +//! Requires the `cron` feature (which compiles the croner-backed validator +//! into the proc-macro). + +#[vespera::cron("not a valid cron expression")] +pub async fn job() {} + +fn main() {} diff --git a/crates/vespera/tests/ui/cron_invalid.stderr b/crates/vespera/tests/ui/cron_invalid.stderr new file mode 100644 index 00000000..7a388c14 --- /dev/null +++ b/crates/vespera/tests/ui/cron_invalid.stderr @@ -0,0 +1,5 @@ +error: #[cron] invalid cron expression `not a valid cron expression`: Invalid pattern: Pattern must have 6 or 7 fields when seconds are required and years are optional.. Expected a 6-field expression `sec min hour day month weekday`, e.g. "0 */5 * * * *". + --> tests/ui/cron_invalid.rs:9:17 + | +9 | #[vespera::cron("not a valid cron expression")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/vespera/tests/ui/route_responses_invalid.rs b/crates/vespera/tests/ui/route_responses_invalid.rs new file mode 100644 index 00000000..cdfd833d --- /dev/null +++ b/crates/vespera/tests/ui/route_responses_invalid.rs @@ -0,0 +1,13 @@ +//! Compile-fail: a malformed `#[route(responses = [...])]` entry must be a +//! clean, span-attached compile error — not silently dropped by the extraction +//! `filter_map` (which previously emitted incomplete OpenAPI with no warning). +//! +//! `(404)` is a parenthesized expression, not a `(status, Type)` tuple, so it +//! is missing the response type. + +#[vespera::route(get, responses = [(404)])] +pub async fn handler() -> &'static str { + "ok" +} + +fn main() {} diff --git a/crates/vespera/tests/ui/route_responses_invalid.stderr b/crates/vespera/tests/ui/route_responses_invalid.stderr new file mode 100644 index 00000000..52b7c484 --- /dev/null +++ b/crates/vespera/tests/ui/route_responses_invalid.stderr @@ -0,0 +1,5 @@ +error: #[route] `responses` entries must be `(status, Type)` tuples, e.g. `responses = [(404, NotFoundError)]`. + --> tests/ui/route_responses_invalid.rs:8:36 + | +8 | #[vespera::route(get, responses = [(404)])] + | ^^^^^ diff --git a/crates/vespera/tests/validated_extractor.rs b/crates/vespera/tests/validated_extractor.rs index 9cf81856..09cec8ba 100644 --- a/crates/vespera/tests/validated_extractor.rs +++ b/crates/vespera/tests/validated_extractor.rs @@ -7,7 +7,7 @@ use ::axum::{Router, body::Body, http::Request, routing::post}; use ::serde::Deserialize; use ::tower::ServiceExt; -use ::vespera::{Schema, Validated}; +use ::vespera::{Schema, Validated, ValidatedWith}; #[derive(Deserialize, Schema)] #[allow(dead_code)] @@ -25,24 +25,67 @@ async fn create_post( "ok" } +#[derive(Clone)] +struct SlugContext { + required_prefix: String, +} + +#[derive(Deserialize, garde::Validate)] +#[garde(context(SlugContext as ctx))] +struct ContextPost { + #[garde(custom(|value: &str, ctx: &SlugContext| { + if value.starts_with(&ctx.required_prefix) { + Ok(()) + } else { + Err(garde::Error::new(format!( + "must start with {}", + ctx.required_prefix + ))) + } + }))] + slug: String, +} + +async fn create_context_post( + validated: ValidatedWith>, +) -> &'static str { + let ::axum::Json(_payload) = validated.into_inner(); + "ok" +} + +fn context_router() -> Router { + Router::new().route("/context-posts", post(create_context_post)) +} + fn router() -> Router { Router::new().route("/posts", post(create_post)) } +fn post_json_request(uri: &str, body: impl Into) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(body.into()) + .unwrap() +} + async fn body_to_string(body: Body) -> String { let bytes = ::axum::body::to_bytes(body, usize::MAX).await.unwrap(); String::from_utf8(bytes.to_vec()).unwrap() } +fn assert_json_content_type(headers: &::axum::http::HeaderMap) { + assert_eq!( + headers.get("content-type").map(|v| v.to_str().unwrap()), + Some("application/json"), + ); +} + #[tokio::test] async fn valid_payload_returns_200() { let app = router(); - let req = Request::builder() - .method("POST") - .uri("/posts") - .header("content-type", "application/json") - .body(Body::from(r#"{"title":"My Post","content":"hello world"}"#)) - .unwrap(); + let req = post_json_request("/posts", r#"{"title":"My Post","content":"hello world"}"#); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), 200); @@ -52,21 +95,11 @@ async fn valid_payload_returns_200() { #[tokio::test] async fn short_title_returns_422_with_path_keyed_envelope() { let app = router(); - let req = Request::builder() - .method("POST") - .uri("/posts") - .header("content-type", "application/json") - .body(Body::from(r#"{"title":"X","content":"ok"}"#)) - .unwrap(); + let req = post_json_request("/posts", r#"{"title":"X","content":"ok"}"#); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), 422); - assert_eq!( - res.headers() - .get("content-type") - .map(|v| v.to_str().unwrap()), - Some("application/json"), - ); + assert_json_content_type(res.headers()); let body: ::serde_json::Value = ::serde_json::from_str(&body_to_string(res.into_body()).await).unwrap(); @@ -84,12 +117,7 @@ async fn short_title_returns_422_with_path_keyed_envelope() { #[tokio::test] async fn empty_content_returns_422() { let app = router(); - let req = Request::builder() - .method("POST") - .uri("/posts") - .header("content-type", "application/json") - .body(Body::from(r#"{"title":"Valid title","content":""}"#)) - .unwrap(); + let req = post_json_request("/posts", r#"{"title":"Valid title","content":""}"#); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), 422); @@ -103,12 +131,7 @@ async fn empty_content_returns_422() { #[tokio::test] async fn multiple_violations_all_appear_in_envelope() { let app = router(); - let req = Request::builder() - .method("POST") - .uri("/posts") - .header("content-type", "application/json") - .body(Body::from(r#"{"title":"X","content":""}"#)) - .unwrap(); + let req = post_json_request("/posts", r#"{"title":"X","content":""}"#); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), 422); @@ -127,12 +150,7 @@ async fn malformed_json_propagates_400_not_422() { // `Validated` must forward that rejection unchanged rather than // synthesizing a 422 from a non-existent garde report. let app = router(); - let req = Request::builder() - .method("POST") - .uri("/posts") - .header("content-type", "application/json") - .body(Body::from("not json")) - .unwrap(); + let req = post_json_request("/posts", "not json"); let res = app.oneshot(req).await.unwrap(); // Axum's Json extractor returns 400 (or 415 depending on cause) — @@ -140,6 +158,35 @@ async fn malformed_json_propagates_400_not_422() { assert_ne!(res.status(), 422); } +#[tokio::test] +async fn context_validated_payload_returns_200_when_state_context_accepts_value() { + let app = context_router().with_state(SlugContext { + required_prefix: "vespera-".to_owned(), + }); + let req = post_json_request("/context-posts", r#"{"slug":"vespera-release"}"#); + + let res = app.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), 200); + assert_eq!(body_to_string(res.into_body()).await, "ok"); +} + +#[tokio::test] +async fn context_validated_payload_returns_422_when_state_context_rejects_value() { + let app = context_router().with_state(SlugContext { + required_prefix: "vespera-".to_owned(), + }); + let req = post_json_request("/context-posts", r#"{"slug":"other-release"}"#); + + let res = app.oneshot(req).await.unwrap(); + + assert_eq!(res.status(), 422); + assert_json_content_type(res.headers()); + let body: ::serde_json::Value = + ::serde_json::from_str(&body_to_string(res.into_body()).await).unwrap(); + assert_envelope_has_field_error(&body, "slug"); +} + // ── per-rule 422 coverage ──────────────────────────────────────────── // // `CreatePost` only exercises `min_length` / `max_length`. The model @@ -219,12 +266,7 @@ async fn dispatch(app: Router, payload: ::serde_json::Value) -> (u16, ::serde_js let res = app.oneshot(req).await.unwrap(); let status = res.status().as_u16(); if status == 422 { - assert_eq!( - res.headers() - .get("content-type") - .map(|v| v.to_str().unwrap()), - Some("application/json"), - ); + assert_json_content_type(res.headers()); } let body: ::serde_json::Value = ::serde_json::from_str(&body_to_string(res.into_body()).await) .unwrap_or(::serde_json::Value::Null); @@ -312,12 +354,7 @@ async fn rule_range_minimum_violation_returns_422() { "ok" } let app = Router::new().route("/n", post(handler)); - let req = Request::builder() - .method("POST") - .uri("/n") - .header("content-type", "application/json") - .body(Body::from(r#"{"age":-1}"#)) - .unwrap(); + let req = post_json_request("/n", r#"{"age":-1}"#); let res = app.oneshot(req).await.unwrap(); assert_eq!(res.status(), 422); let body: ::serde_json::Value = @@ -392,3 +429,33 @@ async fn multiple_per_rule_violations_all_appear_in_envelope() { assert_envelope_has_field_error(&body, field); } } + +// ── byte-snapshot test: 422 validation envelope contract ──────────────── +// +// This test locks the EXACT serialized bytes of the 422 validation-error +// envelope produced by `Validated`. The snapshot proves byte-identity +// across refactors of `crates/vespera/src/validated.rs`. +// +// The envelope shape is a public contract: +// - Used by axum handlers (JSON response body) +// - Hoisted into JNI wire headers as `"validation_errors": [...]` +// - Consumed by Java decoders and client libraries +// +// Multi-error coverage: triggers 2+ field errors to verify the full +// envelope structure (message before path, array ordering, etc.). + +#[tokio::test] +async fn byte_snapshot_422_envelope_multi_error() { + let app = router(); + let req = post_json_request("/posts", r#"{"title":"X","content":""}"#); + + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), 422); + + let body_bytes = ::axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap(); + let body_str = String::from_utf8(body_bytes.to_vec()).unwrap(); + + insta::assert_snapshot!("validated_422_envelope_multi_error", body_str); +} diff --git a/crates/vespera_core/src/openapi.rs b/crates/vespera_core/src/openapi.rs index f8be93b1..a4df90b7 100644 --- a/crates/vespera_core/src/openapi.rs +++ b/crates/vespera_core/src/openapi.rs @@ -3,7 +3,7 @@ use crate::route::PathItem; use crate::schema::{Components, ExternalDocumentation}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; /// `OpenAPI` document version #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -42,11 +42,21 @@ pub struct Contact { pub struct License { /// License name pub name: String, + /// SPDX license expression or identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub identifier: Option, /// License URL #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, } +#[allow(clippy::ref_option)] // serde skip_serializing_if mandates &Option signature +fn is_empty_components(value: &Option) -> bool { + value + .as_ref() + .is_none_or(|components| !has_any_component_map(components)) +} + /// API information #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -95,9 +105,13 @@ pub struct Server { /// Server description #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - /// Server variables + /// Server variables. + /// + /// `BTreeMap` (not `HashMap`) so the generated OpenAPI output is + /// deterministic across runs/processes, consistent with the rest of + /// the document's ordered maps (CORE-01). #[serde(skip_serializing_if = "Option::is_none")] - pub variables: Option>, + pub variables: Option>, } /// Tag definition @@ -128,11 +142,11 @@ pub struct OpenApi { /// Path definitions pub paths: BTreeMap, /// Components (reusable components) - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "is_empty_components")] pub components: Option, /// Security requirements #[serde(skip_serializing_if = "Option::is_none")] - pub security: Option>>>, + pub security: Option>>>, /// Tag definitions #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, @@ -141,52 +155,196 @@ pub struct OpenApi { pub external_docs: Option, } +/// Merge `other` map entries into `self_map` with self-wins on key +/// conflicts, allocating the target map only when `other` has entries. +fn merge_component_map( + self_map: &mut Option>, + other_map: Option>, +) { + let Some(other_map) = non_empty_component_map(other_map) else { + return; + }; + let target = self_map.get_or_insert_with(BTreeMap::new); + for (name, value) in other_map { + target.entry(name).or_insert(value); + } +} + +fn non_empty_component_map(map: Option>) -> Option> { + map.filter(|entries| !entries.is_empty()) +} + +fn has_any_component_map(components: &Components) -> bool { + components + .schemas + .as_ref() + .is_some_and(|entries| !entries.is_empty()) + || components + .responses + .as_ref() + .is_some_and(|entries| !entries.is_empty()) + || components + .parameters + .as_ref() + .is_some_and(|entries| !entries.is_empty()) + || components + .examples + .as_ref() + .is_some_and(|entries| !entries.is_empty()) + || components + .request_bodies + .as_ref() + .is_some_and(|entries| !entries.is_empty()) + || components + .headers + .as_ref() + .is_some_and(|entries| !entries.is_empty()) + || components + .security_schemes + .as_ref() + .is_some_and(|entries| !entries.is_empty()) +} + +/// Merge `other`'s per-method operations into `into` with **self-wins** +/// semantics: an operation (or path-level field) already present on `into` +/// is kept; a slot empty on `into` is filled from `other`. +/// +/// Applied on a path-key conflict so two apps that define the same path +/// under different methods both keep their operations, instead of the +/// incoming [`PathItem`] being dropped whole. Destructuring `other` keeps +/// this exhaustive — adding a `PathItem` field forces this to be updated. +fn merge_path_item(into: &mut PathItem, other: PathItem) { + let PathItem { + get, + post, + put, + patch, + delete, + head, + options, + trace, + parameters, + summary, + description, + ref_path, + servers, + } = other; + if into.get.is_none() { + into.get = get; + } + if into.post.is_none() { + into.post = post; + } + if into.put.is_none() { + into.put = put; + } + if into.patch.is_none() { + into.patch = patch; + } + if into.delete.is_none() { + into.delete = delete; + } + if into.head.is_none() { + into.head = head; + } + if into.options.is_none() { + into.options = options; + } + if into.trace.is_none() { + into.trace = trace; + } + if into.parameters.is_none() { + into.parameters = parameters; + } + if into.summary.is_none() { + into.summary = summary; + } + if into.description.is_none() { + into.description = description; + } + if into.ref_path.is_none() { + into.ref_path = ref_path; + } + if into.servers.is_none() { + into.servers = servers; + } +} + impl OpenApi { /// Merge another `OpenAPI` document into this one. - /// Paths, schemas, and tags from `other` are added to `self`. - /// If there are conflicts, `self` takes precedence. + /// + /// All `paths`, `components` (schemas, responses, parameters, + /// examples, request bodies, headers, security schemes), and `tags` + /// from `other` are added to `self`. Top-level `servers`, `security`, + /// and `external_docs` are adopted from `other` only when `self` has + /// not set its own. On any key/field conflict, `self` takes precedence. pub fn merge(&mut self, other: Self) { - // Merge paths (self takes precedence on conflict) + // Merge paths. On a path-key conflict, merge per HTTP method + // (self-wins per operation) instead of dropping the incoming + // `PathItem` wholesale: two merged apps that both define the same + // path under DIFFERENT methods (parent `GET /users`, child + // `POST /users`) must keep BOTH operations in the generated + // document — otherwise the spec under-documents what the merged + // router actually serves at runtime. for (path, item) in other.paths { - self.paths.entry(path).or_insert(item); - } - - // Merge components - if let Some(other_components) = other.components { - let self_components = self.components.get_or_insert(Components { - schemas: None, - responses: None, - parameters: None, - examples: None, - request_bodies: None, - headers: None, - security_schemes: None, - }); - - // Merge schemas - if let Some(other_schemas) = other_components.schemas { - let self_schemas = self_components.schemas.get_or_insert_with(BTreeMap::new); - for (name, schema) in other_schemas { - self_schemas.entry(name).or_insert(schema); + use std::collections::btree_map::Entry; + match self.paths.entry(path) { + Entry::Vacant(slot) => { + slot.insert(item); } + Entry::Occupied(mut slot) => merge_path_item(slot.get_mut(), item), } + } - // Merge security schemes - if let Some(other_security_schemes) = other_components.security_schemes { - let self_security_schemes = self_components - .security_schemes - .get_or_insert_with(HashMap::new); - for (name, scheme) in other_security_schemes { - self_security_schemes.entry(name).or_insert(scheme); - } - } + // Merge components (every reusable component kind, self-wins on + // key conflict) — previously only `schemas` + `security_schemes` + // were merged, silently dropping the rest. + if let Some(other_components) = other.components + && has_any_component_map(&other_components) + { + let self_components = self.components.get_or_insert_with(Components::default); + + merge_component_map(&mut self_components.schemas, other_components.schemas); + merge_component_map(&mut self_components.responses, other_components.responses); + merge_component_map(&mut self_components.parameters, other_components.parameters); + merge_component_map(&mut self_components.examples, other_components.examples); + merge_component_map( + &mut self_components.request_bodies, + other_components.request_bodies, + ); + merge_component_map(&mut self_components.headers, other_components.headers); + merge_component_map( + &mut self_components.security_schemes, + other_components.security_schemes, + ); } - // Merge tags (deduplicate by name) + // Merge top-level servers / security / external_docs (self wins: + // adopt other's only when self has not set its own). + if self.servers.is_none() { + self.servers = other.servers; + } + if self.security.is_none() { + self.security = other.security; + } + if self.external_docs.is_none() { + self.external_docs = other.external_docs; + } + + // Merge tags, de-duplicating by name with first-wins semantics while + // preserving deterministic output order (existing tags first, then + // incoming tags in their original order). + // + // A linear `any` scan beats a `HashSet` here: tag sets are + // tiny (OpenAPI tags are top-level operation groupings — a handful, + // rarely past a few dozen even for large APIs), so the O(n²) short- + // string compare over an already-resident `Vec` is cheaper than + // allocating a set and cloning every existing + incoming tag name. + // Net: zero allocations and zero `String` clones on the merge path. if let Some(other_tags) = other.tags { let self_tags = self.tags.get_or_insert_with(Vec::new); for tag in other_tags { - if !self_tags.iter().any(|t| t.name == tag.name) { + if !self_tags.iter().any(|existing| existing.name == tag.name) { self_tags.push(tag); } } @@ -195,277 +353,4 @@ impl OpenApi { } #[cfg(test)] -mod tests { - use super::*; - use crate::route::{Operation, PathItem}; - use crate::schema::{Components, Schema, SchemaType, SecurityScheme, SecuritySchemeType}; - - fn create_base_openapi() -> OpenApi { - OpenApi { - openapi: OpenApiVersion::V3_1_0, - info: Info { - title: "Base API".to_string(), - version: "1.0.0".to_string(), - description: None, - terms_of_service: None, - contact: None, - license: None, - summary: None, - }, - servers: None, - paths: BTreeMap::new(), - components: None, - security: None, - tags: None, - external_docs: None, - } - } - - fn create_path_item(summary: &str) -> PathItem { - PathItem { - get: Some(Operation { - summary: Some(summary.to_string()), - description: None, - operation_id: None, - tags: None, - parameters: None, - request_body: None, - responses: BTreeMap::new(), - security: None, - }), - ..Default::default() - } - } - - #[test] - fn test_merge_paths() { - let mut base = create_base_openapi(); - base.paths - .insert("/users".to_string(), create_path_item("Get users")); - - let mut other = create_base_openapi(); - other - .paths - .insert("/posts".to_string(), create_path_item("Get posts")); - other - .paths - .insert("/users".to_string(), create_path_item("Other users")); // Conflict - - base.merge(other); - - // Both paths should exist - assert!(base.paths.contains_key("/users")); - assert!(base.paths.contains_key("/posts")); - // Self takes precedence on conflict - assert_eq!( - base.paths - .get("/users") - .unwrap() - .get - .as_ref() - .unwrap() - .summary, - Some("Get users".to_string()) - ); - } - - #[test] - fn test_merge_schemas() { - let mut base = create_base_openapi(); - let mut base_schemas = BTreeMap::new(); - base_schemas.insert("User".to_string(), Schema::object()); - base.components = Some(Components { - schemas: Some(base_schemas), - responses: None, - parameters: None, - examples: None, - request_bodies: None, - headers: None, - security_schemes: None, - }); - - let mut other = create_base_openapi(); - let mut other_schemas = BTreeMap::new(); - other_schemas.insert("Post".to_string(), Schema::object()); - other_schemas.insert("User".to_string(), Schema::string()); // Conflict - other.components = Some(Components { - schemas: Some(other_schemas), - responses: None, - parameters: None, - examples: None, - request_bodies: None, - headers: None, - security_schemes: None, - }); - - base.merge(other); - - let schemas = base.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); - assert!(schemas.contains_key("Post")); - // Self takes precedence on conflict - assert_eq!( - schemas.get("User").unwrap().schema_type, - Some(SchemaType::Object) - ); - } - - #[test] - fn test_merge_schemas_when_self_has_no_components() { - let mut base = create_base_openapi(); - assert!(base.components.is_none()); - - let mut other = create_base_openapi(); - let mut other_schemas = BTreeMap::new(); - other_schemas.insert("Post".to_string(), Schema::object()); - other.components = Some(Components { - schemas: Some(other_schemas), - responses: None, - parameters: None, - examples: None, - request_bodies: None, - headers: None, - security_schemes: None, - }); - - base.merge(other); - - assert!(base.components.is_some()); - let schemas = base.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("Post")); - } - - #[test] - fn test_merge_security_schemes() { - let mut base = create_base_openapi(); - let mut base_security_schemes = HashMap::new(); - base_security_schemes.insert( - "bearerAuth".to_string(), - SecurityScheme { - r#type: SecuritySchemeType::Http, - description: None, - name: None, - r#in: None, - scheme: Some("bearer".to_string()), - bearer_format: Some("JWT".to_string()), - }, - ); - base.components = Some(Components { - schemas: None, - responses: None, - parameters: None, - examples: None, - request_bodies: None, - headers: None, - security_schemes: Some(base_security_schemes), - }); - - let mut other = create_base_openapi(); - let mut other_security_schemes = HashMap::new(); - other_security_schemes.insert( - "apiKey".to_string(), - SecurityScheme { - r#type: SecuritySchemeType::ApiKey, - description: None, - name: Some("X-API-Key".to_string()), - r#in: Some("header".to_string()), - scheme: None, - bearer_format: None, - }, - ); - other.components = Some(Components { - schemas: None, - responses: None, - parameters: None, - examples: None, - request_bodies: None, - headers: None, - security_schemes: Some(other_security_schemes), - }); - - base.merge(other); - - let security_schemes = base - .components - .as_ref() - .unwrap() - .security_schemes - .as_ref() - .unwrap(); - assert!(security_schemes.contains_key("bearerAuth")); - assert!(security_schemes.contains_key("apiKey")); - } - - #[test] - fn test_merge_tags() { - let mut base = create_base_openapi(); - base.tags = Some(vec![Tag { - name: "users".to_string(), - description: Some("User operations".to_string()), - external_docs: None, - }]); - - let mut other = create_base_openapi(); - other.tags = Some(vec![ - Tag { - name: "posts".to_string(), - description: Some("Post operations".to_string()), - external_docs: None, - }, - Tag { - name: "users".to_string(), - description: Some("Duplicate users tag".to_string()), - external_docs: None, - }, // Duplicate - ]); - - base.merge(other); - - let tags = base.tags.as_ref().unwrap(); - assert_eq!(tags.len(), 2); // No duplicates - assert!(tags.iter().any(|t| t.name == "users")); - assert!(tags.iter().any(|t| t.name == "posts")); - // Self's description takes precedence - let users_tag = tags.iter().find(|t| t.name == "users").unwrap(); - assert_eq!(users_tag.description, Some("User operations".to_string())); - } - - #[test] - fn test_merge_tags_when_self_has_none() { - let mut base = create_base_openapi(); - assert!(base.tags.is_none()); - - let mut other = create_base_openapi(); - other.tags = Some(vec![Tag { - name: "posts".to_string(), - description: None, - external_docs: None, - }]); - - base.merge(other); - - assert!(base.tags.is_some()); - assert_eq!(base.tags.as_ref().unwrap().len(), 1); - } - - #[test] - fn test_merge_empty_other() { - let mut base = create_base_openapi(); - base.paths - .insert("/users".to_string(), create_path_item("Get users")); - base.tags = Some(vec![Tag { - name: "users".to_string(), - description: None, - external_docs: None, - }]); - - let other = create_base_openapi(); // Empty paths, no components, no tags - - base.merge(other); - - // Base should remain unchanged - assert_eq!(base.paths.len(), 1); - assert!(base.paths.contains_key("/users")); - assert_eq!(base.tags.as_ref().unwrap().len(), 1); - } -} +mod tests; diff --git a/crates/vespera_core/src/openapi/tests.rs b/crates/vespera_core/src/openapi/tests.rs new file mode 100644 index 00000000..53cabe27 --- /dev/null +++ b/crates/vespera_core/src/openapi/tests.rs @@ -0,0 +1,555 @@ +use super::*; +use crate::route::{Operation, PathItem}; +use crate::schema::{Components, Schema, SchemaType, SecurityScheme, SecuritySchemeType}; + +fn create_base_openapi() -> OpenApi { + OpenApi { + openapi: OpenApiVersion::V3_1_0, + info: Info { + title: "Base API".to_string(), + version: "1.0.0".to_string(), + description: None, + terms_of_service: None, + contact: None, + license: None, + summary: None, + }, + servers: None, + paths: BTreeMap::new(), + components: None, + security: None, + tags: None, + external_docs: None, + } +} + +fn create_path_item(summary: &str) -> PathItem { + PathItem { + get: Some(Operation { + summary: Some(summary.to_string()), + description: None, + operation_id: None, + tags: None, + parameters: None, + request_body: None, + responses: BTreeMap::new(), + security: None, + deprecated: None, + external_docs: None, + callbacks: None, + servers: None, + }), + ..Default::default() + } +} + +#[test] +fn test_merge_paths() { + let mut base = create_base_openapi(); + base.paths + .insert("/users".to_string(), create_path_item("Get users")); + + let mut other = create_base_openapi(); + other + .paths + .insert("/posts".to_string(), create_path_item("Get posts")); + other + .paths + .insert("/users".to_string(), create_path_item("Other users")); // Conflict + + base.merge(other); + + // Both paths should exist + assert!(base.paths.contains_key("/users")); + assert!(base.paths.contains_key("/posts")); + // Self takes precedence on conflict + assert_eq!( + base.paths + .get("/users") + .unwrap() + .get + .as_ref() + .unwrap() + .summary, + Some("Get users".to_string()) + ); +} + +fn create_post_path_item(summary: &str) -> PathItem { + PathItem { + post: Some(Operation { + summary: Some(summary.to_string()), + description: None, + operation_id: None, + tags: None, + parameters: None, + request_body: None, + responses: BTreeMap::new(), + security: None, + deprecated: None, + external_docs: None, + callbacks: None, + servers: None, + }), + ..Default::default() + } +} + +#[test] +fn test_merge_same_path_different_methods_are_combined() { + // Regression: a path-key conflict must merge per HTTP method, not + // drop the incoming PathItem wholesale. Parent defines GET /users, + // child defines POST /users — the merged document must expose BOTH + // operations (otherwise the spec under-documents the merged router). + let mut base = create_base_openapi(); + base.paths + .insert("/users".to_string(), create_path_item("List users")); // GET + + let mut other = create_base_openapi(); + other + .paths + .insert("/users".to_string(), create_post_path_item("Create user")); // POST + + base.merge(other); + + let users = base.paths.get("/users").expect("/users present"); + // self-wins GET is preserved + assert_eq!( + users.get.as_ref().unwrap().summary, + Some("List users".to_string()) + ); + // incoming POST is merged in (previously dropped on the whole-item + // `or_insert`) + assert_eq!( + users.post.as_ref().unwrap().summary, + Some("Create user".to_string()) + ); +} + +#[test] +fn test_merge_same_path_same_method_self_wins() { + // Same path AND same method on both sides: self's operation is kept, + // the incoming one is discarded. + let mut base = create_base_openapi(); + base.paths + .insert("/users".to_string(), create_path_item("Base get")); + + let mut other = create_base_openapi(); + other + .paths + .insert("/users".to_string(), create_path_item("Other get")); + + base.merge(other); + + assert_eq!( + base.paths + .get("/users") + .unwrap() + .get + .as_ref() + .unwrap() + .summary, + Some("Base get".to_string()) + ); +} + +#[test] +fn test_merge_schemas() { + let mut base = create_base_openapi(); + let mut base_schemas = BTreeMap::new(); + base_schemas.insert("User".to_string(), Schema::object_empty()); + base.components = Some(Components { + schemas: Some(base_schemas), + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }); + + let mut other = create_base_openapi(); + let mut other_schemas = BTreeMap::new(); + other_schemas.insert("Post".to_string(), Schema::object_empty()); + other_schemas.insert("User".to_string(), Schema::string()); // Conflict + other.components = Some(Components { + schemas: Some(other_schemas), + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }); + + base.merge(other); + + let schemas = base.components.as_ref().unwrap().schemas.as_ref().unwrap(); + assert!(schemas.contains_key("User")); + assert!(schemas.contains_key("Post")); + // Self takes precedence on conflict + assert_eq!( + schemas.get("User").unwrap().schema_type, + Some(SchemaType::Object) + ); +} + +#[test] +fn test_merge_schemas_when_self_has_no_components() { + let mut base = create_base_openapi(); + assert!(base.components.is_none()); + + let mut other = create_base_openapi(); + let mut other_schemas = BTreeMap::new(); + other_schemas.insert("Post".to_string(), Schema::object_empty()); + other.components = Some(Components { + schemas: Some(other_schemas), + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }); + + base.merge(other); + + assert!(base.components.is_some()); + let schemas = base.components.as_ref().unwrap().schemas.as_ref().unwrap(); + assert!(schemas.contains_key("Post")); +} + +#[test] +fn test_merge_security_schemes() { + let mut base = create_base_openapi(); + let mut base_security_schemes = BTreeMap::new(); + base_security_schemes.insert( + "bearerAuth".to_string(), + SecurityScheme { + r#type: SecuritySchemeType::Http, + description: None, + name: None, + r#in: None, + scheme: Some("bearer".to_string()), + bearer_format: Some("JWT".to_string()), + flows: None, + open_id_connect_url: None, + }, + ); + base.components = Some(Components { + schemas: None, + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: Some(base_security_schemes), + }); + + let mut other = create_base_openapi(); + let mut other_security_schemes = BTreeMap::new(); + other_security_schemes.insert( + "apiKey".to_string(), + SecurityScheme { + r#type: SecuritySchemeType::ApiKey, + description: None, + name: Some("X-API-Key".to_string()), + r#in: Some("header".to_string()), + scheme: None, + bearer_format: None, + flows: None, + open_id_connect_url: None, + }, + ); + other.components = Some(Components { + schemas: None, + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: Some(other_security_schemes), + }); + + base.merge(other); + + let security_schemes = base + .components + .as_ref() + .unwrap() + .security_schemes + .as_ref() + .unwrap(); + assert!(security_schemes.contains_key("bearerAuth")); + assert!(security_schemes.contains_key("apiKey")); +} + +#[test] +fn test_merge_tags() { + let mut base = create_base_openapi(); + base.tags = Some(vec![Tag { + name: "users".to_string(), + description: Some("User operations".to_string()), + external_docs: None, + }]); + + let mut other = create_base_openapi(); + other.tags = Some(vec![ + Tag { + name: "posts".to_string(), + description: Some("Post operations".to_string()), + external_docs: None, + }, + Tag { + name: "users".to_string(), + description: Some("Duplicate users tag".to_string()), + external_docs: None, + }, // Duplicate + ]); + + base.merge(other); + + let tags = base.tags.as_ref().unwrap(); + assert_eq!(tags.len(), 2); // No duplicates + assert!(tags.iter().any(|t| t.name == "users")); + assert!(tags.iter().any(|t| t.name == "posts")); + // Self's description takes precedence + let users_tag = tags.iter().find(|t| t.name == "users").unwrap(); + assert_eq!(users_tag.description, Some("User operations".to_string())); +} + +#[test] +fn test_merge_tags_when_self_has_none() { + let mut base = create_base_openapi(); + assert!(base.tags.is_none()); + + let mut other = create_base_openapi(); + other.tags = Some(vec![Tag { + name: "posts".to_string(), + description: None, + external_docs: None, + }]); + + base.merge(other); + + assert!(base.tags.is_some()); + assert_eq!(base.tags.as_ref().unwrap().len(), 1); +} + +#[test] +fn test_merge_empty_other() { + let mut base = create_base_openapi(); + base.paths + .insert("/users".to_string(), create_path_item("Get users")); + base.tags = Some(vec![Tag { + name: "users".to_string(), + description: None, + external_docs: None, + }]); + + let other = create_base_openapi(); // Empty paths, no components, no tags + + base.merge(other); + + // Base should remain unchanged + assert_eq!(base.paths.len(), 1); + assert!(base.paths.contains_key("/users")); + assert_eq!(base.tags.as_ref().unwrap().len(), 1); +} + +#[test] +fn test_merge_components_responses_and_parameters() { + use crate::route::{Parameter, ParameterLocation, Response}; + + let response = |desc: &str| Response { + description: desc.to_string(), + headers: None, + content: None, + }; + + let mut base = create_base_openapi(); + base.components = Some(Components { + schemas: None, + responses: Some(BTreeMap::from([("NotFound".to_string(), response("base"))])), + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }); + + let mut other = create_base_openapi(); + other.components = Some(Components { + schemas: None, + responses: Some(BTreeMap::from([ + ("NotFound".to_string(), response("other-dup")), + ("ServerError".to_string(), response("other")), + ])), + parameters: Some(BTreeMap::from([( + "PageParam".to_string(), + Parameter { + name: "page".to_string(), + r#in: ParameterLocation::Query, + description: None, + required: None, + schema: None, + example: None, + }, + )])), + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }); + + base.merge(other); + + let comps = base.components.as_ref().unwrap(); + let responses = comps.responses.as_ref().unwrap(); + // other's non-conflicting response is merged in (previously dropped). + assert!(responses.contains_key("NotFound")); + assert!(responses.contains_key("ServerError")); + // self wins on conflict. + assert_eq!(responses.get("NotFound").unwrap().description, "base"); + // parameters adopted from other (base had none) — previously dropped. + assert!(comps.parameters.as_ref().unwrap().contains_key("PageParam")); +} + +#[test] +fn test_merge_empty_component_maps_are_absent() { + let mut base = create_base_openapi(); + let mut other = create_base_openapi(); + other.components = Some(Components { + schemas: Some(BTreeMap::new()), + responses: Some(BTreeMap::new()), + parameters: Some(BTreeMap::new()), + examples: Some(BTreeMap::new()), + request_bodies: Some(BTreeMap::new()), + headers: Some(BTreeMap::new()), + security_schemes: Some(BTreeMap::new()), + }); + + base.merge(other); + + assert!(base.components.is_none()); +} + +#[test] +fn empty_components_do_not_serialize() { + let api = OpenApi { + components: Some(Components::default()), + ..create_base_openapi() + }; + + let json = serde_json::to_string(&api).unwrap(); + + assert!( + !json.contains("components"), + "empty components should be omitted: {json}" + ); +} + +#[test] +fn license_identifier_serializes_when_present() { + let license = License { + name: "Apache-2.0".to_string(), + identifier: Some("Apache-2.0".to_string()), + url: None, + }; + + let json = serde_json::to_string(&license).unwrap(); + + assert_eq!(json, r#"{"name":"Apache-2.0","identifier":"Apache-2.0"}"#); +} + +#[test] +fn test_merge_empty_component_maps_do_not_create_empty_sections() { + let mut schemas = BTreeMap::new(); + schemas.insert("User".to_string(), Schema::object_empty()); + + let mut base = create_base_openapi(); + base.components = Some(Components { + schemas: Some(schemas), + responses: None, + parameters: None, + examples: None, + request_bodies: None, + headers: None, + security_schemes: None, + }); + + let mut other = create_base_openapi(); + other.components = Some(Components { + schemas: Some(BTreeMap::new()), + responses: Some(BTreeMap::new()), + parameters: Some(BTreeMap::new()), + examples: Some(BTreeMap::new()), + request_bodies: Some(BTreeMap::new()), + headers: Some(BTreeMap::new()), + security_schemes: Some(BTreeMap::new()), + }); + + base.merge(other); + + let components = base.components.as_ref().unwrap(); + assert!(components.schemas.as_ref().unwrap().contains_key("User")); + assert!(components.responses.is_none()); + assert!(components.parameters.is_none()); + assert!(components.examples.is_none()); + assert!(components.request_bodies.is_none()); + assert!(components.headers.is_none()); + assert!(components.security_schemes.is_none()); +} + +#[test] +fn test_merge_top_level_servers_security_external_docs() { + use crate::schema::ExternalDocumentation; + + // base sets none of the three → adopts other's. + let mut base = create_base_openapi(); + let mut other = create_base_openapi(); + other.servers = Some(vec![Server { + url: "https://api.example.com".to_string(), + description: None, + variables: None, + }]); + other.security = Some(vec![BTreeMap::from([( + "bearerAuth".to_string(), + Vec::new(), + )])]); + other.external_docs = Some(ExternalDocumentation { + description: None, + url: "https://docs.example.com".to_string(), + }); + + base.merge(other); + + assert_eq!( + base.servers.as_ref().unwrap()[0].url, + "https://api.example.com" + ); + assert!(base.security.is_some()); + assert_eq!( + base.external_docs.as_ref().unwrap().url, + "https://docs.example.com" + ); + + // self-wins: base already has servers → other's ignored. + let mut base2 = create_base_openapi(); + base2.servers = Some(vec![Server { + url: "https://self.example.com".to_string(), + description: None, + variables: None, + }]); + let mut other2 = create_base_openapi(); + other2.servers = Some(vec![Server { + url: "https://other.example.com".to_string(), + description: None, + variables: None, + }]); + base2.merge(other2); + assert_eq!( + base2.servers.as_ref().unwrap()[0].url, + "https://self.example.com" + ); +} diff --git a/crates/vespera_core/src/route.rs b/crates/vespera_core/src/route.rs index 58328755..e7b19e07 100644 --- a/crates/vespera_core/src/route.rs +++ b/crates/vespera_core/src/route.rs @@ -1,9 +1,9 @@ //! Route-related structure definitions use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; -use crate::SchemaRef; +use crate::{SchemaRef, openapi::Server, schema::ExternalDocumentation}; /// HTTP method #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -38,16 +38,31 @@ impl TryFrom<&str> for HttpMethod { type Error = String; fn try_from(value: &str) -> Result { - match value.to_uppercase().as_str() { - "GET" => Ok(Self::Get), - "POST" => Ok(Self::Post), - "PUT" => Ok(Self::Put), - "PATCH" => Ok(Self::Patch), - "DELETE" => Ok(Self::Delete), - "HEAD" => Ok(Self::Head), - "OPTIONS" => Ok(Self::Options), - "TRACE" => Ok(Self::Trace), - other => Err(format!("unknown HTTP method: {other}")), + // Match case-insensitively without allocating an upper-cased copy + // on the success path (HTTP method names are ASCII per RFC 9110); + // the cold error path still reports the ASCII-uppercased value so the + // message is byte-identical to the previous implementation. + if value.eq_ignore_ascii_case("GET") { + Ok(Self::Get) + } else if value.eq_ignore_ascii_case("POST") { + Ok(Self::Post) + } else if value.eq_ignore_ascii_case("PUT") { + Ok(Self::Put) + } else if value.eq_ignore_ascii_case("PATCH") { + Ok(Self::Patch) + } else if value.eq_ignore_ascii_case("DELETE") { + Ok(Self::Delete) + } else if value.eq_ignore_ascii_case("HEAD") { + Ok(Self::Head) + } else if value.eq_ignore_ascii_case("OPTIONS") { + Ok(Self::Options) + } else if value.eq_ignore_ascii_case("TRACE") { + Ok(Self::Trace) + } else { + Err(format!( + "unknown HTTP method: {}", + value.to_ascii_uppercase() + )) } } } @@ -110,7 +125,7 @@ pub struct MediaType { pub example: Option, /// Examples #[serde(skip_serializing_if = "Option::is_none")] - pub examples: Option>, + pub examples: Option>, } /// Example definition @@ -136,7 +151,7 @@ pub struct Response { pub description: String, /// Header definitions #[serde(skip_serializing_if = "Option::is_none")] - pub headers: Option>, + pub headers: Option>, /// Schema per Content-Type #[serde(skip_serializing_if = "Option::is_none")] pub content: Option>, @@ -180,13 +195,28 @@ pub struct Operation { pub responses: BTreeMap, /// Security requirements #[serde(skip_serializing_if = "Option::is_none")] - pub security: Option>>>, + pub security: Option>>>, + /// Whether this operation is deprecated + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated: Option, + /// External documentation for this operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_docs: Option, + /// Callback definitions keyed by runtime expression. + #[serde(skip_serializing_if = "Option::is_none")] + pub callbacks: Option>>, + /// Alternative servers for this operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub servers: Option>, } /// Path Item definition (all HTTP methods for a specific path) #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PathItem { + /// Reference to another path item. + #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")] + pub ref_path: Option, /// GET method #[serde(skip_serializing_if = "Option::is_none")] pub get: Option, @@ -220,22 +250,37 @@ pub struct PathItem { /// Description #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Alternative servers for all operations in this path. + #[serde(skip_serializing_if = "Option::is_none")] + pub servers: Option>, } impl PathItem { - /// Set an operation for a specific HTTP method - pub fn set_operation(&mut self, method: HttpMethod, operation: Operation) { + /// Try to set an operation for a specific HTTP method. + /// + /// Returns the operation that was already present, if this call replaced one. + #[must_use] + pub fn try_set_operation( + &mut self, + method: HttpMethod, + operation: Operation, + ) -> Option { match method { - HttpMethod::Get => self.get = Some(operation), - HttpMethod::Post => self.post = Some(operation), - HttpMethod::Put => self.put = Some(operation), - HttpMethod::Patch => self.patch = Some(operation), - HttpMethod::Delete => self.delete = Some(operation), - HttpMethod::Head => self.head = Some(operation), - HttpMethod::Options => self.options = Some(operation), - HttpMethod::Trace => self.trace = Some(operation), + HttpMethod::Get => self.get.replace(operation), + HttpMethod::Post => self.post.replace(operation), + HttpMethod::Put => self.put.replace(operation), + HttpMethod::Patch => self.patch.replace(operation), + HttpMethod::Delete => self.delete.replace(operation), + HttpMethod::Head => self.head.replace(operation), + HttpMethod::Options => self.options.replace(operation), + HttpMethod::Trace => self.trace.replace(operation), } } + + /// Set an operation for a specific HTTP method, discarding any replaced operation. + pub fn set_operation(&mut self, method: HttpMethod, operation: Operation) { + let _ = self.try_set_operation(method, operation); + } } #[cfg(test)] @@ -321,6 +366,10 @@ mod tests { request_body: None, responses: BTreeMap::new(), security: None, + deprecated: None, + external_docs: None, + callbacks: None, + servers: None, }; // Test setting GET operation @@ -391,6 +440,10 @@ mod tests { request_body: None, responses: BTreeMap::new(), security: None, + deprecated: None, + external_docs: None, + callbacks: None, + servers: None, }; let operation2 = Operation { @@ -402,6 +455,10 @@ mod tests { request_body: None, responses: BTreeMap::new(), security: None, + deprecated: None, + external_docs: None, + callbacks: None, + servers: None, }; // Set first operation @@ -419,6 +476,49 @@ mod tests { ); } + #[test] + fn operation_and_path_item_optional_openapi_fields_serialize_when_present() { + let mut callbacks = BTreeMap::new(); + callbacks.insert( + "{$request.body#/callbackUrl}".to_string(), + Box::new(PathItem::default()), + ); + let operation = Operation { + operation_id: None, + tags: None, + summary: None, + description: None, + parameters: None, + request_body: None, + responses: BTreeMap::new(), + security: None, + deprecated: None, + external_docs: None, + callbacks: Some(callbacks), + servers: Some(vec![Server { + url: "https://api.example.com".to_string(), + description: None, + variables: None, + }]), + }; + let path_item = PathItem { + ref_path: Some("#/paths/~1users".to_string()), + get: Some(operation), + servers: Some(vec![Server { + url: "https://path.example.com".to_string(), + description: None, + variables: None, + }]), + ..PathItem::default() + }; + + let json = serde_json::to_string(&path_item).unwrap(); + + assert!(json.contains("\"$ref\":\"#/paths/~1users\"")); + assert!(json.contains("callbacks")); + assert!(json.contains("servers")); + } + #[rstest] #[case(HttpMethod::Get, "GET")] #[case(HttpMethod::Post, "POST")] diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index c9e6b6fb..461e13f4 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -1,38 +1,22 @@ //! Schema-related structure definitions -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; +mod components; +mod schema_ref; +mod serde_impls; -/// Schema reference or inline schema -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum SchemaRef { - /// Schema reference (e.g., "#/components/schemas/User") - Ref(Reference), - /// Inline schema - Inline(Box), -} +pub use components::{Components, OAuthFlow, OAuthFlows, SecurityScheme, SecuritySchemeType}; +pub use schema_ref::{AdditionalProperties, Reference, SchemaRef}; -/// Reference definition -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Reference { - /// Reference path (e.g., "#/components/schemas/User") - #[serde(rename = "$ref")] - pub ref_path: String, -} - -impl Reference { - /// Create a new reference - #[must_use] - pub const fn new(ref_path: String) -> Self { - Self { ref_path } - } +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; - /// Create a component schema reference - #[must_use] - pub fn schema(name: &str) -> Self { - Self::new(format!("#/components/schemas/{name}")) - } +#[cfg(test)] +#[allow(clippy::ref_option)] // serde serialize_with mandates &Option signature +fn serialize_number_constraint(value: &Option, serializer: S) -> Result +where + S: serde::Serializer, +{ + serde_impls::serialize_number_constraint(value, serializer) } /// JSON Schema type @@ -48,246 +32,167 @@ pub enum SchemaType { Null, } -/// Serialize `Option` as integer when the value has no fractional part. -/// -/// Ensures OpenAPI JSON uses `0` instead of `0.0` for integer constraints like -/// `minimum`/`maximum`, matching the convention that integer type bounds are integers. -#[allow(clippy::ref_option)] // serde serialize_with mandates &Option signature -fn serialize_number_constraint(value: &Option, serializer: S) -> Result -where - S: serde::Serializer, -{ - match value { - Some(v) if v.fract() == 0.0 => { - // Practical OpenAPI constraints are well within i64 range - #[allow(clippy::cast_possible_truncation)] - let int_val = *v as i64; - serializer.serialize_some(&int_val) - } - Some(v) => serializer.serialize_some(v), - None => serializer.serialize_none(), - } +#[allow(clippy::ref_option)] // serde skip_serializing_if mandates &Option signature +fn is_empty_properties(value: &Option>) -> bool { + value.as_ref().is_none_or(BTreeMap::is_empty) +} + +#[allow(clippy::ref_option)] // serde skip_serializing_if mandates &Option signature +fn is_empty_required(value: &Option>) -> bool { + value.as_ref().is_none_or(Vec::is_empty) } /// JSON Schema definition -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, Default)] pub struct Schema { - /// Schema reference ($ref) - if present, other fields are ignored - #[serde(rename = "$ref")] - #[serde(skip_serializing_if = "Option::is_none")] + /// Schema reference (`$ref`). + /// + /// A *pure* reference should be expressed as [`SchemaRef::Ref`]. + /// This field exists only for the one legitimate mixed form OpenAPI + /// 3.1 permits — a **nullable reference** (`$ref` + `nullable`) — + /// which is best built through [`Schema::nullable_reference`] rather + /// than by hand, to avoid accidentally mixing `$ref` with unrelated + /// inline constraints (the invalid state flagged by CORE-03). pub ref_path: Option, /// Schema type - #[serde(rename = "type")] - #[serde(skip_serializing_if = "Option::is_none")] pub schema_type: Option, /// Format (for numbers or strings) - #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, /// Title - #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, /// Description - #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// Default value - #[serde(skip_serializing_if = "Option::is_none")] pub default: Option, /// Example - #[serde(skip_serializing_if = "Option::is_none")] pub example: Option, /// Examples - #[serde(skip_serializing_if = "Option::is_none")] pub examples: Option>, // Number constraints /// Minimum value - #[serde( - skip_serializing_if = "Option::is_none", - serialize_with = "serialize_number_constraint" - )] pub minimum: Option, /// Maximum value - #[serde( - skip_serializing_if = "Option::is_none", - serialize_with = "serialize_number_constraint" - )] pub maximum: Option, - /// Exclusive minimum - #[serde(skip_serializing_if = "Option::is_none")] - pub exclusive_minimum: Option, - /// Exclusive maximum - #[serde(skip_serializing_if = "Option::is_none")] - pub exclusive_maximum: Option, + /// Exclusive minimum boundary (OpenAPI 3.1 / JSON Schema 2020-12 numeric form). + pub exclusive_minimum: Option, + /// Exclusive maximum boundary (OpenAPI 3.1 / JSON Schema 2020-12 numeric form). + pub exclusive_maximum: Option, /// Multiple of - #[serde( - skip_serializing_if = "Option::is_none", - serialize_with = "serialize_number_constraint" - )] pub multiple_of: Option, // String constraints /// Minimum length - #[serde(skip_serializing_if = "Option::is_none")] pub min_length: Option, /// Maximum length - #[serde(skip_serializing_if = "Option::is_none")] pub max_length: Option, /// Pattern (regex) - #[serde(skip_serializing_if = "Option::is_none")] pub pattern: Option, // Array constraints - /// Array item schema - #[serde(skip_serializing_if = "Option::is_none")] - pub items: Option>, + /// Array item schema. + /// + /// No outer `Box`: [`SchemaRef::Inline`] already boxes the nested + /// [`Schema`], so the recursive type is finite without a second + /// indirection (CORE-02). + pub items: Option, /// Prefix items for tuple arrays (`OpenAPI` 3.1 / JSON Schema 2020-12) - #[serde(skip_serializing_if = "Option::is_none")] pub prefix_items: Option>, /// Minimum number of items - #[serde(skip_serializing_if = "Option::is_none")] pub min_items: Option, /// Maximum number of items - #[serde(skip_serializing_if = "Option::is_none")] pub max_items: Option, /// Unique items flag - #[serde(skip_serializing_if = "Option::is_none")] pub unique_items: Option, // Object constraints /// Property definitions - #[serde(skip_serializing_if = "Option::is_none")] pub properties: Option>, /// List of required properties - #[serde(skip_serializing_if = "Option::is_none")] pub required: Option>, - /// Whether additional properties are allowed (can be boolean or `SchemaRef`) - #[serde(skip_serializing_if = "Option::is_none")] - pub additional_properties: Option, + /// `additionalProperties`: a boolean or a value-schema (CORE-04). + /// + /// Typed as [`AdditionalProperties`] (untagged) instead of a raw + /// `serde_json::Value`, so invalid shapes can't be constructed and + /// the value-schema case avoids the `SchemaRef -> serde_json::Value` + /// round-trip the parser previously paid. Wire output is unchanged. + pub additional_properties: Option, /// Minimum number of properties - #[serde(skip_serializing_if = "Option::is_none")] pub min_properties: Option, /// Maximum number of properties - #[serde(skip_serializing_if = "Option::is_none")] pub max_properties: Option, // General constraints /// Enum values - #[serde(skip_serializing_if = "Option::is_none")] pub r#enum: Option>, /// All conditions must be satisfied (AND) - #[serde(skip_serializing_if = "Option::is_none")] pub all_of: Option>, /// At least one condition must be satisfied (OR) - #[serde(skip_serializing_if = "Option::is_none")] pub any_of: Option>, /// Exactly one condition must be satisfied (XOR) - #[serde(skip_serializing_if = "Option::is_none")] pub one_of: Option>, - /// Condition must not be satisfied (NOT) - #[serde(skip_serializing_if = "Option::is_none")] - pub not: Option>, + /// Condition must not be satisfied (NOT). + /// + /// No outer `Box` — [`SchemaRef::Inline`] already boxes the nested + /// schema (CORE-02). + pub not: Option, /// Discriminator for polymorphic schemas (used with oneOf/anyOf/allOf) - #[serde(skip_serializing_if = "Option::is_none")] pub discriminator: Option, /// Nullable flag - #[serde(skip_serializing_if = "Option::is_none")] pub nullable: Option, /// Read-only flag - #[serde(skip_serializing_if = "Option::is_none")] pub read_only: Option, /// Write-only flag - #[serde(skip_serializing_if = "Option::is_none")] pub write_only: Option, /// External documentation reference - #[serde(skip_serializing_if = "Option::is_none")] pub external_docs: Option, // JSON Schema 2020-12 dynamic references /// Definitions ($defs) - reusable schema definitions - #[serde(rename = "$defs")] - #[serde(skip_serializing_if = "Option::is_none")] pub defs: Option>, /// Dynamic anchor ($dynamicAnchor) - defines a dynamic anchor - #[serde(rename = "$dynamicAnchor")] - #[serde(skip_serializing_if = "Option::is_none")] pub dynamic_anchor: Option, /// Dynamic reference ($dynamicRef) - references a dynamic anchor - #[serde(rename = "$dynamicRef")] - #[serde(skip_serializing_if = "Option::is_none")] pub dynamic_ref: Option, } impl Schema { - /// Create a new schema + /// Create a new schema of the given type. + /// + /// Every other field starts at its [`Default`] (`None`/empty), so a newly + /// added `Schema` field is auto-defaulted here instead of having to be + /// appended to a ~40-field manual initializer that drifts out of sync. #[must_use] - pub const fn new(schema_type: SchemaType) -> Self { + pub fn new(schema_type: SchemaType) -> Self { Self { - ref_path: None, schema_type: Some(schema_type), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - r#enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - discriminator: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, + ..Self::default() } } /// Create a string schema #[must_use] - pub const fn string() -> Self { + pub fn string() -> Self { Self::new(SchemaType::String) } /// Create an integer schema #[must_use] - pub const fn integer() -> Self { + pub fn integer() -> Self { Self::new(SchemaType::Integer) } /// Create a number schema #[must_use] - pub const fn number() -> Self { + pub fn number() -> Self { Self::new(SchemaType::Number) } /// Create a boolean schema #[must_use] - pub const fn boolean() -> Self { + pub fn boolean() -> Self { Self::new(SchemaType::Boolean) } @@ -295,7 +200,7 @@ impl Schema { #[must_use] pub fn array(items: SchemaRef) -> Self { Self { - items: Some(Box::new(items)), + items: Some(items), ..Self::new(SchemaType::Array) } } @@ -309,6 +214,82 @@ impl Schema { ..Self::new(SchemaType::Object) } } + + /// Create an object schema without allocating empty `properties` or `required` collections. + #[must_use] + pub fn object_empty() -> Self { + Self::new(SchemaType::Object) + } + + /// Build a **nullable reference** schema that serializes as OpenAPI 3.1 + /// `anyOf`: `[{ "$ref": }, { "type": "null" }]`. + /// + /// This is the single legitimate mixed `$ref` form (CORE-03): a + /// reference that is also allowed to be `null`. Centralizing it + /// here keeps `ref_path` from being hand-mixed with unrelated inline + /// constraints at call sites. `ref_path` is the full reference + /// path (e.g. `"#/components/schemas/User"`); `schema_type` stays + /// `None` so only the nullable-reference `anyOf` shape is emitted. + #[must_use] + pub fn nullable_reference(ref_path: String) -> Self { + Self { + ref_path: Some(ref_path), + schema_type: None, + nullable: Some(true), + ..Self::object_empty() + } + } + + /// Reconstruct a [`Schema`] from a compile-time-serialized JSON spec. + /// + /// This is the bridge the `schema!` proc-macro uses to emit a runtime + /// `Schema` value that is **identical** to the one the OpenAPI + /// generator produces for the same type: the macro builds the schema + /// through the shared `parse_struct_to_schema` path, serializes it to + /// JSON at compile time, and emits a call to this constructor — so the + /// `schema!` result can never drift from the documented component + /// schema (required-by-nullability, doc descriptions, + /// flatten/transparent, field constraints, `$ref` references). + /// + /// The input is always valid JSON (the macro just serialized it via + /// `serde_json`), so a parse failure is unreachable in practice; it + /// degrades to [`Schema::default`] rather than panicking inside + /// generated user code. A failure would silently drop a component + /// schema, so it is surfaced via `debug_assert!` (caught in + /// development / CI) while release builds still degrade gracefully — a + /// macro/serde drift never goes unnoticed but never panics in + /// downstream user code either. + #[must_use] + pub fn from_compiled_json(json: &str) -> Self { + match serde_json::from_str(json) { + Ok(schema) => schema, + Err(e) => { + // Surface the (in-practice-unreachable) macro/serde drift in + // debug / CI builds via `debug_assert!`. In release, degrade + // to a VISIBLE sentinel schema (a description-only object) + // rather than a silent `Schema::default()`, so a drift never + // disappears unnoticed from the generated spec yet never + // panics in downstream user code. + debug_assert!( + false, + "vespera: Schema::from_compiled_json failed to parse macro-emitted \ + JSON ({e}); emitting a sentinel schema. This indicates a \ + vespera bug — the macro serialized a Schema that cannot round-trip." + ); + schema_parse_failure_sentinel(&e) + } + } + } +} + +fn schema_parse_failure_sentinel(error: &serde_json::Error) -> Schema { + Schema { + title: Some("VESPERA_SCHEMA_PARSE_ERROR".to_owned()), + description: Some(format!( + "vespera: schema unavailable — macro/serde drift ({error})" + )), + ..Schema::default() + } } /// External documentation reference @@ -336,187 +317,5 @@ pub struct Discriminator { pub mapping: Option>, } -/// `OpenAPI` Components (reusable components) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Components { - /// Schema definitions - #[serde(skip_serializing_if = "Option::is_none")] - pub schemas: Option>, - /// Response definitions - #[serde(skip_serializing_if = "Option::is_none")] - pub responses: Option>, - /// Parameter definitions - #[serde(skip_serializing_if = "Option::is_none")] - pub parameters: Option>, - /// Example definitions - #[serde(skip_serializing_if = "Option::is_none")] - pub examples: Option>, - /// Request body definitions - #[serde(skip_serializing_if = "Option::is_none")] - pub request_bodies: Option>, - /// Header definitions - #[serde(skip_serializing_if = "Option::is_none")] - pub headers: Option>, - /// Security scheme definitions - #[serde(skip_serializing_if = "Option::is_none")] - pub security_schemes: Option>, -} - -/// Security scheme type -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum SecuritySchemeType { - ApiKey, - Http, - MutualTls, - OAuth2, - OpenIdConnect, -} - -/// Security scheme definition -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SecurityScheme { - /// Security scheme type - pub r#type: SecuritySchemeType, - /// Description - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Name (for API Key) - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Location (for API Key: query, header, cookie) - #[serde(skip_serializing_if = "Option::is_none")] - pub r#in: Option, - /// Scheme (for HTTP: bearer, basic, etc.) - #[serde(skip_serializing_if = "Option::is_none")] - pub scheme: Option, - /// Bearer format (for HTTP Bearer) - #[serde(skip_serializing_if = "Option::is_none")] - pub bearer_format: Option, -} - #[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - - #[rstest] - #[case(Schema::string(), SchemaType::String)] - #[case(Schema::integer(), SchemaType::Integer)] - #[case(Schema::number(), SchemaType::Number)] - #[case(Schema::boolean(), SchemaType::Boolean)] - fn primitive_helpers_set_schema_type(#[case] schema: Schema, #[case] expected: SchemaType) { - assert_eq!(schema.schema_type, Some(expected)); - } - - #[test] - fn array_helper_sets_type_and_items() { - let item_schema = Schema::boolean(); - let schema = Schema::array(SchemaRef::Inline(Box::new(item_schema.clone()))); - - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - let items = schema.items.expect("items should be set"); - match *items { - SchemaRef::Inline(inner) => { - assert_eq!(inner.schema_type, Some(SchemaType::Boolean)); - } - SchemaRef::Ref(_) => panic!("array helper should set inline items"), - } - } - - #[test] - fn object_helper_initializes_collections() { - let schema = Schema::object(); - - assert_eq!(schema.schema_type, Some(SchemaType::Object)); - let props = schema.properties.expect("properties should be initialized"); - assert!(props.is_empty()); - let required = schema.required.expect("required should be initialized"); - assert!(required.is_empty()); - } - - #[test] - fn serialize_number_constraint_none_serializes_null() { - // Direct call bypasses skip_serializing_if to cover the None branch - let result = - super::serialize_number_constraint(&None, serde_json::value::Serializer).unwrap(); - assert_eq!(result, serde_json::Value::Null); - } - - #[test] - fn serialize_minimum_whole_number_as_integer() { - let schema = Schema { - minimum: Some(0.0), - ..Schema::integer() - }; - let json = serde_json::to_string(&schema).unwrap(); - // Must be "minimum":0 (integer), NOT "minimum":0.0 - assert!( - json.contains("\"minimum\":0"), - "expected integer 0, got: {json}" - ); - assert!( - !json.contains("\"minimum\":0.0"), - "must not contain 0.0: {json}" - ); - } - - #[test] - fn serialize_minimum_fractional_as_float() { - let schema = Schema { - minimum: Some(1.5), - ..Schema::number() - }; - let json = serde_json::to_string(&schema).unwrap(); - assert!( - json.contains("\"minimum\":1.5"), - "expected 1.5, got: {json}" - ); - } - - #[test] - fn serialize_minimum_none_omitted() { - let schema = Schema::integer(); - let json = serde_json::to_string(&schema).unwrap(); - assert!( - !json.contains("minimum"), - "None minimum should be omitted: {json}" - ); - } - - #[test] - fn serialize_maximum_whole_number_as_integer() { - let schema = Schema { - maximum: Some(100.0), - ..Schema::integer() - }; - let json = serde_json::to_string(&schema).unwrap(); - assert!( - json.contains("\"maximum\":100"), - "expected integer 100, got: {json}" - ); - assert!( - !json.contains("\"maximum\":100.0"), - "must not contain 100.0: {json}" - ); - } - - #[test] - fn serialize_multiple_of_whole_number_as_integer() { - let schema = Schema { - multiple_of: Some(2.0), - ..Schema::integer() - }; - let json = serde_json::to_string(&schema).unwrap(); - assert!( - json.contains("\"multipleOf\":2"), - "expected integer 2, got: {json}" - ); - assert!( - !json.contains("\"multipleOf\":2.0"), - "must not contain 2.0: {json}" - ); - } -} +mod tests; diff --git a/crates/vespera_core/src/schema/components.rs b/crates/vespera_core/src/schema/components.rs new file mode 100644 index 00000000..b3819194 --- /dev/null +++ b/crates/vespera_core/src/schema/components.rs @@ -0,0 +1,104 @@ +use super::Schema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// `OpenAPI` Components (reusable components) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Components { + /// Schema definitions + #[serde(skip_serializing_if = "Option::is_none")] + pub schemas: Option>, + /// Response definitions + #[serde(skip_serializing_if = "Option::is_none")] + pub responses: Option>, + /// Parameter definitions + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option>, + /// Example definitions + #[serde(skip_serializing_if = "Option::is_none")] + pub examples: Option>, + /// Request body definitions + #[serde(skip_serializing_if = "Option::is_none")] + pub request_bodies: Option>, + /// Header definitions + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, + /// Security scheme definitions + #[serde(skip_serializing_if = "Option::is_none")] + pub security_schemes: Option>, +} + +/// Security scheme type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SecuritySchemeType { + ApiKey, + Http, + /// OpenAPI's canonical wire name is `mutualTLS` (not the `camelCase` + /// `mutualTls` the container rule would produce). + #[serde(rename = "mutualTLS")] + MutualTls, + /// OpenAPI's canonical wire name is `oauth2`; the `camelCase` container + /// rule would otherwise lowercase only the leading char and emit the + /// invalid `oAuth2`. + #[serde(rename = "oauth2")] + OAuth2, + OpenIdConnect, +} + +/// Security scheme definition +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SecurityScheme { + /// Security scheme type + pub r#type: SecuritySchemeType, + /// Description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Name (for API Key) + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Location (for API Key: query, header, cookie) + #[serde(skip_serializing_if = "Option::is_none")] + pub r#in: Option, + /// Scheme (for HTTP: bearer, basic, etc.) + #[serde(skip_serializing_if = "Option::is_none")] + pub scheme: Option, + /// Bearer format (for HTTP Bearer) + #[serde(skip_serializing_if = "Option::is_none")] + pub bearer_format: Option, + /// OAuth2 flows (for OAuth2 security schemes). + #[serde(skip_serializing_if = "Option::is_none")] + pub flows: Option, + /// OpenID Connect discovery URL (for OpenID Connect security schemes). + #[serde(skip_serializing_if = "Option::is_none")] + pub open_id_connect_url: Option, +} + +/// OAuth2 flow definitions for OpenAPI security schemes. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OAuthFlows { + #[serde(skip_serializing_if = "Option::is_none")] + pub implicit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_credentials: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization_code: Option, +} + +/// OAuth2 flow definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OAuthFlow { + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_url: Option, + pub scopes: BTreeMap, +} diff --git a/crates/vespera_core/src/schema/schema_ref.rs b/crates/vespera_core/src/schema/schema_ref.rs new file mode 100644 index 00000000..514449f4 --- /dev/null +++ b/crates/vespera_core/src/schema/schema_ref.rs @@ -0,0 +1,318 @@ +use super::Schema; +use serde::{ + Deserialize, Serialize, + de::{Error as DeError, IgnoredAny, MapAccess}, +}; + +/// Schema reference or inline schema. +/// +/// Serializes untagged — a bare `{"$ref": ...}` object for +/// [`SchemaRef::Ref`], the schema object for [`SchemaRef::Inline`]. +/// +/// Deserialization is a hand-written impl rather than +/// `#[serde(untagged)]`: an untagged `Ref`-first enum greedily matched +/// **any** object carrying a `$ref` key and silently dropped its +/// siblings (e.g. a nullable reference's `"nullable": true`). The +/// custom impl treats only a *pure* `{"$ref": }` object as a +/// reference; a `$ref` accompanied by any sibling keyword +/// (`nullable`, `description`, …) is an inline [`Schema`], so those +/// siblings survive the round-trip instead of being discarded. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum SchemaRef { + /// Schema reference (e.g., "#/components/schemas/User") + Ref(Reference), + /// Inline schema + Inline(Box), +} + +impl<'de> Deserialize<'de> for SchemaRef { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(SchemaRefVisitor) + } +} + +struct SchemaRefVisitor; + +impl<'de> serde::de::Visitor<'de> for SchemaRefVisitor { + type Value = SchemaRef; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("an OpenAPI schema reference or inline schema object") + } + + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + let mut schema = Schema::default(); + let mut pure_ref = true; + let mut has_inline_fields = false; + let mut ref_path = None; + let mut type_nullable = None; + let mut nullable = None; + + while let Some(key) = access.next_key::()? { + match key { + SchemaField::RefPath => { + let path = access.next_value::()?; + if pure_ref && ref_path.is_none() && !has_inline_fields { + ref_path = Some(path); + } else { + pure_ref = false; + has_inline_fields = true; + schema.ref_path = Some(path); + } + } + other => { + if let Some(path) = ref_path.take() { + schema.ref_path = Some(path); + } + pure_ref = false; + has_inline_fields = true; + apply_schema_field( + other, + &mut schema, + &mut type_nullable, + &mut nullable, + &mut access, + )?; + } + } + } + + if pure_ref && let Some(path) = ref_path { + return Ok(SchemaRef::Ref(Reference::new(path))); + } + schema.nullable = match type_nullable { + Some(true) => Some(true), + None => nullable, + Some(false) => nullable.or(Some(false)), + }; + Ok(SchemaRef::Inline(Box::new(schema))) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SchemaField { + RefPath, + Type, + Format, + Title, + Description, + Default, + Example, + Examples, + Minimum, + Maximum, + ExclusiveMinimum, + ExclusiveMaximum, + MultipleOf, + MinLength, + MaxLength, + Pattern, + Items, + PrefixItems, + MinItems, + MaxItems, + UniqueItems, + Properties, + Required, + AdditionalProperties, + MinProperties, + MaxProperties, + Enum, + AllOf, + AnyOf, + OneOf, + Not, + Discriminator, + Nullable, + ReadOnly, + WriteOnly, + ExternalDocs, + Defs, + DynamicAnchor, + DynamicRef, + Unknown, +} + +impl<'de> Deserialize<'de> for SchemaField { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct SchemaFieldVisitor; + + impl serde::de::Visitor<'_> for SchemaFieldVisitor { + type Value = SchemaField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON Schema field name") + } + + fn visit_str(self, value: &str) -> Result + where + E: DeError, + { + Ok(match value { + "$ref" => SchemaField::RefPath, + "type" => SchemaField::Type, + "format" => SchemaField::Format, + "title" => SchemaField::Title, + "description" => SchemaField::Description, + "default" => SchemaField::Default, + "example" => SchemaField::Example, + "examples" => SchemaField::Examples, + "minimum" => SchemaField::Minimum, + "maximum" => SchemaField::Maximum, + "exclusiveMinimum" => SchemaField::ExclusiveMinimum, + "exclusiveMaximum" => SchemaField::ExclusiveMaximum, + "multipleOf" => SchemaField::MultipleOf, + "minLength" => SchemaField::MinLength, + "maxLength" => SchemaField::MaxLength, + "pattern" => SchemaField::Pattern, + "items" => SchemaField::Items, + "prefixItems" => SchemaField::PrefixItems, + "minItems" => SchemaField::MinItems, + "maxItems" => SchemaField::MaxItems, + "uniqueItems" => SchemaField::UniqueItems, + "properties" => SchemaField::Properties, + "required" => SchemaField::Required, + "additionalProperties" => SchemaField::AdditionalProperties, + "minProperties" => SchemaField::MinProperties, + "maxProperties" => SchemaField::MaxProperties, + "enum" => SchemaField::Enum, + "allOf" => SchemaField::AllOf, + "anyOf" => SchemaField::AnyOf, + "oneOf" => SchemaField::OneOf, + "not" => SchemaField::Not, + "discriminator" => SchemaField::Discriminator, + "nullable" => SchemaField::Nullable, + "readOnly" => SchemaField::ReadOnly, + "writeOnly" => SchemaField::WriteOnly, + "externalDocs" => SchemaField::ExternalDocs, + "$defs" => SchemaField::Defs, + "$dynamicAnchor" => SchemaField::DynamicAnchor, + "$dynamicRef" => SchemaField::DynamicRef, + _ => SchemaField::Unknown, + }) + } + } + + deserializer.deserialize_identifier(SchemaFieldVisitor) + } +} + +fn apply_schema_field<'de, M>( + field: SchemaField, + schema: &mut Schema, + type_nullable: &mut Option, + nullable: &mut Option, + access: &mut M, +) -> Result<(), M::Error> +where + M: MapAccess<'de>, +{ + match field { + SchemaField::RefPath => schema.ref_path = Some(access.next_value()?), + SchemaField::Type => { + let (schema_type, next_nullable) = access + .next_value::()? + .into_schema_type_and_nullable::()?; + schema.schema_type = schema_type; + *type_nullable = next_nullable; + } + SchemaField::Format => schema.format = Some(access.next_value()?), + SchemaField::Title => schema.title = Some(access.next_value()?), + SchemaField::Description => schema.description = Some(access.next_value()?), + SchemaField::Default => schema.default = Some(access.next_value()?), + SchemaField::Example => schema.example = Some(access.next_value()?), + SchemaField::Examples => schema.examples = Some(access.next_value()?), + SchemaField::Minimum => schema.minimum = Some(access.next_value()?), + SchemaField::Maximum => schema.maximum = Some(access.next_value()?), + SchemaField::ExclusiveMinimum => schema.exclusive_minimum = Some(access.next_value()?), + SchemaField::ExclusiveMaximum => schema.exclusive_maximum = Some(access.next_value()?), + SchemaField::MultipleOf => schema.multiple_of = Some(access.next_value()?), + SchemaField::MinLength => schema.min_length = Some(access.next_value()?), + SchemaField::MaxLength => schema.max_length = Some(access.next_value()?), + SchemaField::Pattern => schema.pattern = Some(access.next_value()?), + SchemaField::Items => schema.items = Some(access.next_value()?), + SchemaField::PrefixItems => schema.prefix_items = Some(access.next_value()?), + SchemaField::MinItems => schema.min_items = Some(access.next_value()?), + SchemaField::MaxItems => schema.max_items = Some(access.next_value()?), + SchemaField::UniqueItems => schema.unique_items = Some(access.next_value()?), + SchemaField::Properties => schema.properties = Some(access.next_value()?), + SchemaField::Required => schema.required = Some(access.next_value()?), + SchemaField::AdditionalProperties => { + schema.additional_properties = Some(access.next_value()?); + } + SchemaField::MinProperties => schema.min_properties = Some(access.next_value()?), + SchemaField::MaxProperties => schema.max_properties = Some(access.next_value()?), + SchemaField::Enum => schema.r#enum = Some(access.next_value()?), + SchemaField::AllOf => schema.all_of = Some(access.next_value()?), + SchemaField::AnyOf => schema.any_of = Some(access.next_value()?), + SchemaField::OneOf => schema.one_of = Some(access.next_value()?), + SchemaField::Not => schema.not = Some(access.next_value()?), + SchemaField::Discriminator => schema.discriminator = Some(access.next_value()?), + SchemaField::Nullable => *nullable = Some(access.next_value()?), + SchemaField::ReadOnly => schema.read_only = Some(access.next_value()?), + SchemaField::WriteOnly => schema.write_only = Some(access.next_value()?), + SchemaField::ExternalDocs => schema.external_docs = Some(access.next_value()?), + SchemaField::Defs => schema.defs = Some(access.next_value()?), + SchemaField::DynamicAnchor => schema.dynamic_anchor = Some(access.next_value()?), + SchemaField::DynamicRef => schema.dynamic_ref = Some(access.next_value()?), + SchemaField::Unknown => { + let _ = access.next_value::()?; + } + } + Ok(()) +} + +/// Reference definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Reference { + /// Reference path (e.g., "#/components/schemas/User") + #[serde(rename = "$ref")] + pub ref_path: String, +} + +impl Reference { + /// Create a new reference + #[must_use] + pub const fn new(ref_path: String) -> Self { + Self { ref_path } + } + + /// Create a component schema reference + #[must_use] + pub fn schema(name: &str) -> Self { + // Build with an exact-capacity push instead of `format!` — same + // string, no formatting machinery and no reallocation. + const PREFIX: &str = "#/components/schemas/"; + let mut ref_path = String::with_capacity(PREFIX.len() + name.len()); + ref_path.push_str(PREFIX); + ref_path.push_str(name); + Self::new(ref_path) + } +} + +/// `additionalProperties` value (JSON Schema / OpenAPI 3.1). +/// +/// Either a boolean (`true`/`false` — allow or forbid extra properties) +/// or a schema that every additional property must satisfy. Untagged, +/// so it serializes to exactly the JSON Schema wire form (a bare +/// `true`/`false` or the schema object / `$ref`) with no wrapper — +/// byte-identical to the previous `serde_json::Value` representation +/// for the values vespera actually emits. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AdditionalProperties { + /// `additionalProperties: true | false`. + Bool(bool), + /// `additionalProperties: `. + Schema(SchemaRef), +} diff --git a/crates/vespera_core/src/schema/serde_impls.rs b/crates/vespera_core/src/schema/serde_impls.rs new file mode 100644 index 00000000..f19ba72c --- /dev/null +++ b/crates/vespera_core/src/schema/serde_impls.rs @@ -0,0 +1,481 @@ +use super::{ + AdditionalProperties, Discriminator, ExternalDocumentation, Schema, SchemaRef, SchemaType, + is_empty_properties, is_empty_required, +}; +use serde::{ + Deserialize, Serialize, + de::Error as DeError, + ser::{SerializeSeq, SerializeStruct}, +}; +use std::collections::BTreeMap; + +/// Serialize `Option` as integer when the value has no fractional part. +/// +/// Ensures OpenAPI JSON uses `0` instead of `0.0` for integer constraints like +/// `minimum`/`maximum`, matching the convention that integer type bounds are integers. +#[cfg(test)] +#[allow(clippy::ref_option)] // serde serialize_with mandates &Option signature +pub(super) fn serialize_number_constraint( + value: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + match value { + Some(v) if v.fract() == 0.0 => { + // Float→int casts saturate in Rust, so an out-of-range + // constraint (e.g. `1e20`) would silently become `i64::MAX` + // and corrupt the generated spec. Emit the integer form + // only when it round-trips exactly back to the original + // value; otherwise keep the `f64` rendering. + #[allow(clippy::cast_possible_truncation)] + let int_val = *v as i64; + // Exact round-trip check is intentional: we emit the integer + // form only when `i64 → f64` reproduces the original bits. + #[allow(clippy::cast_precision_loss, clippy::float_cmp)] + if int_val as f64 == *v { + serializer.serialize_some(&int_val) + } else { + serializer.serialize_some(v) + } + } + Some(v) => serializer.serialize_some(v), + None => serializer.serialize_none(), + } +} + +struct NumberConstraint(f64); + +impl Serialize for NumberConstraint { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if self.0.fract() == 0.0 { + #[allow(clippy::cast_possible_truncation)] + let int_val = self.0 as i64; + #[allow(clippy::cast_precision_loss, clippy::float_cmp)] + if int_val as f64 == self.0 { + return int_val.serialize(serializer); + } + } + self.0.serialize(serializer) + } +} + +struct NullableRefSchema<'a> { + ref_path: &'a str, +} + +impl Serialize for NullableRefSchema<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut out = serializer.serialize_struct("Schema", 1)?; + out.serialize_field("$ref", self.ref_path)?; + out.end() + } +} + +struct NullSchema; + +impl Serialize for NullSchema { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut out = serializer.serialize_struct("Schema", 1)?; + out.serialize_field("type", &SchemaType::Null)?; + out.end() + } +} + +struct NullableRefAnyOf<'a> { + ref_path: &'a str, +} + +impl Serialize for NullableRefAnyOf<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut seq = serializer.serialize_seq(Some(2))?; + seq.serialize_element(&NullableRefSchema { + ref_path: self.ref_path, + })?; + seq.serialize_element(&NullSchema)?; + seq.end() + } +} + +struct ExamplesWithLegacy<'a> { + example: Option<&'a serde_json::Value>, + examples: &'a [serde_json::Value], +} + +impl Serialize for ExamplesWithLegacy<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let len = self.examples.len() + usize::from(self.example.is_some()); + let mut seq = serializer.serialize_seq(Some(len))?; + if let Some(example) = self.example { + seq.serialize_element(example)?; + } + for example in self.examples { + seq.serialize_element(example)?; + } + seq.end() + } +} + +#[derive(Deserialize, Serialize)] +#[serde(untagged)] +pub(super) enum SchemaTypeWire { + Single(SchemaType), + Multiple(Vec), +} + +impl SchemaTypeWire { + pub(super) fn into_schema_type_and_nullable( + self, + ) -> Result<(Option, Option), E> + where + E: DeError, + { + match self { + Self::Single(schema_type) => Ok((Some(schema_type), None)), + Self::Multiple(schema_types) => { + let nullable = schema_types.contains(&SchemaType::Null).then_some(true); + let mut schema_type = None; + for next_type in schema_types + .into_iter() + .filter(|schema_type| *schema_type != SchemaType::Null) + { + if let Some(current_type) = schema_type + && current_type != next_type + { + return Err(E::custom( + "OpenAPI schema `type` arrays with multiple non-null types are not representable; use anyOf/oneOf instead", + )); + } + schema_type = Some(next_type); + } + // `["null"]` (or `["null","null"]`): a null-only `type` array. + // Without this it would yield `(None, Some(true))` and + // re-serialize to `{}` — silently dropping the null constraint. + // Collapse to the equivalent singular `type:"null"` so the + // schema round-trips losslessly. + if schema_type.is_none() && nullable == Some(true) { + return Ok((Some(SchemaType::Null), None)); + } + Ok((schema_type, nullable)) + } + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SchemaDeserialize { + #[serde(rename = "$ref")] + ref_path: Option, + #[serde(rename = "type")] + schema_type: Option, + format: Option, + title: Option, + description: Option, + default: Option, + example: Option, + examples: Option>, + minimum: Option, + maximum: Option, + exclusive_minimum: Option, + exclusive_maximum: Option, + multiple_of: Option, + min_length: Option, + max_length: Option, + pattern: Option, + items: Option, + prefix_items: Option>, + min_items: Option, + max_items: Option, + unique_items: Option, + properties: Option>, + required: Option>, + additional_properties: Option, + min_properties: Option, + max_properties: Option, + r#enum: Option>, + all_of: Option>, + any_of: Option>, + one_of: Option>, + not: Option, + discriminator: Option, + nullable: Option, + read_only: Option, + write_only: Option, + external_docs: Option, + #[serde(rename = "$defs")] + defs: Option>, + #[serde(rename = "$dynamicAnchor")] + dynamic_anchor: Option, + #[serde(rename = "$dynamicRef")] + dynamic_ref: Option, +} + +impl<'de> Deserialize<'de> for Schema { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = SchemaDeserialize::deserialize(deserializer)?; + let (schema_type, type_nullable) = wire.schema_type.map_or(Ok((None, None)), |wire| { + wire.into_schema_type_and_nullable::() + })?; + let nullable = match type_nullable { + Some(true) => Some(true), + None => wire.nullable, + Some(false) => wire.nullable.or(Some(false)), + }; + Ok(Self { + ref_path: wire.ref_path, + schema_type, + format: wire.format, + title: wire.title, + description: wire.description, + default: wire.default, + example: wire.example, + examples: wire.examples, + minimum: wire.minimum, + maximum: wire.maximum, + exclusive_minimum: wire.exclusive_minimum, + exclusive_maximum: wire.exclusive_maximum, + multiple_of: wire.multiple_of, + min_length: wire.min_length, + max_length: wire.max_length, + pattern: wire.pattern, + items: wire.items, + prefix_items: wire.prefix_items, + min_items: wire.min_items, + max_items: wire.max_items, + unique_items: wire.unique_items, + properties: wire.properties, + required: wire.required, + additional_properties: wire.additional_properties, + min_properties: wire.min_properties, + max_properties: wire.max_properties, + r#enum: wire.r#enum, + all_of: wire.all_of, + any_of: wire.any_of, + one_of: wire.one_of, + not: wire.not, + discriminator: wire.discriminator, + nullable, + read_only: wire.read_only, + write_only: wire.write_only, + external_docs: wire.external_docs, + defs: wire.defs, + dynamic_anchor: wire.dynamic_anchor, + dynamic_ref: wire.dynamic_ref, + }) + } +} + +/// Borrowing serializer for a nullable scalar `type` array (`[T, "null"]`). +/// +/// Avoids the temporary two-element `Vec` the +/// `SchemaTypeWire::Multiple(vec![t, Null])` path allocated on **every** +/// nullable non-`$ref` schema during OpenAPI generation. Emits the identical +/// JSON array (`SchemaTypeWire` is `#[serde(untagged)]`, so `Multiple(vec)` +/// renders as a bare array), so the wire bytes are unchanged — mirrors the +/// existing zero-allocation [`NullableRefAnyOf`] serializer. +struct NullableScalarType(SchemaType); + +impl Serialize for NullableScalarType { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(2))?; + seq.serialize_element(&self.0)?; + seq.serialize_element(&SchemaType::Null)?; + seq.end() + } +} + +impl Serialize for Schema { + #[allow(clippy::too_many_lines)] + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let nullable_ref = self.nullable == Some(true) && self.ref_path.is_some(); + if nullable_ref && self.any_of.is_some() { + return Err(serde::ser::Error::custom( + "invalid Schema: nullable `$ref` serializes through anyOf and cannot also carry explicit any_of", + )); + } + // A nullable `$ref` is emitted as `anyOf: [{$ref}, {type:null}]`; a + // sibling `type` would then describe the SAME node twice and produce + // ambiguous/invalid output (`anyOf` AND `type` at one level). Vespera's + // own `Schema::nullable_reference` always leaves `schema_type` None, so + // this only fires for a hand-built `Schema` that mixed the two — reject + // it like the `any_of` case above instead of serializing broken OpenAPI. + if nullable_ref && self.schema_type.is_some() { + return Err(serde::ser::Error::custom( + "invalid Schema: nullable `$ref` serializes through anyOf and cannot also carry an explicit type; build it via Schema::nullable_reference", + )); + } + let mut out = serializer.serialize_struct("Schema", 42)?; + if let Some(ref_path) = &self.ref_path { + if nullable_ref { + out.serialize_field("anyOf", &NullableRefAnyOf { ref_path })?; + } else { + out.serialize_field("$ref", ref_path)?; + } + } + if let Some(schema_type) = self.schema_type { + // Nullable scalar → `[T, "null"]` via the borrowing + // `NullableScalarType` (no temporary `Vec`); plain scalar → `T` + // directly (`SchemaTypeWire::Single` is untagged, so a bare + // `SchemaType` is byte-identical). Both avoid the previous + // per-schema `SchemaTypeWire` value. + if self.nullable == Some(true) { + out.serialize_field("type", &NullableScalarType(schema_type))?; + } else { + out.serialize_field("type", &schema_type)?; + } + } + if let Some(value) = &self.format { + out.serialize_field("format", value)?; + } + if let Some(value) = &self.title { + out.serialize_field("title", value)?; + } + if let Some(value) = &self.description { + out.serialize_field("description", value)?; + } + if let Some(value) = &self.default { + out.serialize_field("default", value)?; + } + match (&self.example, &self.examples) { + (Some(example), Some(examples)) => { + out.serialize_field( + "examples", + &ExamplesWithLegacy { + example: Some(example), + examples, + }, + )?; + } + (Some(example), None) => { + out.serialize_field( + "examples", + &ExamplesWithLegacy { + example: Some(example), + examples: &[], + }, + )?; + } + (None, Some(examples)) => { + out.serialize_field("examples", examples)?; + } + (None, None) => {} + } + if let Some(value) = self.minimum { + out.serialize_field("minimum", &NumberConstraint(value))?; + } + if let Some(value) = self.maximum { + out.serialize_field("maximum", &NumberConstraint(value))?; + } + if let Some(value) = self.exclusive_minimum { + out.serialize_field("exclusiveMinimum", &NumberConstraint(value))?; + } + if let Some(value) = self.exclusive_maximum { + out.serialize_field("exclusiveMaximum", &NumberConstraint(value))?; + } + if let Some(value) = self.multiple_of { + out.serialize_field("multipleOf", &NumberConstraint(value))?; + } + if let Some(value) = self.min_length { + out.serialize_field("minLength", &value)?; + } + if let Some(value) = self.max_length { + out.serialize_field("maxLength", &value)?; + } + if let Some(value) = &self.pattern { + out.serialize_field("pattern", value)?; + } + if let Some(value) = &self.items { + out.serialize_field("items", value)?; + } + if let Some(value) = &self.prefix_items { + out.serialize_field("prefixItems", value)?; + } + if let Some(value) = self.min_items { + out.serialize_field("minItems", &value)?; + } + if let Some(value) = self.max_items { + out.serialize_field("maxItems", &value)?; + } + if let Some(value) = self.unique_items { + out.serialize_field("uniqueItems", &value)?; + } + if !is_empty_properties(&self.properties) { + out.serialize_field("properties", &self.properties)?; + } + if !is_empty_required(&self.required) { + out.serialize_field("required", &self.required)?; + } + if let Some(value) = &self.additional_properties { + out.serialize_field("additionalProperties", value)?; + } + if let Some(value) = self.min_properties { + out.serialize_field("minProperties", &value)?; + } + if let Some(value) = self.max_properties { + out.serialize_field("maxProperties", &value)?; + } + if let Some(value) = &self.r#enum { + out.serialize_field("enum", value)?; + } + if let Some(value) = &self.all_of { + out.serialize_field("allOf", value)?; + } + if let Some(value) = &self.any_of + && !nullable_ref + { + out.serialize_field("anyOf", value)?; + } + if let Some(value) = &self.one_of { + out.serialize_field("oneOf", value)?; + } + if let Some(value) = &self.not { + out.serialize_field("not", value)?; + } + if let Some(value) = &self.discriminator { + out.serialize_field("discriminator", value)?; + } + if let Some(value) = self.read_only { + out.serialize_field("readOnly", &value)?; + } + if let Some(value) = self.write_only { + out.serialize_field("writeOnly", &value)?; + } + if let Some(value) = &self.external_docs { + out.serialize_field("externalDocs", value)?; + } + if let Some(value) = &self.defs { + out.serialize_field("$defs", value)?; + } + if let Some(value) = &self.dynamic_anchor { + out.serialize_field("$dynamicAnchor", value)?; + } + if let Some(value) = &self.dynamic_ref { + out.serialize_field("$dynamicRef", value)?; + } + out.end() + } +} diff --git a/crates/vespera_core/src/schema/tests.rs b/crates/vespera_core/src/schema/tests.rs new file mode 100644 index 00000000..891c47ee --- /dev/null +++ b/crates/vespera_core/src/schema/tests.rs @@ -0,0 +1,510 @@ +use super::*; +use rstest::rstest; + +#[rstest] +#[case(Schema::string(), SchemaType::String)] +#[case(Schema::integer(), SchemaType::Integer)] +#[case(Schema::number(), SchemaType::Number)] +#[case(Schema::boolean(), SchemaType::Boolean)] +fn primitive_helpers_set_schema_type(#[case] schema: Schema, #[case] expected: SchemaType) { + assert_eq!(schema.schema_type, Some(expected)); +} + +#[test] +fn array_helper_sets_type_and_items() { + let item_schema = Schema::boolean(); + let schema = Schema::array(SchemaRef::Inline(Box::new(item_schema.clone()))); + + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + let items = schema.items.expect("items should be set"); + match items { + SchemaRef::Inline(inner) => { + assert_eq!(inner.schema_type, Some(SchemaType::Boolean)); + } + SchemaRef::Ref(_) => panic!("array helper should set inline items"), + } +} + +#[test] +fn object_helper_initializes_collections() { + let schema = Schema::object(); + + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + let props = schema.properties.expect("properties should be initialized"); + assert!(props.is_empty()); + let required = schema.required.expect("required should be initialized"); + assert!(required.is_empty()); +} + +#[test] +fn object_empty_helper_avoids_empty_collection_allocations() { + let schema = Schema::object_empty(); + + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + assert!(schema.properties.is_none()); + assert!(schema.required.is_none()); + assert_eq!( + serde_json::to_string(&schema).unwrap(), + r#"{"type":"object"}"# + ); +} + +#[test] +fn serialize_number_constraint_none_serializes_null() { + // Direct call bypasses skip_serializing_if to cover the None branch + let result = super::serialize_number_constraint(&None, serde_json::value::Serializer).unwrap(); + assert_eq!(result, serde_json::Value::Null); +} + +#[test] +fn serialize_minimum_whole_number_as_integer() { + let schema = Schema { + minimum: Some(0.0), + ..Schema::integer() + }; + let json = serde_json::to_string(&schema).unwrap(); + // Must be "minimum":0 (integer), NOT "minimum":0.0 + assert!( + json.contains("\"minimum\":0"), + "expected integer 0, got: {json}" + ); + assert!( + !json.contains("\"minimum\":0.0"), + "must not contain 0.0: {json}" + ); +} + +#[test] +fn serialize_minimum_fractional_as_float() { + let schema = Schema { + minimum: Some(1.5), + ..Schema::number() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert!( + json.contains("\"minimum\":1.5"), + "expected 1.5, got: {json}" + ); +} + +#[test] +fn serialize_minimum_none_omitted() { + let schema = Schema::integer(); + let json = serde_json::to_string(&schema).unwrap(); + assert!( + !json.contains("minimum"), + "None minimum should be omitted: {json}" + ); +} + +#[test] +fn schema_level_example_serializes_as_examples_array() { + let schema = Schema { + example: Some(serde_json::json!("abc")), + examples: Some(vec![serde_json::json!("def")]), + ..Schema::string() + }; + + let value: serde_json::Value = serde_json::to_value(schema).unwrap(); + + assert!(value.get("example").is_none()); + assert_eq!(value["examples"], serde_json::json!(["abc", "def"])); +} + +#[test] +fn schema_level_example_and_examples_serialization_is_byte_identical() { + let schema = Schema { + example: Some(serde_json::json!("abc")), + examples: Some(vec![serde_json::json!("def")]), + ..Schema::string() + }; + + let json = serde_json::to_string(&schema).unwrap(); + + assert_eq!(json, r#"{"type":"string","examples":["abc","def"]}"#); +} + +#[test] +fn schema_level_legacy_example_deserializes_for_round_trip_compatibility() { + let schema: Schema = serde_json::from_value(serde_json::json!({ + "type": "string", + "example": "legacy" + })) + .unwrap(); + + assert_eq!(schema.example, Some(serde_json::json!("legacy"))); +} + +#[test] +fn serialize_maximum_whole_number_as_integer() { + let schema = Schema { + maximum: Some(100.0), + ..Schema::integer() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert!( + json.contains("\"maximum\":100"), + "expected integer 100, got: {json}" + ); + assert!( + !json.contains("\"maximum\":100.0"), + "must not contain 100.0: {json}" + ); +} + +#[test] +fn serialize_out_of_i64_range_constraint_stays_float() { + // A whole-number constraint beyond i64 range must NOT saturate to + // i64::MAX — it stays a float so the spec keeps the real value. + let schema = Schema { + maximum: Some(1e20), + ..Schema::number() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert!( + !json.contains(&i64::MAX.to_string()), + "must not saturate to i64::MAX: {json}" + ); + // Parse back: the constraint value must be preserved exactly, + // regardless of serde's float formatting. + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + parsed["maximum"].as_f64(), + Some(1e20), + "constraint value must be preserved: {json}" + ); +} + +#[test] +fn serialize_multiple_of_whole_number_as_integer() { + let schema = Schema { + multiple_of: Some(2.0), + ..Schema::integer() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert!( + json.contains("\"multipleOf\":2"), + "expected integer 2, got: {json}" + ); + assert!( + !json.contains("\"multipleOf\":2.0"), + "must not contain 2.0: {json}" + ); +} + +// ── CORE: OpenAPI 3.1 conformance of the schema model ──────────── + +#[test] +fn oauth2_security_scheme_serializes_to_canonical_lowercase() { + // OpenAPI's canonical wire name is `oauth2`. serde's `camelCase` + // container rule lowercases only the leading char, which would emit + // the invalid `oAuth2` without the explicit `#[serde(rename)]`. + let json = serde_json::to_string(&SecuritySchemeType::OAuth2).unwrap(); + assert_eq!(json, "\"oauth2\"", "must be exactly \"oauth2\""); +} + +#[rstest] +#[case(SecuritySchemeType::ApiKey, "\"apiKey\"")] +#[case(SecuritySchemeType::Http, "\"http\"")] +#[case(SecuritySchemeType::MutualTls, "\"mutualTLS\"")] +#[case(SecuritySchemeType::OAuth2, "\"oauth2\"")] +#[case(SecuritySchemeType::OpenIdConnect, "\"openIdConnect\"")] +fn security_scheme_type_uses_openapi_canonical_wire_names( + #[case] ty: SecuritySchemeType, + #[case] expected: &str, +) { + assert_eq!(serde_json::to_string(&ty).unwrap(), expected); +} + +#[test] +#[should_panic(expected = "from_compiled_json failed to parse")] +fn from_compiled_json_invalid_input_trips_debug_assert() { + // In debug / test builds the (in-practice-unreachable) macro/serde + // drift guard fires loudly so a bug never goes unnoticed in CI. + let _ = Schema::from_compiled_json("{not valid json"); +} + +#[test] +fn compiled_json_parse_failure_sentinel_is_machine_detectable() { + let error = serde_json::from_str::("{not valid json").unwrap_err(); + let schema = schema_parse_failure_sentinel(&error); + + assert_eq!(schema.title.as_deref(), Some("VESPERA_SCHEMA_PARSE_ERROR")); + assert!( + schema + .description + .as_deref() + .is_some_and(|description| description.contains("macro/serde drift")), + "sentinel description should identify macro/serde drift: {schema:#?}", + ); +} + +// ── CORE-04: typed `additionalProperties` (untagged) ───────────── +// +// The untagged enum MUST serialize to the bare JSON Schema wire form +// (a `true`/`false` or the schema object/`$ref`) — byte-identical to +// the previous `serde_json::Value` representation — and round-trip +// back to the right variant. Untagged deserialization is +// order-sensitive, so these lock the contract. + +#[test] +fn additional_properties_bool_serializes_bare() { + let schema = Schema { + additional_properties: Some(AdditionalProperties::Bool(false)), + ..Schema::object_empty() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert!( + json.contains("\"additionalProperties\":false"), + "bool must serialize as a bare boolean, got: {json}" + ); +} + +#[test] +fn additional_properties_schema_ref_serializes_as_ref() { + let schema = Schema { + additional_properties: Some(AdditionalProperties::Schema(SchemaRef::Ref( + Reference::schema("User"), + ))), + ..Schema::object_empty() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert!( + json.contains("\"additionalProperties\":{\"$ref\":\"#/components/schemas/User\"}"), + "schema-ref must serialize as a bare $ref object, got: {json}" + ); +} + +#[test] +fn additional_properties_roundtrips_each_variant() { + // bool → Bool + let v: AdditionalProperties = serde_json::from_str("true").unwrap(); + assert!(matches!(v, AdditionalProperties::Bool(true))); + // {"$ref":...} → Schema(Ref) + let v: AdditionalProperties = + serde_json::from_str(r##"{"$ref":"#/components/schemas/X"}"##).unwrap(); + assert!(matches!(v, AdditionalProperties::Schema(SchemaRef::Ref(_)))); + // inline schema object → Schema(Inline) + let v: AdditionalProperties = serde_json::from_str(r#"{"type":"string"}"#).unwrap(); + assert!(matches!( + v, + AdditionalProperties::Schema(SchemaRef::Inline(_)) + )); +} + +// ── CORE-03: nullable-reference constructor ────────────────────── + +#[test] +fn nullable_reference_emits_anyof_ref_and_null_only() { + let schema = Schema::nullable_reference("#/components/schemas/User".to_owned()); + let json = serde_json::to_string(&schema).unwrap(); + assert!( + json.contains("\"anyOf\":[{\"$ref\":\"#/components/schemas/User\"},{\"type\":\"null\"}]"), + "nullable ref must be anyOf(ref, null): {json}" + ); + assert!( + !json.contains("\"nullable\""), + "OpenAPI 3.1 must not emit nullable: {json}" + ); + // schema_type stays None so no top-level `"type"` is emitted alongside. + assert!( + !json.starts_with("{\"type\":"), + "a nullable reference must not also emit a top-level type: {json}" + ); +} + +#[test] +fn nullable_reference_serialization_is_byte_identical() { + let schema = Schema::nullable_reference("#/components/schemas/User".to_owned()); + + let json = serde_json::to_string(&schema).unwrap(); + + assert_eq!( + json, + r##"{"anyOf":[{"$ref":"#/components/schemas/User"},{"type":"null"}]}"## + ); +} + +#[test] +fn nullable_reference_with_explicit_any_of_returns_clean_serialization_error() { + let schema = Schema { + any_of: Some(vec![SchemaRef::Inline(Box::new(Schema::string()))]), + ..Schema::nullable_reference("#/components/schemas/User".to_owned()) + }; + + let err = serde_json::to_string(&schema).unwrap_err(); + + assert!( + err.to_string() + .contains("cannot also carry explicit any_of"), + "unexpected error: {err}", + ); +} + +#[test] +fn nullable_reference_with_explicit_type_returns_clean_serialization_error() { + // A hand-built nullable `$ref` that ALSO carries a `schema_type` would + // serialize both `anyOf` and a sibling `type` — ambiguous/invalid OpenAPI. + // It must fail with a clean serialization error like the `any_of` case, + // not silently emit the broken shape. (Vespera's own `nullable_reference` + // leaves `schema_type` None, so this only guards external manual construction.) + let schema = Schema { + schema_type: Some(SchemaType::Object), + ..Schema::nullable_reference("#/components/schemas/User".to_owned()) + }; + + let err = serde_json::to_string(&schema).unwrap_err(); + + assert!( + err.to_string() + .contains("cannot also carry an explicit type"), + "unexpected error: {err}", + ); +} + +#[test] +fn nullable_primitive_emits_type_array_with_null() { + let schema = Schema { + nullable: Some(true), + ..Schema::string() + }; + let json = serde_json::to_string(&schema).unwrap(); + assert_eq!(json, r#"{"type":["string","null"]}"#); +} + +#[test] +fn nullable_primitive_type_array_deserializes() { + let schema: Schema = serde_json::from_str(r#"{"type":["integer","null"]}"#).unwrap(); + assert_eq!(schema.schema_type, Some(SchemaType::Integer)); + assert_eq!(schema.nullable, Some(true)); +} + +#[test] +fn duplicate_single_type_array_deserializes_without_loss() { + let schema: Schema = serde_json::from_str(r#"{"type":["integer","integer","null"]}"#).unwrap(); + + assert_eq!(schema.schema_type, Some(SchemaType::Integer)); + assert_eq!(schema.nullable, Some(true)); +} + +#[test] +fn null_only_type_array_round_trips_to_singular_null() { + // Regression: `{"type":["null"]}` previously deserialized to + // (schema_type=None, nullable=Some(true)) and re-serialized to `{}`, + // silently dropping the null constraint. It must collapse to the + // equivalent singular `type:"null"` and round-trip losslessly. + let schema: Schema = serde_json::from_str(r#"{"type":["null"]}"#).unwrap(); + assert_eq!(schema.schema_type, Some(SchemaType::Null)); + assert_eq!(schema.nullable, None); + + let json = serde_json::to_string(&schema).unwrap(); + assert_eq!(json, r#"{"type":"null"}"#); +} + +#[test] +fn repeated_null_only_type_array_round_trips_to_singular_null() { + let schema: Schema = serde_json::from_str(r#"{"type":["null","null"]}"#).unwrap(); + assert_eq!(schema.schema_type, Some(SchemaType::Null)); + assert_eq!(schema.nullable, None); + + let json = serde_json::to_string(&schema).unwrap(); + assert_eq!(json, r#"{"type":"null"}"#); +} + +#[test] +fn multi_type_array_with_null_is_rejected_instead_of_lossy_collapsing() { + let err = + serde_json::from_str::(r#"{"type":["string","integer","null"]}"#).unwrap_err(); + + assert!( + err.to_string().contains("multiple non-null types"), + "unexpected error: {err}", + ); +} + +#[test] +fn multi_type_array_without_null_is_rejected_instead_of_lossy_collapsing() { + let err = serde_json::from_str::(r#"{"type":["integer","string"]}"#).unwrap_err(); + + assert!( + err.to_string().contains("multiple non-null types"), + "unexpected error: {err}", + ); +} + +#[test] +fn type_array_nullability_wins_over_nullable_false_sibling() { + let schema: Schema = + serde_json::from_str(r#"{"type":["string","null"],"nullable":false}"#).unwrap(); + + assert_eq!(schema.schema_type, Some(SchemaType::String)); + assert_eq!(schema.nullable, Some(true)); +} + +#[test] +fn primitive_schema_serialize_contract_stays_byte_identical() { + assert_eq!( + serde_json::to_string(&Schema::string()).unwrap(), + r#"{"type":"string"}"# + ); +} + +// ── SchemaRef: $ref-sibling preservation ───────────────────────── +// +// The prior `#[serde(untagged)]` `Ref`-first enum greedily matched +// ANY object with a `$ref` key and silently dropped its siblings +// (e.g. a nullable reference's `"nullable": true`). The custom +// `Deserialize` treats only a *pure* `{"$ref": }` as a +// reference; a `$ref` with any sibling becomes an inline `Schema` +// so the siblings round-trip intact. + +#[test] +fn schema_ref_pure_ref_deserializes_as_ref() { + let v: SchemaRef = serde_json::from_str(r##"{"$ref":"#/components/schemas/User"}"##).unwrap(); + match v { + SchemaRef::Ref(r) => assert_eq!(r.ref_path, "#/components/schemas/User"), + SchemaRef::Inline(_) => panic!("a pure $ref must deserialize as SchemaRef::Ref"), + } +} + +#[test] +fn schema_ref_with_nullable_sibling_preserves_fields() { + let v: SchemaRef = + serde_json::from_str(r##"{"$ref":"#/components/schemas/User","nullable":true}"##).unwrap(); + match v { + SchemaRef::Inline(schema) => { + assert_eq!( + schema.ref_path.as_deref(), + Some("#/components/schemas/User"), + "the $ref must survive as an inline ref_path" + ); + assert_eq!( + schema.nullable, + Some(true), + "the nullable sibling must not be dropped" + ); + } + SchemaRef::Ref(_) => panic!("$ref with a sibling must not be matched as a bare Ref"), + } +} + +#[test] +fn schema_ref_inline_object_deserializes_as_inline() { + let v: SchemaRef = serde_json::from_str(r#"{"type":"string"}"#).unwrap(); + assert!(matches!(v, SchemaRef::Inline(_))); +} + +#[test] +fn schema_ref_nullable_reference_roundtrips() { + // Build → serialize → deserialize must keep 3.1 nullable semantics. + let original = Schema::nullable_reference("#/components/schemas/User".to_owned()); + let json = serde_json::to_string(&SchemaRef::Inline(Box::new(original))).unwrap(); + let back: SchemaRef = serde_json::from_str(&json).unwrap(); + match back { + SchemaRef::Inline(s) => { + assert!(s.ref_path.is_none()); + assert_eq!(s.any_of.as_ref().map(Vec::len), Some(2)); + } + SchemaRef::Ref(_) => panic!("a nullable reference must round-trip as inline"), + } +} diff --git a/crates/vespera_inprocess/Cargo.toml b/crates/vespera_inprocess/Cargo.toml index e6999cff..03ee6137 100644 --- a/crates/vespera_inprocess/Cargo.toml +++ b/crates/vespera_inprocess/Cargo.toml @@ -6,7 +6,23 @@ description = "In-process HTTP dispatch for axum — drive a Router via oneshot license.workspace = true repository.workspace = true +[features] +# Compiles the criterion A/B "before" twins (serde-based wire header +# parse/serialize, the `http::request::Builder` request build, the +# `serde_json::Value` 422 hoist) and the `bench_support` surface that +# `benches/dispatch.rs` calls. OFF by default so production builds (and +# the shipped JNI cdylib) never compile the serde wire-header scaffolding +# that exists only to benchmark the hand-rolled production path against. +# Enable with `cargo bench -p vespera_inprocess --features bench-support`. +bench-support = [] + [dependencies] +# Lock-free read snapshot for the multi-app router registry: named-app +# dispatch (every request in a multi-app JNI deployment) resolves with a +# single atomic load instead of an `RwLock` read acquisition, matching +# the lock-free fast path the default app already enjoys. Registration +# (startup-only, first-wins) goes through copy-on-write `rcu`. +arc-swap = "1" axum = "0.8" bytes = "1" http = "1" @@ -19,7 +35,20 @@ tokio = { version = "1", features = ["rt"] } [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } +# The criterion bench runs under mimalloc (set as its `#[global_allocator]` +# in benches/dispatch.rs) to match the SHIPPED JNI cdylib, which enables +# mimalloc by default. Measured 2026-06: the default Windows system heap +# routes per-request `Vec` allocations >= ~1 MiB through a slow +# VirtualAlloc commit/decommit path (e.g. 1 MiB `dispatch_from_bytes` +# materialise = 311 us system-heap vs 30 us mimalloc — a ~10x cliff that is +# pure harness artifact, never seen by the cdylib). Benching under mimalloc +# keeps the large-body absolute numbers representative of production. +mimalloc = "0.1" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +# `FutureExt::catch_unwind` for the `async_spawn_pattern` bench, which +# A/Bs the vespera_jni `dispatchAsync` spawn-mechanism change (inner +# `tokio::spawn` vs in-place `catch_unwind`). +futures-util = { version = "0.3", default-features = false, features = ["std"] } [[bench]] name = "dispatch" diff --git a/crates/vespera_inprocess/benches/dispatch.rs b/crates/vespera_inprocess/benches/dispatch.rs index b6e63bf0..00f9380e 100644 --- a/crates/vespera_inprocess/benches/dispatch.rs +++ b/crates/vespera_inprocess/benches/dispatch.rs @@ -1,6 +1,6 @@ //! Criterion benchmarks for the in-process dispatch surface. //! -//! Three groups: +//! Five groups: //! //! - `router_path`: `Router::clone()` of a pre-built router (post-P1) //! vs rebuilding the router from a factory closure (pre-P1, simulated). @@ -8,24 +8,49 @@ //! vs `dispatch_typed(router, &env)` which clones internally (pre-P2). //! - `wire_path`: end-to-end `dispatch_from_bytes` — wire-format //! round-trip including header JSON parse + body byte handling. +//! - `headers_path`: `dispatch_from_bytes` against a route that sets +//! many response headers (incl. multi-value `set-cookie`) — +//! isolates `collect_header_map` + wire header serialisation cost. +//! - `streaming_path`: `dispatch_streaming_async` (response +//! streaming) and `dispatch_bidirectional_streaming` (request + +//! response streaming through the mpsc channel + spawn_blocking +//! producer) — gates the chunk-size / channel-capacity work. Also +//! includes a no-body-poll route to isolate lazy request-pull setup. //! //! Scaling axes: //! - `route_count`: 10 / 100 / 500 routes (Router-build dominance). //! - `body_kb`: 1 / 64 / 1024 KB request bodies (body-clone dominance). use std::collections::HashMap; +use std::ops::ControlFlow; +use std::panic::AssertUnwindSafe; +use std::sync::Mutex; use axum::{ Json, Router, + http::{HeaderMap, HeaderName}, + response::{IntoResponse, Response}, routing::{get, post}, }; use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use futures_util::FutureExt; use serde::{Deserialize, Serialize}; use tokio::runtime::Runtime; use vespera_inprocess::{ - RequestEnvelope, dispatch_from_bytes, dispatch_owned, dispatch_typed, register_app, + DirectWriteResult, RequestChunk, RequestEnvelope, dispatch_bidirectional_streaming, + dispatch_from_bytes, dispatch_into, dispatch_into_async_borrowed, dispatch_owned, + dispatch_streaming_async, dispatch_typed, register_app, }; +// Bench under mimalloc to match the shipped JNI cdylib (which enables mimalloc +// by default). Without this, the default Windows system heap routes the +// per-request `Vec` allocations these benches stress (input `wire.clone()`, +// response materialisation) through a slow VirtualAlloc commit/decommit path +// for blocks >= ~1 MiB, producing a ~10x large-body "cliff" that no shipped +// build ever pays. See the `mimalloc` dev-dependency note in Cargo.toml. +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + // ── Test fixtures ──────────────────────────────────────────────────── #[derive(Serialize, Deserialize)] @@ -41,10 +66,56 @@ async fn handler_echo(Json(payload): Json) -> Json { Json(payload) } +/// Echo raw request-body bytes back — used by the streaming benches +/// so request chunks flow through the handler unchanged. +async fn handler_echo_bytes(body: bytes::Bytes) -> bytes::Bytes { + body +} + +/// Return without polling the request body. This isolates the cost of +/// bidirectional request-pull setup for handlers that do not need the +/// body at all. +async fn handler_discard_body() -> &'static str { + "ok" +} + +/// Respond with a realistic header set: 10 single-value headers plus +/// a 3-value `set-cookie` — exercises `collect_header_map`'s Vacant +/// and Occupied paths and the wire header JSON serialisation. +async fn handler_many_headers() -> Response { + let mut headers = HeaderMap::new(); + for (name, value) in [ + ("cache-control", "no-store"), + ("etag", "\"abc123def456\""), + ("vary", "accept-encoding"), + ("x-content-type-options", "nosniff"), + ("x-frame-options", "DENY"), + ("x-request-id", "01HV2N3M4P5Q6R7S8T9V0W1X2Y"), + ("x-trace-id", "4bf92f3577b34da6a3ce929d0e0e4736"), + ("access-control-allow-origin", "*"), + ("strict-transport-security", "max-age=63072000"), + ("content-language", "en"), + ] { + headers.insert( + HeaderName::from_static(name), + value.parse().expect("static header value"), + ); + } + let cookie = HeaderName::from_static("set-cookie"); + headers.append(cookie.clone(), "session=s1; HttpOnly".parse().unwrap()); + headers.append(cookie.clone(), "theme=dark; Path=/".parse().unwrap()); + headers.append(cookie, "lang=en; Path=/".parse().unwrap()); + (headers, "ok").into_response() +} + /// Build a router with `n_routes` distinct GET endpoints plus one /// `POST /echo` that echoes the request body. fn build_router(n_routes: usize) -> Router { - let mut router = Router::new().route("/echo", post(handler_echo)); + let mut router = Router::new() + .route("/echo", post(handler_echo)) + .route("/echo/bytes", post(handler_echo_bytes)) + .route("/discard", post(handler_discard_body)) + .route("/headers", get(handler_many_headers)); for i in 0..n_routes { let path = format!("/r{i}"); router = router.route(&path, get(handler_get)); @@ -66,28 +137,90 @@ fn make_envelope(body_kb: usize) -> RequestEnvelope { } } -/// Wire-format request payload for the `dispatch_from_bytes` bench. -fn make_wire_request(body_kb: usize) -> Vec { - let body_str = serde_json::to_string(&Echo { - body: "x".repeat(body_kb * 1024), - }) - .unwrap(); +/// Assemble `[u32 BE header_len | header JSON | body]` wire bytes. +fn assemble_wire(method: &str, path: &str, content_type: Option<&str>, body: &[u8]) -> Vec { + assemble_wire_for_app(method, path, content_type, None, body) +} + +/// `assemble_wire` with an optional `"app"` wire-header field. +fn assemble_wire_for_app( + method: &str, + path: &str, + content_type: Option<&str>, + app: Option<&str>, + body: &[u8], +) -> Vec { + let mut header = content_type.map_or_else( + || serde_json::json!({ "v": 1, "method": method, "path": path }), + |ct| { + serde_json::json!({ + "v": 1, + "method": method, + "path": path, + "headers": {"content-type": ct}, + }) + }, + ); + if let Some(app) = app { + header["app"] = serde_json::Value::String(app.to_owned()); + } + let header_bytes = serde_json::to_vec(&header).unwrap(); + let header_len = u32::try_from(header_bytes.len()).unwrap(); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + wire +} + +/// `assemble_wire` with an arbitrary request-header set (used by the +/// request-header-scan bench — the real-world multi-header shape the +/// single-header `assemble_wire` cannot express). +fn assemble_wire_with_headers( + method: &str, + path: &str, + headers: &[(&str, &str)], + body: &[u8], +) -> Vec { + let header_map: serde_json::Map = headers + .iter() + .map(|(k, v)| ((*k).to_owned(), serde_json::Value::String((*v).to_owned()))) + .collect(); let header = serde_json::json!({ "v": 1, - "method": "POST", - "path": "/echo", - "headers": {"content-type": "application/json"}, + "method": method, + "path": path, + "headers": header_map, }); let header_bytes = serde_json::to_vec(&header).unwrap(); let header_len = u32::try_from(header_bytes.len()).unwrap(); - let body_bytes = body_str.as_bytes(); - let mut wire = Vec::with_capacity(4 + header_bytes.len() + body_bytes.len()); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); wire.extend_from_slice(&header_len.to_be_bytes()); wire.extend_from_slice(&header_bytes); - wire.extend_from_slice(body_bytes); + wire.extend_from_slice(body); wire } +/// Wire-format request payload for the `dispatch_from_bytes` bench. +fn make_wire_request(body_kb: usize) -> Vec { + let body_str = serde_json::to_string(&Echo { + body: "x".repeat(body_kb * 1024), + }) + .unwrap(); + assemble_wire( + "POST", + "/echo", + Some("application/json"), + body_str.as_bytes(), + ) +} + +/// Register the shared bench app exactly once per process. +fn install_bench_app() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| register_app(|| build_router(100))); +} + // ── Benchmarks ─────────────────────────────────────────────────────── /// P1 isolation: cached `Router::clone()` vs factory rebuild per call. @@ -160,8 +293,7 @@ fn bench_dispatch_path(c: &mut Criterion) { /// response bytes via the registered app. Measures the realistic FFI /// cost the JNI bridge pays. fn bench_wire_path(c: &mut Criterion) { - static INIT: std::sync::Once = std::sync::Once::new(); - INIT.call_once(|| register_app(|| build_router(100))); + install_bench_app(); let runtime = Runtime::new().expect("tokio runtime"); let mut group = c.benchmark_group("wire_path"); @@ -183,10 +315,729 @@ fn bench_wire_path(c: &mut Criterion) { drop(runtime); } +/// Raw-byte isolation: `dispatch_from_bytes` against `/echo/bytes`, +/// which echoes the request body unchanged. Comparing this group with +/// `wire_path` (JSON `/echo`) isolates the `serde_json` +/// deserialize+reserialize cost from vespera's pure dispatch/copy +/// overhead at identical body sizes. +fn bench_bytes_path(c: &mut Criterion) { + install_bench_app(); + + let runtime = Runtime::new().expect("tokio runtime"); + let mut group = c.benchmark_group("bytes_path"); + + for &body_kb in &[1_usize, 64, 1024] { + let payload = vec![0xA5u8; body_kb * 1024]; + let wire = assemble_wire( + "POST", + "/echo/bytes", + Some("application/octet-stream"), + &payload, + ); + group.throughput(Throughput::Bytes((body_kb * 1024) as u64)); + + group.bench_with_input( + BenchmarkId::new("raw_bytes_dispatch_from_bytes", body_kb), + &body_kb, + |b, _| { + b.iter(|| dispatch_from_bytes(wire.clone(), &runtime)); + }, + ); + } + + group.finish(); + drop(runtime); +} + +/// Direct-write A/B: `dispatch_from_bytes` (materialises the wire +/// response into a fresh `Vec` per call) vs `dispatch_into` (streams +/// the wire response straight into a caller-owned, preallocated buffer +/// — the JNI `dispatchDirect` path). Both echo a raw byte body via +/// `/echo/bytes`, so the delta isolates the response `Vec` allocation + +/// final body memcpy that the direct-write path removes. +/// +/// The `dispatch_into` buffer is sized exactly once (outside the timed +/// loop) and reused across iterations, mirroring the pooled direct +/// buffer the Java bridge hands in. +fn bench_direct_write_path(c: &mut Criterion) { + install_bench_app(); + + let runtime = Runtime::new().expect("tokio runtime"); + let mut group = c.benchmark_group("direct_write_path"); + + // Bodyless GET — the #3 borrowed-input sweet spot. Same-run A/B: + // `bodyless_owned` clones the wire into a `Vec` (mirrors the JNI + // `dispatchDirect0` `.to_vec()` copy of the direct buffer), while + // `bodyless_borrowed` reads the wire in place and builds an empty body, + // copying nothing. The delta isolates the eliminated input copy. + { + let wire = assemble_wire("GET", "/r0", None, &[]); + let required = { + let mut probe = vec![0u8; 4096]; + match dispatch_into(wire.clone(), &mut probe, &runtime) { + DirectWriteResult::Complete(n) | DirectWriteResult::Overflow(n) => n, + } + }; + group.bench_function("bodyless_owned_dispatch_into", |b| { + let mut out = vec![0u8; required]; + b.iter(|| dispatch_into(wire.clone(), &mut out, &runtime)); + }); + group.bench_function("bodyless_borrowed_dispatch_into", |b| { + let mut out = vec![0u8; required]; + b.iter(|| runtime.block_on(dispatch_into_async_borrowed(&wire, &mut out))); + }); + } + + for &body_kb in &[64_usize, 1024, 4096] { + let payload = vec![0xA5u8; body_kb * 1024]; + let wire = assemble_wire( + "POST", + "/echo/bytes", + Some("application/octet-stream"), + &payload, + ); + group.throughput(Throughput::Bytes((body_kb * 1024) as u64)); + + // Exact response size: one untimed probe with a generous buffer. + let required = { + let mut probe = vec![0u8; payload.len() + 4096]; + match dispatch_into(wire.clone(), &mut probe, &runtime) { + DirectWriteResult::Complete(n) | DirectWriteResult::Overflow(n) => n, + } + }; + + group.bench_with_input( + BenchmarkId::new("materialize_dispatch_from_bytes", body_kb), + &body_kb, + |b, _| { + b.iter(|| dispatch_from_bytes(wire.clone(), &runtime)); + }, + ); + + group.bench_with_input( + BenchmarkId::new("direct_write_dispatch_into", body_kb), + &body_kb, + |b, _| { + let mut out = vec![0u8; required]; + b.iter(|| dispatch_into(wire.clone(), &mut out, &runtime)); + }, + ); + } + + group.finish(); + drop(runtime); +} + +/// P2 isolation (within-run A/B): default-app resolution via the +/// lock-free `OnceLock` fast path vs named-app resolution through the +/// lock-free `ArcSwap` load (INP-07). Identical router, identical wire +/// request shape — the only difference is the `"app"` header field. +fn bench_resolve_path(c: &mut Criterion) { + static INIT_NAMED: std::sync::Once = std::sync::Once::new(); + + install_bench_app(); + INIT_NAMED + .call_once(|| vespera_inprocess::register_app_named("bench-named", || build_router(100))); + + let runtime = Runtime::new().expect("tokio runtime"); + let mut group = c.benchmark_group("resolve_path"); + + let wire_default = assemble_wire_for_app("GET", "/r0", None, None, &[]); + group.bench_function("default_oncelock_fast_path", |b| { + b.iter(|| dispatch_from_bytes(wire_default.clone(), &runtime)); + }); + + let wire_named = assemble_wire_for_app("GET", "/r0", None, Some("bench-named"), &[]); + // Named-app resolution now goes through the lock-free `ArcSwap` load + // (INP-07), not the former `RwLock`. + group.bench_function("named_arcswap_path", |b| { + b.iter(|| dispatch_from_bytes(wire_named.clone(), &runtime)); + }); + + group.finish(); + drop(runtime); +} + +/// P2 contention measurement: concurrent `dispatch_from_bytes` from +/// many OS threads against one shared multi-thread runtime. +/// +/// `default` resolves through the lock-free `OnceLock` fast path; +/// `named` resolves through the lock-free `ArcSwap` load (INP-07). +/// Both stay lock-free under reader pressure — the residual delta is +/// the `OnceLock` single-atomic-load advantage over the `ArcSwap` +/// load-plus-hash-lookup, which the single-threaded `resolve_path` +/// group cannot isolate. See `registry_ab` for the RwLock-vs-ArcSwap +/// before/after. +/// Excluded from the CI regression gate (heavily scheduler-dependent); +/// run locally for the numbers. +fn bench_contended_path(c: &mut Criterion) { + static INIT_NAMED: std::sync::Once = std::sync::Once::new(); + + install_bench_app(); + INIT_NAMED + .call_once(|| vespera_inprocess::register_app_named("bench-named", || build_router(100))); + + let runtime = std::sync::Arc::new(Runtime::new().expect("tokio runtime")); + let mut group = c.benchmark_group("contended_path"); + + for &threads in &[8_usize, 32] { + for (label, app) in [ + ("default_oncelock", None), + ("named_arcswap", Some("bench-named")), + ] { + let wire = assemble_wire_for_app("GET", "/r0", None, app, &[]); + group.bench_with_input(BenchmarkId::new(label, threads), &threads, |b, &threads| { + b.iter_custom(|iters| { + let per_thread = usize::try_from(iters) + .unwrap_or(usize::MAX) + .div_ceil(threads); + let start = std::time::Instant::now(); + std::thread::scope(|scope| { + for _ in 0..threads { + let wire = wire.clone(); + let runtime = std::sync::Arc::clone(&runtime); + scope.spawn(move || { + for _ in 0..per_thread { + std::hint::black_box(dispatch_from_bytes( + wire.clone(), + &runtime, + )); + } + }); + } + }); + start.elapsed() + }); + }); + } + } + + group.finish(); +} + +/// INP-07 before/after A/B: named-app router resolution under +/// concurrent reader pressure — the **previous** `RwLock` +/// registry vs the **current** `ArcSwap` registry, both +/// populated identically and both doing the exact `lookup + +/// Router::clone` the dispatch read path performs. The synchronization +/// primitive is the only difference, so the delta is the pure +/// lock-vs-lock-free read cost INP-07 buys. +/// +/// The single-threaded `resolve_path` group cannot show this — the win +/// is reader *scalability*, which only appears once many threads hammer +/// the shared map (RwLock readers contend on one reader-count cache +/// line; `ArcSwap` shards that away). Heavily scheduler-dependent; +/// run locally for the numbers. +fn bench_registry_ab(c: &mut Criterion) { + use arc_swap::ArcSwap; + use std::collections::HashMap; + use std::sync::{Arc, RwLock}; + + let make_map = || { + let mut m: HashMap = HashMap::new(); + m.insert("bench-named".to_owned(), build_router(100)); + m + }; + let rwlock: Arc>> = Arc::new(RwLock::new(make_map())); + let arcswap: Arc>> = + Arc::new(ArcSwap::from_pointee(make_map())); + + let mut group = c.benchmark_group("registry_ab"); + + for &threads in &[8_usize, 32] { + // BEFORE — one RwLock read-lock acquisition per resolution. + let rwlock_b = Arc::clone(&rwlock); + group.bench_with_input( + BenchmarkId::new("rwlock_read_before", threads), + &threads, + |b, &threads| { + b.iter_custom(|iters| { + let per_thread = usize::try_from(iters) + .unwrap_or(usize::MAX) + .div_ceil(threads); + let start = std::time::Instant::now(); + std::thread::scope(|scope| { + for _ in 0..threads { + let rwlock = Arc::clone(&rwlock_b); + scope.spawn(move || { + for _ in 0..per_thread { + let guard = rwlock + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::hint::black_box(guard.get("bench-named").cloned()); + } + }); + } + }); + start.elapsed() + }); + }, + ); + + // AFTER — one lock-free `ArcSwap` load per resolution. + let arcswap_a = Arc::clone(&arcswap); + group.bench_with_input( + BenchmarkId::new("arcswap_read_after", threads), + &threads, + |b, &threads| { + b.iter_custom(|iters| { + let per_thread = usize::try_from(iters) + .unwrap_or(usize::MAX) + .div_ceil(threads); + let start = std::time::Instant::now(); + std::thread::scope(|scope| { + for _ in 0..threads { + let arcswap = Arc::clone(&arcswap_a); + scope.spawn(move || { + for _ in 0..per_thread { + std::hint::black_box( + arcswap.load().get("bench-named").cloned(), + ); + } + }); + } + }); + start.elapsed() + }); + }, + ); + } + + group.finish(); +} + +/// P4 isolation: response with 10 single-value headers + 3-value +/// `set-cookie` — dominated by `collect_header_map` allocations and +/// wire header JSON serialisation rather than body handling. +fn bench_headers_path(c: &mut Criterion) { + install_bench_app(); + + let runtime = Runtime::new().expect("tokio runtime"); + let wire = assemble_wire("GET", "/headers", None, &[]); + let mut group = c.benchmark_group("headers_path"); + + group.bench_function("many_headers_roundtrip", |b| { + b.iter(|| dispatch_from_bytes(wire.clone(), &runtime)); + }); + + group.finish(); + drop(runtime); +} + +/// P1/P3 isolation: streaming dispatch throughput. +/// +/// - `response_streaming`: full body in the request, response drained +/// through the `on_chunk` callback. +/// - `bidirectional`: request body fed through `pull_chunk` in +/// [`vespera_inprocess::DEFAULT_STREAMING_CHUNK_BYTES`] pieces +/// (mirrors the JNI `InputStream` reader), response drained through +/// `on_chunk` — exercises the bounded mpsc channel and the +/// `spawn_blocking` producer. +fn bench_streaming_path(c: &mut Criterion) { + install_bench_app(); + + let runtime = Runtime::new().expect("tokio runtime"); + let mut group = c.benchmark_group("streaming_path"); + + for &body_kb in &[64_usize, 1024] { + let payload = vec![0xA5u8; body_kb * 1024]; + group.throughput(Throughput::Bytes((body_kb * 1024) as u64)); + + let wire = assemble_wire( + "POST", + "/echo/bytes", + Some("application/octet-stream"), + &payload, + ); + group.bench_with_input( + BenchmarkId::new("response_streaming", body_kb), + &body_kb, + |b, _| { + b.iter(|| { + let mut sink = 0usize; + runtime.block_on(dispatch_streaming_async(wire.clone(), |chunk| { + sink += chunk.len(); + ControlFlow::Continue(()) + })); + sink + }); + }, + ); + + let header_only = + assemble_wire("POST", "/echo/bytes", Some("application/octet-stream"), &[]); + let pull_chunk_size = vespera_inprocess::DEFAULT_STREAMING_CHUNK_BYTES; + let request_chunks: Vec> = payload + .chunks(pull_chunk_size) + .map(<[u8]>::to_vec) + .collect(); + group.bench_with_input( + BenchmarkId::new("bidirectional", body_kb), + &body_kb, + |b, _| { + b.iter(|| { + let chunks_iter = Mutex::new(request_chunks.clone().into_iter()); + let pull = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; + let mut sink = 0usize; + runtime.block_on(dispatch_bidirectional_streaming( + header_only.clone(), + pull, + |chunk| { + sink += chunk.len(); + ControlFlow::Continue(()) + }, + )); + sink + }); + }, + ); + + let discard_header_only = + assemble_wire("POST", "/discard", Some("application/octet-stream"), &[]); + group.bench_with_input( + BenchmarkId::new("bidirectional_no_body_poll", body_kb), + &body_kb, + |b, _| { + b.iter(|| { + let remaining = Mutex::new(body_kb * 1024); + let pull = move || -> RequestChunk { + let mut remaining = remaining.lock().unwrap(); + if *remaining == 0 { + return RequestChunk::End; + } + let len = (*remaining).min(pull_chunk_size); + *remaining -= len; + RequestChunk::Data(vec![0xA5u8; len]) + }; + let mut sink = 0usize; + runtime.block_on(dispatch_bidirectional_streaming( + discard_header_only.clone(), + pull, + |chunk| { + sink += chunk.len(); + ControlFlow::Continue(()) + }, + )); + sink + }); + }, + ); + } + + group.finish(); + drop(runtime); +} + +/// #2 isolation: the `vespera_jni::dispatchAsync` spawn mechanism. +/// +/// Both variants run the dispatch task on a shared multi-thread runtime +/// (the outer `tokio::spawn`, common to both) and differ only in how a +/// panic in the dispatch future is isolated: +/// +/// - `double_spawn_pre`: a **second** `tokio::spawn` (panic → `JoinError`), +/// the pre-#2 shape — one extra task allocation + scheduler hop. +/// - `single_spawn_catch_unwind_post`: `FutureExt::catch_unwind` in place, +/// the post-#2 shape — same panic → fallback, no second task. +/// +/// The inner future is trivial so the spawn/catch_unwind overhead is the +/// dominant cost and the delta isolates exactly what #2 removes per async +/// dispatch (independent of the dispatch payload size). +fn bench_async_spawn_pattern(c: &mut Criterion) { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .build() + .expect("multi-thread runtime"); + let mut group = c.benchmark_group("async_spawn_pattern"); + + group.bench_function("double_spawn_pre", |b| { + b.iter(|| { + runtime.block_on(async { + tokio::spawn(async move { + tokio::spawn(async { vec![0u8; 64] }) + .await + .unwrap_or_else(|_| vec![1u8; 16]) + }) + .await + .unwrap() + }) + }); + }); + + group.bench_function("single_spawn_catch_unwind_post", |b| { + b.iter(|| { + runtime.block_on(async { + tokio::spawn(async move { + AssertUnwindSafe(async { vec![0u8; 64] }) + .catch_unwind() + .await + .unwrap_or_else(|_| vec![1u8; 16]) + }) + .await + .unwrap() + }) + }); + }); + + group.finish(); + drop(runtime); +} + +/// Same-run A/B for the `RequestSourceCloser` hardening: the request-source +/// close hook is now invoked under `catch_unwind` so a panicking hook running +/// from `Drop` during unwind cannot double-panic -> `abort()` the host JVM. +/// This isolates the added `catch_unwind` landing-pad cost vs a direct call, +/// with BOTH arms in the SAME run so the measurement is immune to the +/// cross-run thermal/load drift that swamps the dispatch-level `streaming_path` +/// comparison (the close hook fires once per bidirectional dispatch, after the +/// response body is fully drained, so its cost is amortised over an entire +/// dispatch — this micro-A/B is the only instrument fine enough to resolve it). +fn bench_close_hook_ab(c: &mut Criterion) { + use std::panic::AssertUnwindSafe; + let mut group = c.benchmark_group("close_hook_ab"); + + // `pre`: the previous direct `close()` call. `post`: the hardened + // `catch_unwind(AssertUnwindSafe(close))`. The closure does a tiny + // black-boxed op so it is neither optimised away nor large enough to + // dwarf the landing-pad cost being measured. + group.bench_function("direct_call_pre", |b| { + b.iter(|| { + let f = || std::hint::black_box(1u64).wrapping_mul(3); + std::hint::black_box(f()) + }); + }); + + group.bench_function("catch_unwind_post", |b| { + b.iter(|| { + let f = || std::hint::black_box(1u64).wrapping_mul(3); + std::hint::black_box(std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or(0)) + }); + }); + + group.finish(); +} + +/// Same-run A/B for the Oracle-flagged `dispatchAsync` completion-isolation +/// question: does completing the Java `CompletableFuture` from a +/// `spawn_blocking` thread (so a blocking / re-entrant Java continuation runs +/// OFF the core Tokio workers) cost enough to matter on the async path? +/// +/// - `complete_inline_pre`: the future is completed inline on the dispatch +/// worker (the pre-change behaviour) — no isolation hop. +/// - `complete_spawn_blocking_post`: the completion is moved to a +/// `spawn_blocking` thread — isolates Java continuations from the core +/// workers at the cost of one blocking-pool hand-off. +/// +/// Both arms run in the SAME run (drift-immune). The delta is the per-async- +/// dispatch cost isolation would add, and decides whether to isolate +/// unconditionally or document the `thenApplyAsync` contract instead (speed is +/// the stated priority, so a large hop argues for the zero-cost doc contract). +/// +/// VERDICT (measured, AMD Ryzen 9 9950X): `complete_inline_pre` ~1.5 µs vs +/// `complete_spawn_blocking_post` ~24.5 µs — a ~16x per-dispatch regression. +/// Forced isolation is therefore REJECTED (it violates the speed-first +/// priority); the worker-thread completion is kept and the threading contract +/// is documented on `dispatchAsync` instead (callers use `*Async` continuations +/// and avoid blocking / re-entrant inline continuations). This A/B stays as +/// the permanent regression-decision guard so the 16x cost is not re-discovered. +fn bench_async_completion_isolation_ab(c: &mut Criterion) { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .build() + .expect("multi-thread runtime"); + let mut group = c.benchmark_group("async_completion_ab"); + + group.bench_function("complete_inline_pre", |b| { + b.iter(|| { + runtime.block_on(async { + tokio::spawn(async move { + let resp = std::hint::black_box(vec![0u8; 64]); + std::hint::black_box(resp.len()) + }) + .await + .unwrap() + }) + }); + }); + + group.bench_function("complete_spawn_blocking_post", |b| { + b.iter(|| { + runtime.block_on(async { + tokio::spawn(async move { + let resp = std::hint::black_box(vec![0u8; 64]); + tokio::task::spawn_blocking(move || std::hint::black_box(resp.len())) + .await + .unwrap() + }) + .await + .unwrap() + }) + }); + }); + + group.finish(); + drop(runtime); +} + +// The `bench-support`-gated within-run A/B benchmark groups +// (`wire_header_serde`, `request_build_ab`, `hoist_422_ab`) live in the +// `serde_ab` submodule (compiled only under `--features bench-support`) to +// keep this file under the 1000-line cap. +#[cfg(feature = "bench-support")] +#[path = "dispatch/serde_ab.rs"] +mod serde_ab; + +/// Request-header handling cost: a POST carrying a realistic multi-header +/// set (the shape a real browser / reverse-proxy sends) dispatched +/// end-to-end via `dispatch_from_bytes`. The `wire_path` / `bytes_path` +/// groups send only ONE request header (content-type), so they cannot +/// surface the per-request header-scan cost; this group does, for 1 / 8 / +/// 16 headers, isolating the content-type pre-scan that the dispatch path +/// previously ran separately from the request-build header loop. +fn bench_request_headers_path(c: &mut Criterion) { + install_bench_app(); + + let runtime = Runtime::new().expect("tokio runtime"); + let mut group = c.benchmark_group("request_headers_path"); + + // Realistic request headers (browser / proxy shape). content-type is + // present (so a POST extractor is satisfied) but sorts into the middle + // of the JSON object, mirroring how a real header set is scanned. + let all_headers: &[(&str, &str)] = &[ + ("host", "api.example.com"), + ("user-agent", "Mozilla/5.0 (bench) Gecko/20100101"), + ("accept", "application/json, text/plain, */*"), + ("accept-encoding", "gzip, deflate, br"), + ("accept-language", "en-US,en;q=0.9"), + ("content-type", "application/json"), + ( + "authorization", + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + ), + ("x-request-id", "01HV2N3M4P5Q6R7S8T9V0W1X2Y"), + ("x-forwarded-for", "203.0.113.7"), + ("x-forwarded-proto", "https"), + ("referer", "https://app.example.com/dashboard"), + ("cookie", "session=abc123; theme=dark; lang=en"), + ("origin", "https://app.example.com"), + ("cache-control", "no-cache"), + ("connection", "keep-alive"), + ("dnt", "1"), + ]; + let body = br#"{"body":"x"}"#; + + for &n in &[1_usize, 8, 16] { + let headers = &all_headers[..n.min(all_headers.len())]; + let wire = assemble_wire_with_headers("POST", "/echo", headers, body); + group.bench_with_input(BenchmarkId::new("dispatch_from_bytes", n), &n, |b, _| { + b.iter(|| dispatch_from_bytes(wire.clone(), &runtime)); + }); + } + + group.finish(); + drop(runtime); +} + +/// Query-string handling A/B (same-run, drift-immune): a GET carrying a query +/// string dispatched two ways. +/// +/// - `separate_query_field_join`: the query travels in a SEPARATE wire +/// `query` field, so the dispatch path joins `path + '?' + query` into a +/// fresh `String` before `Uri` parsing (the current Java-bridge encoding). +/// - `combined_in_path_borrow`: the query is EMBEDDED in the `path` field, so +/// the dispatch path borrows `path` directly and hits the empty-query +/// zero-join `Uri::try_from(path)` fast path. +/// +/// The delta isolates the per-query-request `String` join + copy that sending +/// the combined form removes. The servlet already has the full request URI, +/// so the Java bridge can send `path` with the query embedded. +/// +/// MEASURED (AMD/Windows, mimalloc): `separate_query_field_join` ~865 ns vs +/// `combined_in_path_borrow` ~831 ns — a ~4% per-query-GET win, statistically +/// significant (non-overlapping CIs). REALIZATION IS GATED: embedding the +/// query in the `path` field changes the request wire header, which is locked +/// byte-for-byte by a CROSS-LANGUAGE golden on BOTH sides +/// (`tests/wire_contract.rs::cross_language_request_golden_routes` and the Java +/// `VesperaWireTest.CANONICAL_REQUEST_HEADER_JSON`). Honouring that contract, +/// the change is deferred to an explicit, lock-stepped both-goldens update +/// rather than taken unilaterally for 4% on one request shape. This A/B stays +/// as the permanent decision record (mirrors `async_completion_ab`). +fn bench_query_path(c: &mut Criterion) { + install_bench_app(); + + let runtime = Runtime::new().expect("tokio runtime"); + let mut group = c.benchmark_group("query_path"); + + let query = "page=1&limit=20&sort=created_at&order=desc&filter=active&q=hello"; + + // SEPARATE: `path` = "/r0" + a distinct wire `query` field → join branch. + let wire_separate = { + let header = serde_json::json!({ + "v": 1, "method": "GET", "path": "/r0", "query": query, + }); + let header_bytes = serde_json::to_vec(&header).unwrap(); + let header_len = u32::try_from(header_bytes.len()).unwrap(); + let mut wire = Vec::with_capacity(4 + header_bytes.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire + }; + + // COMBINED: query embedded in `path`, no `query` field → borrow branch. + let combined_path = format!("/r0?{query}"); + let wire_combined = assemble_wire("GET", &combined_path, None, &[]); + + group.bench_function("separate_query_field_join", |b| { + b.iter(|| dispatch_from_bytes(wire_separate.clone(), &runtime)); + }); + group.bench_function("combined_in_path_borrow", |b| { + b.iter(|| dispatch_from_bytes(wire_combined.clone(), &runtime)); + }); + + group.finish(); + drop(runtime); +} + criterion_group!( benches, + bench_query_path, + bench_request_headers_path, bench_router_path, bench_dispatch_path, - bench_wire_path + bench_wire_path, + bench_bytes_path, + bench_direct_write_path, + bench_resolve_path, + bench_contended_path, + bench_registry_ab, + bench_headers_path, + bench_streaming_path, + bench_async_spawn_pattern, + bench_close_hook_ab, + bench_async_completion_isolation_ab +); + +// The within-run A/B groups compare the production hand-rolled paths against +// the retained `serde_json` / `http::request::Builder` / `serde_json::Value` +// "before" twins. Those twins live behind the `bench-support` feature so a +// production build never compiles them — run these groups with +// `cargo bench -p vespera_inprocess --bench dispatch --features bench-support`. +#[cfg(feature = "bench-support")] +criterion_group!( + ab_benches, + serde_ab::bench_wire_header_serde, + serde_ab::bench_request_build_path, + serde_ab::bench_hoist_422_path ); + +#[cfg(feature = "bench-support")] +criterion_main!(benches, ab_benches); +#[cfg(not(feature = "bench-support"))] criterion_main!(benches); diff --git a/crates/vespera_inprocess/benches/dispatch/serde_ab.rs b/crates/vespera_inprocess/benches/dispatch/serde_ab.rs new file mode 100644 index 00000000..dfb32b70 --- /dev/null +++ b/crates/vespera_inprocess/benches/dispatch/serde_ab.rs @@ -0,0 +1,215 @@ +//! `bench-support`-gated within-run A/B benchmark groups. +//! +//! Each group compares a production hand-rolled path against its retained +//! `serde_json` / `http::request::Builder` / `serde_json::Value` "before" twin +//! in the SAME criterion run (noise-robust). Split out of `dispatch.rs` to keep +//! that file under the 1000-line cap; the whole module is compiled only under +//! `--features bench-support` (the `mod` declaration in `dispatch.rs` is +//! `#[cfg(feature = "bench-support")]`). Wired into the parent `ab_benches` +//! criterion group. + +// This is a `#[path]` bench submodule of `dispatch.rs`; it intentionally +// re-uses the parent bench file's imports (criterion types, header types, +// wire-assembly helpers) rather than re-listing them. The glob is the +// idiomatic shape for a bench helper split out only to honour the file-size cap. +#[allow(clippy::wildcard_imports)] +use super::*; + +/// `request_parse_*` / `response_serialize_*` within-run A/B: the hand-rolled +/// wire-header parse / slice-serialize vs the retained `serde_json` twins, in +/// the SAME criterion run so the delta is read without cross-run drift. +/// +/// - `request_parse_*`: full header parse of a realistic small +/// `GET /health`-shaped header (the SmartDispatch DIRECT sweet spot) — +/// `parse_wire_header` (hand) vs `parse_wire_header_serde`. +/// - `response_serialize_*`: slice-serialize of a many-header response +/// (10 single-value + 3-value `set-cookie` + content-type/length) — +/// `write_wire_header_into_slice` (hand) vs the `serde_json` twin. +pub fn bench_wire_header_serde(c: &mut Criterion) { + use vespera_inprocess::ResponseMetadata; + use vespera_inprocess::bench_support::{ + bench_parse_hand, bench_parse_serde, bench_write_hand, bench_write_serde, + }; + + // Request-parse fixture: exactly the JSON object `parse_wire_header` + // receives (no length prefix) for a small idempotent GET. + let request_header: &[u8] = br#"{"v":1,"method":"GET","path":"/health","headers":{"accept":"*/*","user-agent":"bench/1.0","host":"localhost:3000"}}"#; + + // Forward-compat fixture: the same small GET plus UNKNOWN header fields + // (an object with escaped-string values + nesting, and an array). These + // are ignored by both parsers via the value-skip path — the input shape + // a newer client / custom FFI caller can legitimately send. Isolates the + // unknown-value skip cost (escaped-string skip allocation + the recursion + // depth guard) that the standard `request_header` fixture never exercises. + let request_header_unknown: &[u8] = br#"{"v":1,"method":"GET","path":"/health","headers":{"accept":"*/*"},"x-meta":{"trace":"a\"b\nc\td","span":"00f0\u00e9","nested":{"k":[1,2,"v\u00e9"]}},"flags":[true,null,42,-3.14e2]}"#; + + // Response-serialize fixture: the realistic many-header response shape + // (mirrors `handler_many_headers`) plus content-type / content-length. + let mut resp_headers = HeaderMap::new(); + for (name, value) in [ + ("cache-control", "no-store"), + ("etag", "\"abc123def456\""), + ("vary", "accept-encoding"), + ("x-content-type-options", "nosniff"), + ("x-frame-options", "DENY"), + ("x-request-id", "01HV2N3M4P5Q6R7S8T9V0W1X2Y"), + ("x-trace-id", "4bf92f3577b34da6a3ce929d0e0e4736"), + ("access-control-allow-origin", "*"), + ("strict-transport-security", "max-age=63072000"), + ("content-language", "en"), + ("content-type", "application/json"), + ("content-length", "1024"), + ] { + resp_headers.insert( + HeaderName::from_static(name), + value.parse().expect("static header value"), + ); + } + let cookie = HeaderName::from_static("set-cookie"); + resp_headers.append(cookie.clone(), "session=s1; HttpOnly".parse().unwrap()); + resp_headers.append(cookie.clone(), "theme=dark; Path=/".parse().unwrap()); + resp_headers.append(cookie, "lang=en; Path=/".parse().unwrap()); + let metadata = ResponseMetadata::current(); + + let mut group = c.benchmark_group("wire_header_serde"); + + group.bench_function("request_parse_hand", |b| { + b.iter(|| bench_parse_hand(std::hint::black_box(request_header))); + }); + group.bench_function("request_parse_serde", |b| { + b.iter(|| bench_parse_serde(std::hint::black_box(request_header))); + }); + + // Forward-compat unknown-field skip path (escaped-string skip + depth + // guard). Standard `request_parse_hand` never enters `skip_value`, so this + // is where the non-allocating escaped-string skip shows up. + group.bench_function("request_parse_unknown_hand", |b| { + b.iter(|| bench_parse_hand(std::hint::black_box(request_header_unknown))); + }); + group.bench_function("request_parse_unknown_serde", |b| { + b.iter(|| bench_parse_serde(std::hint::black_box(request_header_unknown))); + }); + + // Size the out buffer once (outside the timed loop) and reuse it, + // mirroring the pooled direct buffer the JNI bridge hands in. + let required = bench_write_hand(&mut [0u8; 1024], 200, &resp_headers, &metadata); + group.bench_function("response_serialize_hand", |b| { + let mut out = vec![0u8; required]; + b.iter(|| bench_write_hand(&mut out, 200, &resp_headers, &metadata)); + }); + group.bench_function("response_serialize_serde", |b| { + let mut out = vec![0u8; required]; + b.iter(|| bench_write_serde(&mut out, 200, &resp_headers, &metadata)); + }); + + group.finish(); +} + +/// Direct `Request` construction vs the `http::request::Builder` state +/// machine (within-run A/B). Both arms build a full request from the same +/// method / path / query / headers / body in the SAME criterion run +/// (noise-robust, like `wire_header_serde`), so the builder-vs-direct delta is +/// read without cross-run drift. Each arm sums the built request's field byte +/// lengths so neither can be optimised down to a partial build. +/// +/// Fixtures span the dispatch hot path's real request shapes: a bodyless `GET` +/// (the DIRECT sweet spot), a `GET` with 3 headers, a small `POST` with +/// `content-type`, and a `POST` with 8 realistic headers. +pub fn bench_request_build_path(c: &mut Criterion) { + use vespera_inprocess::bench_support::{bench_build_request_new, bench_build_request_old}; + + type Fixture = ( + &'static str, + &'static str, + &'static str, + &'static str, + &'static [(&'static str, &'static str)], + &'static str, + ); + let fixtures: &[Fixture] = &[ + ("bodyless_get", "GET", "/r0", "", &[], ""), + ( + "get_3_headers", + "GET", + "/r0", + "", + &[ + ("accept", "*/*"), + ("user-agent", "bench/1.0"), + ("host", "localhost:3000"), + ], + "", + ), + ( + "post_content_type", + "POST", + "/echo", + "", + &[("content-type", "application/json")], + r#"{"body":"x"}"#, + ), + ( + "post_8_headers", + "POST", + "/echo", + "", + &[ + ("content-type", "application/json"), + ("accept", "*/*"), + ("user-agent", "bench/1.0"), + ("host", "localhost:3000"), + ("authorization", "Bearer abcdef0123456789"), + ("accept-encoding", "gzip, deflate, br"), + ("accept-language", "en-US,en;q=0.9"), + ("x-request-id", "01HV2N3M4P5Q6R7S8T9V0W1X2Y"), + ], + r#"{"body":"x"}"#, + ), + ]; + + let mut group = c.benchmark_group("request_build_ab"); + for &(label, method, path, query, headers, body) in fixtures { + let body = bytes::Bytes::copy_from_slice(body.as_bytes()); + group.bench_function(BenchmarkId::new("direct_new", label), |b| { + b.iter(|| bench_build_request_new(method, path, query, headers, body.clone())); + }); + group.bench_function(BenchmarkId::new("builder_old", label), |b| { + b.iter(|| bench_build_request_old(method, path, query, headers, body.clone())); + }); + } + group.finish(); +} + +/// Typed-deserialize vs `serde_json::Value` DOM for the 422 validation-error +/// hoist (within-run A/B). Both arms parse the same framework-generated +/// `{"errors":[{"path","message"}]}` envelope in the SAME criterion run, so +/// the DOM-removal delta is read without cross-run drift. Each arm sums the +/// hoisted field byte lengths so neither can be optimised to a partial parse. +/// +/// Fixtures: a 1-error envelope (typical single-field failure) and a 5-error +/// envelope (form-heavy request) — where the eliminated `Value` map/array/key +/// allocations scale with error count. +pub fn bench_hoist_422_path(c: &mut Criterion) { + use vespera_inprocess::bench_support::{bench_hoist_new, bench_hoist_old}; + + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("content-type"), + "application/json".parse().expect("static header value"), + ); + + let body_1: &str = r#"{"errors":[{"path":"email","message":"not a valid email"}]}"#; + let body_5: &str = r#"{"errors":[{"path":"username","message":"length is lower than 3"},{"path":"email","message":"not a valid email"},{"path":"age","message":"greater than 120"},{"path":"bio","message":"length is greater than 256"},{"path":"phone","message":"not a valid phone number"}]}"#; + + let mut group = c.benchmark_group("hoist_422_ab"); + for (label, body) in [("errors_1", body_1), ("errors_5", body_5)] { + let body = bytes::Bytes::copy_from_slice(body.as_bytes()); + group.bench_function(BenchmarkId::new("typed_new", label), |b| { + b.iter(|| bench_hoist_new(&headers, &body)); + }); + group.bench_function(BenchmarkId::new("value_old", label), |b| { + b.iter(|| bench_hoist_old(&headers, &body)); + }); + } + group.finish(); +} diff --git a/crates/vespera_inprocess/src/config.rs b/crates/vespera_inprocess/src/config.rs new file mode 100644 index 00000000..978c9c6f --- /dev/null +++ b/crates/vespera_inprocess/src/config.rs @@ -0,0 +1,380 @@ +//! Process-wide streaming configuration (chunk size, channel +//! capacity) — resolved once via `OnceLock`: setter > env > default. + +use std::sync::OnceLock; + +// ── Streaming Configuration ────────────────────────────────────────── + +/// Default per-chunk buffer size for streaming dispatches (256 KiB). +/// +/// Large enough to amortise per-chunk FFI overhead (JNI region copy + +/// `OutputStream.write` call per chunk), small enough to keep memory +/// bounded for multi-GB streams. Raised from 64 KiB to 256 KiB +/// because measured streaming throughput improves ~25 % (11.6 → 14.5 +/// GB/s) at the cost of an extra 192 KiB of per-stream buffer per +/// direction — both still well within "low-single-digit MiB resident +/// per stream" for multi-GB transfers. Tune down via +/// `set_streaming_chunk_bytes`, the `VESPERA_STREAMING_CHUNK_BYTES` +/// env var, or `VesperaBridge.configureStreaming(...)` when memory is +/// tighter than throughput. +pub const DEFAULT_STREAMING_CHUNK_BYTES: usize = 256 * 1024; + +/// Default capacity (slots) of the bounded mpsc channel that feeds +/// request-body chunks into axum during bidirectional streaming. +pub const DEFAULT_STREAMING_CHANNEL_CAPACITY: usize = 16; + +const MIN_STREAMING_CHUNK_BYTES: usize = 4 * 1024; +const MAX_STREAMING_CHUNK_BYTES: usize = 8 * 1024 * 1024; +const MIN_STREAMING_CHANNEL_CAPACITY: usize = 1; +const MAX_STREAMING_CHANNEL_CAPACITY: usize = 1024; + +static STREAMING_CHUNK_BYTES: OnceLock = OnceLock::new(); +static STREAMING_CHANNEL_CAPACITY: OnceLock = OnceLock::new(); + +/// Parse an optional config string into a clamped `usize`, falling back to +/// `default` when the value is **absent**. +/// +/// A value that is **present but unparseable** (e.g. a typo like `"256KiB"` or +/// `"abc"`) emits a one-time stderr warning — every caller resolves through a +/// process-`OnceLock`, so its initializer runs at most once — and then uses +/// `default`. This mirrors [`max_request_bytes`]'s warn-and-default policy so a +/// mistuned streaming knob is never silently ignored (the operator would +/// otherwise believe they tuned a value that is actually unchanged). +fn parse_config_value( + var_name: &str, + raw: Option<&str>, + default: usize, + min: usize, + max: usize, +) -> usize { + raw.map_or(default, |s| { + s.trim().parse::().map_or_else( + |_| { + eprintln!( + "vespera: ignoring invalid {var_name}={s:?} \ + (expected a non-negative integer); using the default {default}" + ); + default + }, + |v| v.clamp(min, max), + ) + }) +} + +/// Effective per-chunk buffer size for streaming dispatches. +/// +/// Resolution order (first hit wins, then cached for the process +/// lifetime via `OnceLock` — a single atomic load per call): +/// +/// 1. [`set_streaming_chunk_bytes`] called before the first read +/// 2. `VESPERA_STREAMING_CHUNK_BYTES` environment variable +/// 3. [`DEFAULT_STREAMING_CHUNK_BYTES`] (256 KiB) +/// +/// Values are clamped to `[4 KiB, 8 MiB]`. +#[must_use] +#[inline] +pub fn streaming_chunk_bytes() -> usize { + *STREAMING_CHUNK_BYTES.get_or_init(|| { + parse_config_value( + "VESPERA_STREAMING_CHUNK_BYTES", + std::env::var("VESPERA_STREAMING_CHUNK_BYTES") + .ok() + .as_deref(), + DEFAULT_STREAMING_CHUNK_BYTES, + MIN_STREAMING_CHUNK_BYTES, + MAX_STREAMING_CHUNK_BYTES, + ) + }) +} + +/// Override the streaming chunk size **before the first dispatch** +/// (e.g. from a host-language configuration hook at init time). +/// +/// Returns `false` when the value was already fixed — either by a +/// previous call or because a dispatch has already read it. The +/// supplied value is clamped to `[4 KiB, 8 MiB]`. +pub fn set_streaming_chunk_bytes(bytes: usize) -> bool { + STREAMING_CHUNK_BYTES + .set(bytes.clamp(MIN_STREAMING_CHUNK_BYTES, MAX_STREAMING_CHUNK_BYTES)) + .is_ok() +} + +/// Effective bound (slots) of the bidirectional request-body channel. +/// +/// Same resolution order as [`streaming_chunk_bytes`]: +/// [`set_streaming_channel_capacity`] > +/// `VESPERA_STREAMING_CHANNEL_CAPACITY` env var > +/// [`DEFAULT_STREAMING_CHANNEL_CAPACITY`] (16). Clamped to +/// `[1, 1024]`. +#[must_use] +#[inline] +pub fn streaming_channel_capacity() -> usize { + *STREAMING_CHANNEL_CAPACITY.get_or_init(|| { + parse_config_value( + "VESPERA_STREAMING_CHANNEL_CAPACITY", + std::env::var("VESPERA_STREAMING_CHANNEL_CAPACITY") + .ok() + .as_deref(), + DEFAULT_STREAMING_CHANNEL_CAPACITY, + MIN_STREAMING_CHANNEL_CAPACITY, + MAX_STREAMING_CHANNEL_CAPACITY, + ) + }) +} + +/// Override the bidirectional channel capacity **before the first +/// dispatch**. Returns `false` when already fixed. Clamped to +/// `[1, 1024]`. +pub fn set_streaming_channel_capacity(slots: usize) -> bool { + STREAMING_CHANNEL_CAPACITY + .set(slots.clamp( + MIN_STREAMING_CHANNEL_CAPACITY, + MAX_STREAMING_CHANNEL_CAPACITY, + )) + .is_ok() +} + +/// Hard ceiling on the peak request-body bytes buffered in the +/// bidirectional-streaming mpsc channel at any instant. The channel holds up +/// to `channel_capacity` chunks, each up to `chunk_bytes`, so peak buffered +/// memory is `chunk_bytes * channel_capacity`. With BOTH knobs at their +/// maxima (8 MiB * 1024) that product is **8 GiB** — which defeats streaming's +/// `O(chunk)` RAM goal and can OOM a host under concurrent uploads. +/// [`effective_streaming_channel_capacity`] clamps the in-flight slot count so +/// this product is never exceeded. +const MAX_STREAMING_BUFFERED_BYTES: usize = 64 * 1024 * 1024; + +/// Effective in-flight slot count for the bidirectional request-body channel: +/// [`streaming_channel_capacity`] clamped so that +/// `chunk_bytes * slots <= MAX_STREAMING_BUFFERED_BYTES`. +/// +/// This adapts to the configured chunk size — a large chunk yields fewer +/// slots — so peak buffered request memory per stream stays bounded no matter +/// how the two knobs are set. The configured capacity is honoured unchanged +/// when it is already within budget (the common default 256 KiB * 16 = 4 MiB +/// is far under the 64 MiB ceiling). The floor is 1 slot so streaming always +/// makes progress even with an 8 MiB chunk. +#[must_use] +#[inline] +pub fn effective_streaming_channel_capacity() -> usize { + cap_channel_slots( + streaming_channel_capacity(), + streaming_chunk_bytes(), + MAX_STREAMING_BUFFERED_BYTES, + ) +} + +/// Pure product-cap math behind [`effective_streaming_channel_capacity`], +/// split out so the clamp behaviour is unit-testable without the +/// process-global `OnceLock` config (which can only be set once per process). +fn cap_channel_slots(configured: usize, chunk_bytes: usize, max_buffered: usize) -> usize { + let chunk = chunk_bytes.max(1); + let budget_slots = (max_buffered / chunk).max(1); + configured.min(budget_slots) +} + +// ── Request-size ingress cap ───────────────────────────────────────── + +static MAX_REQUEST_BYTES: OnceLock = OnceLock::new(); + +/// Maximum accepted request size (header + body) for the **buffered** +/// dispatch entry points, in bytes. `0` (the default) means +/// **unlimited**, preserving prior behaviour. +/// +/// Resolution order (first hit wins, then cached for the process +/// lifetime): [`set_max_request_bytes`] > `VESPERA_MAX_REQUEST_BYTES` +/// env var > `0` (unlimited). +/// +/// This is a defense-in-depth ingress cap: a caller that bypasses the +/// autoconfigured Spring proxy (which already routes large bodies to +/// streaming) and feeds a multi-GB body straight into `dispatchBytes` / +/// `dispatchAsync` / `dispatchDirect` would otherwise force a full +/// resident copy. When set, oversized requests get a `413` wire +/// response **before** the body is dispatched. +/// +/// The cap also covers the **response-streaming** entry points +/// (`dispatch_streaming_async`, `dispatch_streaming_with_header_async`) +/// because they still buffer the full *request* in memory — only the +/// *response* is streamed. **Bidirectional** streaming +/// (`dispatch_bidirectional_streaming*`), which pulls the request body +/// chunk-by-chunk, is intentionally exempt: it is `O(chunk)` RAM and is +/// the correct path for legitimately large payloads. +#[must_use] +#[inline] +pub fn max_request_bytes() -> usize { + *MAX_REQUEST_BYTES.get_or_init(|| { + // Absent (or non-Unicode) env → unlimited, the documented default. + std::env::var("VESPERA_MAX_REQUEST_BYTES") + .ok() + .map_or(0, |raw| { + raw.trim().parse::().unwrap_or_else(|_| { + // Present but unparseable: a typo here (e.g. "10MB", "abc") + // would otherwise silently fall through to `0` (unlimited), + // disabling the DoS ingress cap with NO signal. Emit a one-time + // stderr warning — this `OnceLock` initializer runs at most once + // per process — so the misconfiguration is observable, then + // preserve the documented unlimited default rather than guessing + // an arbitrary numeric cap that could reject legitimate traffic. + eprintln!( + "vespera: ignoring invalid VESPERA_MAX_REQUEST_BYTES={raw:?} \ + (expected a non-negative integer in bytes); the request-size \ + ingress cap stays unlimited" + ); + 0 + }) + }) + }) +} + +/// Override the request-size cap **before the first dispatch**. +/// `0` means unlimited. Returns `false` when the value was already +/// fixed (a previous call or a dispatch already read it). +pub fn set_max_request_bytes(bytes: usize) -> bool { + MAX_REQUEST_BYTES.set(bytes).is_ok() +} + +/// Whether a request of `len` bytes exceeds the configured cap. +/// Always `false` when the cap is unlimited (`0`). +#[must_use] +#[inline] +pub fn request_exceeds_limit(len: usize) -> bool { + let max = max_request_bytes(); + max != 0 && len > max +} + +#[cfg(test)] +mod tests { + use super::{ + DEFAULT_STREAMING_CHANNEL_CAPACITY, DEFAULT_STREAMING_CHUNK_BYTES, parse_config_value, + }; + + #[test] + fn absent_value_yields_default() { + assert_eq!( + parse_config_value( + "VESPERA_STREAMING_CHUNK_BYTES", + None, + DEFAULT_STREAMING_CHUNK_BYTES, + 4096, + 8 << 20 + ), + DEFAULT_STREAMING_CHUNK_BYTES + ); + } + + #[test] + fn unparseable_value_yields_default() { + for raw in ["", "abc", "-1", "64KiB", "1.5"] { + assert_eq!( + parse_config_value( + "VESPERA_STREAMING_CHANNEL_CAPACITY", + Some(raw), + DEFAULT_STREAMING_CHANNEL_CAPACITY, + 1, + 1024 + ), + DEFAULT_STREAMING_CHANNEL_CAPACITY, + "raw = {raw:?}" + ); + } + } + + // The hardcoded `262144` below is the current + // `DEFAULT_STREAMING_CHUNK_BYTES` (256 KiB). These tests cover + // `parse_config_value`'s parsing/clamp behaviour, not the default + // constant directly — but we keep the representative value in + // sync with the real default so any future bump only needs one + // edit per call site. Bumped from 65536 (64 KiB) when the + // chunk-size default was raised to 256 KiB for +25 % streaming + // throughput. + #[test] + fn valid_value_is_used_and_whitespace_tolerated() { + assert_eq!( + parse_config_value( + "VESPERA_STREAMING_CHUNK_BYTES", + Some("131072"), + 262_144, + 4096, + 8 << 20 + ), + 131_072 + ); + assert_eq!( + parse_config_value( + "VESPERA_STREAMING_CHANNEL_CAPACITY", + Some(" 64 "), + 16, + 1, + 1024 + ), + 64 + ); + } + + #[test] + fn out_of_range_values_are_clamped() { + assert_eq!( + parse_config_value( + "VESPERA_STREAMING_CHUNK_BYTES", + Some("1"), + 262_144, + 4096, + 8 << 20 + ), + 4096 + ); + assert_eq!( + parse_config_value( + "VESPERA_STREAMING_CHUNK_BYTES", + Some("999999999"), + 262_144, + 4096, + 8 << 20 + ), + 8 << 20 + ); + } + + use super::{MAX_STREAMING_BUFFERED_BYTES, cap_channel_slots}; + + #[test] + fn channel_slots_unchanged_when_within_budget() { + // Default config (256 KiB chunk * 16 slots = 4 MiB) is well under the + // 64 MiB ceiling, so the configured capacity passes through unchanged. + assert_eq!( + cap_channel_slots(16, 256 * 1024, MAX_STREAMING_BUFFERED_BYTES), + 16 + ); + } + + #[test] + fn channel_slots_capped_for_pathological_max_config() { + // 8 MiB chunk * 1024 slots would buffer 8 GiB; the product cap clamps + // the slots to 64 MiB / 8 MiB = 8 (64 MiB peak), not 1024. + assert_eq!( + cap_channel_slots(1024, 8 * 1024 * 1024, MAX_STREAMING_BUFFERED_BYTES), + 8 + ); + } + + #[test] + fn channel_slots_floor_is_one_and_zero_chunk_is_safe() { + // A chunk larger than the whole budget still yields >= 1 slot so the + // stream makes progress (peak = one chunk). + assert_eq!( + cap_channel_slots(1024, 128 * 1024 * 1024, MAX_STREAMING_BUFFERED_BYTES), + 1 + ); + // Defensive: a 0 chunk size must never divide by zero. + assert_eq!(cap_channel_slots(16, 0, MAX_STREAMING_BUFFERED_BYTES), 16); + } + + #[test] + fn channel_slots_small_chunk_keeps_full_capacity() { + // 4 KiB chunk * 1024 slots = 4 MiB, under budget → full capacity kept. + assert_eq!( + cap_channel_slots(1024, 4 * 1024, MAX_STREAMING_BUFFERED_BYTES), + 1024 + ); + } +} diff --git a/crates/vespera_inprocess/src/dispatch.rs b/crates/vespera_inprocess/src/dispatch.rs new file mode 100644 index 00000000..501c824a --- /dev/null +++ b/crates/vespera_inprocess/src/dispatch.rs @@ -0,0 +1,597 @@ +//! Public dispatch entry points: the direct (text envelope) API, the +//! binary wire API, and the direct-write (caller buffer) API. + +use std::collections::BTreeMap; + +use axum::body::Body; +use bytes::Bytes; +use http_body::Body as HttpBody; +use http_body_util::BodyExt; + +use crate::Router; +use crate::envelope::{RequestEnvelope, ResponseEnvelope, ResponseMetadata}; +use crate::internal::{dispatch_and_split, dispatch_parts, to_response_envelope_text}; +use crate::registry::resolve_app_router; +use crate::wire::{ + WIRE_HEADER_RESERVE, WIRE_VERSION, WireRequestHeader, error_wire, header_capacity_estimate, + parse_wire_header, split_wire_borrowed, split_wire_request, to_wire_bytes, + write_wire_header_into_slice, write_wire_header_into_vec, +}; + +// ── Shared wire prelude (used by every wire entry point) ───────────── + +/// Ingress-cap guard shared by the **buffered** wire entry points +/// (`dispatch_from_bytes_async`, `dispatch_into_async`, +/// `dispatch_into_async_borrowed`, and the response-streaming pair). +/// Returns the `413` wire bytes when the request exceeds the configured +/// maximum, else `None`. Centralizing the message keeps the cap identical +/// across entry points; **bidirectional** streaming is intentionally exempt +/// (it is `O(chunk)` RAM) and so does not call this. +#[inline] +pub fn check_ingress_cap(len: usize) -> Option> { + if crate::config::request_exceeds_limit(len) { + Some(error_wire( + 413, + &format!( + "request size {len} bytes exceeds configured maximum of {} bytes", + crate::config::max_request_bytes() + ), + )) + } else { + None + } +} + +/// Wire-prelude shared by **every** wire entry point (buffered, +/// direct-write, and streaming): parse the header, enforce the protocol +/// [`WIRE_VERSION`], and resolve the target app [`Router`]. Centralizing +/// this keeps the security-sensitive version check + app resolution +/// byte-identical across all dispatchers — the previous per-entry-point +/// copies were a drift hazard. +/// +/// `header_bytes` is the wire header-JSON region; the returned +/// [`WireRequestHeader`] borrows from it, so the caller MUST keep it alive +/// for as long as the header is used. On failure the `Err` carries the +/// exact wire error bytes to deliver in the caller's shape (`400` for a +/// parse error or version mismatch, `400`/`404` from app resolution). +#[inline] +pub fn parse_validate_resolve( + header_bytes: &[u8], +) -> Result<(WireRequestHeader<'_>, Router), Vec> { + let header = parse_wire_header(header_bytes).map_err(|msg| error_wire(400, &msg))?; + if header.v != WIRE_VERSION { + return Err(error_wire( + 400, + &format!( + "unsupported wire version: got {}, expected {WIRE_VERSION}", + header.v + ), + )); + } + let router = resolve_app_router(&header)?; + Ok((header, router)) +} + +/// Return the sub-[`Bytes`] of `owner` that exactly backs the string slice `s`, +/// or `None` when `s` does not lie within `owner`. +/// +/// Used on the OWNED wire path to build a zero-copy [`http::Uri`] from the +/// request's owning header `Bytes` — sharing the bytes `Uri::try_from(&str)` +/// would otherwise re-allocate and copy. The pointer arithmetic is fully +/// checked, and because distinct heap allocations never overlap, an `s` that +/// lives in its OWN allocation (an escaped `Cow::Owned` path, or a borrowed +/// string from a different buffer) can never satisfy the in-range bound: the +/// function returns `None` and the caller falls back to the copying path. So a +/// returned `Some(bytes)` is guaranteed to hold exactly `s`'s bytes — there is +/// no provenance-confusion path, and `slice` itself never panics. +fn slice_from_owner(owner: &Bytes, s: &str) -> Option { + let base = owner.as_ptr() as usize; + let off = (s.as_ptr() as usize).checked_sub(base)?; + let end = off.checked_add(s.len())?; + (end <= owner.len()).then(|| owner.slice(off..end)) +} + +// ── Dispatch (direct API — backward compatible) ────────────────────── + +/// Dispatch a [`RequestEnvelope`] through an axum [`Router`] and +/// return the serialised [`ResponseEnvelope`] JSON. +/// +/// This borrows the envelope and clones its owned fields before +/// passing them to the hot path. Callers that already own a +/// [`RequestEnvelope`] should prefer [`dispatch_owned`] to skip the +/// clone. +pub async fn dispatch(router: Router, envelope: &RequestEnvelope) -> String { + let result = dispatch_owned(router, envelope.clone()).await; + serde_json::to_string(&result).expect("ResponseEnvelope serialization is infallible") +} + +/// Typed dispatch — returns a [`ResponseEnvelope`] directly. +/// +/// See [`dispatch`] for the clone trade-off; prefer [`dispatch_owned`] +/// when the envelope is already owned. +pub async fn dispatch_typed(router: Router, envelope: &RequestEnvelope) -> ResponseEnvelope { + dispatch_owned(router, envelope.clone()).await +} + +/// Dispatch an owned [`RequestEnvelope`] — moves the envelope into +/// the HTTP request so the body, path, and headers are never cloned. +/// +/// This is the hot path used by callers (e.g. custom FFI transports) +/// that already own a freshly built envelope. +pub async fn dispatch_owned(router: Router, envelope: RequestEnvelope) -> ResponseEnvelope { + let RequestEnvelope { + method, + path, + query, + headers, + body, + } = envelope; + let parts = match dispatch_parts( + router, + &method, + &path, + &query, + headers.iter().map(|(k, v)| (k.as_str(), v.as_str())), + Bytes::from(body), + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => { + return ResponseEnvelope { + status, + headers: BTreeMap::new(), + body: msg, + metadata: ResponseMetadata::current(), + }; + } + }; + to_response_envelope_text(parts) +} + +// ── Binary Wire API ────────────────────────────────────────────────── + +/// Dispatch a wire-format request through the registered app and +/// return a wire-format response. +/// +/// Wire format: +/// ```text +/// bytes 0..4 : u32 BE = header_json byte length N +/// bytes 4..4+N : UTF-8 JSON +/// (request) { "v":1, "method", "path", +/// "query"?, "headers"? } +/// (response) { "v":1, "status", "headers", +/// "metadata" } +/// bytes 4+N..end : raw body bytes (UTF-8 text or binary — +/// no encoding applied) +/// ``` +/// +/// All failure modes return a valid wire-format response (length- +/// prefixed) so the caller's decoder never has to special-case +/// errors. Specifically: +/// +/// * input shorter than 4 bytes → 400 with explanatory body +/// * `header_len` exceeds input → 400 +/// * header JSON parse failure → 400 +/// * wire version mismatch → 400 +/// * invalid app name → 400 +/// * unknown HTTP method → 405 +/// * no app registered under the requested name → 404 +/// * router/handler errors → surfaced verbatim as response wire +pub fn dispatch_from_bytes(input: Vec, runtime: &tokio::runtime::Runtime) -> Vec { + runtime.block_on(dispatch_from_bytes_async(input)) +} + +/// Async sibling of [`dispatch_from_bytes`]. Use this when the caller +/// is already inside a Tokio runtime (e.g. an axum handler embedding +/// another vespera router, or a tokio-spawned task in the JNI bridge's +/// async dispatch path). +/// +/// All failure modes return a valid wire-format response (same +/// guarantees as [`dispatch_from_bytes`]), including `404` when no app +/// is registered under the requested name. +pub async fn dispatch_from_bytes_async(input: Vec) -> Vec { + // Ingress cap (defense-in-depth): reject an oversized buffered request + // with 413 before any further work. Unlimited by default; bidirectional + // streaming is exempt. See [`check_ingress_cap`]. + if let Some(err) = check_ingress_cap(input.len()) { + return err; + } + // Malformed input must report parse errors regardless of whether an app + // is registered, so split first, then the shared parse/version/resolve. + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => return error_wire(400, &msg), + }; + let (header, router) = match parse_validate_resolve(&header_bytes) { + Ok(parts) => parts, + Err(wire) => return wire, + }; + + // Content-Type defaulting (non-empty body with no explicit + // content-type → application/json) is applied inside dispatch_and_split, + // which detects the header during its build pass; we only signal that a + // non-empty body should default. Computed before `body_bytes` is moved. + let default_json_when_absent = !body_bytes.is_empty(); + + // Owned path: with no query and a path borrowed from the owning header + // `Bytes`, hand its sub-`Bytes` to the URI builder so the URI SHARES those + // bytes instead of `Uri::try_from(&str)` copying them — one fewer + // per-request allocation (`slice_from_owner` / `dispatch_and_split`). + let path_bytes = if header.query.is_empty() { + slice_from_owner(&header_bytes, &header.path) + } else { + None + }; + + let (status, headers, metadata, body) = match dispatch_and_split( + router, + &header.method, + &header.path, + &header.query, + path_bytes, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + Body::from(body_bytes), + default_json_when_absent, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return error_wire(status, &msg), + }; + + finish_buffered_wire(status, headers, metadata, body).await +} + +/// Buffered sibling of [`finish_direct_write`]: assemble the full wire +/// response `Vec` by streaming the response body **frames straight into +/// the final buffer**, instead of collecting the body into an intermediate +/// `Bytes` (via `http_body_util::Collected::to_bytes`) and copying it again +/// in [`to_wire_bytes`]. +/// +/// * Single-frame body (the common `Json`/`Bytes`/`String` response): the +/// emitted bytes and the single body copy are identical to the previous +/// `collect()` + `to_wire_bytes` path, minus the `Collected` / `to_bytes` +/// layer. +/// * Multi-frame body: also removes the `to_bytes` concatenation copy and +/// keeps peak memory at ~one body (the growing `Vec`) instead of +/// body-plus-collected. +/// +/// `status == 422` keeps the materialise path so the `validation_errors` +/// hoisting into the wire header is preserved byte-for-byte (validation +/// failures are tiny + cold). A body-stream error mid-drain discards the +/// partial buffer and returns `error_wire(500, ...)`, matching the previous +/// [`crate::internal::dispatch_parts`] 500-on-body-error contract. +async fn finish_buffered_wire( + status: u16, + headers: http::HeaderMap, + metadata: ResponseMetadata, + mut body: Body, +) -> Vec { + if status == 422 { + let Ok(collected) = body.collect().await else { + // Body aborted mid-collect: a failed 422 must surface as a 500, + // never as a clean (empty-bodied) 422 — same contract as the + // non-422 path below and `collect_response_parts`. + return error_wire(500, "response body stream error"); + }; + let body_bytes = collected.to_bytes(); + return to_wire_bytes((status, headers, body_bytes, metadata)); + } + + // Size the final buffer up front: 4-byte length prefix + adaptive header + // estimate (floored at WIRE_HEADER_RESERVE so small-header responses + // never reserve less than before) + the body's exact size when the body + // reports one (Full bodies do), so a single-frame response serializes + // with zero reallocations. + let header_cap = header_capacity_estimate(&headers, &metadata).max(WIRE_HEADER_RESERVE); + let body_cap = usize::try_from(body.size_hint().exact().unwrap_or(0)).unwrap_or(0); + // Saturating so a pathological/oversized exact body hint cannot wrap the + // capacity arithmetic (debug panic / release wrap → under-reserve); the + // common case computes the identical value, and `finish_direct_write` + // already uses the same saturating accounting for its overflow reporting. + let mut out = Vec::with_capacity(4usize.saturating_add(header_cap).saturating_add(body_cap)); + if !write_wire_header_into_vec(&mut out, status, &headers, &metadata) { + // Unreachable for a real `HeaderMap` (4 GiB+ of header JSON); never + // panic on the response path — emit a 500 wire response instead. + return error_wire(500, "response header exceeds u32::MAX bytes"); + } + + loop { + match body.frame().await { + Some(Ok(frame)) => { + if let Some(data) = frame.data_ref() + && !data.is_empty() + { + out.extend_from_slice(data); + } + } + // Body aborted mid-stream: nothing has been handed to the caller + // yet (we return only at the end), so discard the partial buffer + // and emit a 500 rather than a truncated body — mirrors the + // collect_response_parts 500-on-body-error contract. + Some(Err(_)) => return error_wire(500, "response body stream error"), + None => break, + } + } + out +} + +/// Outcome of [`dispatch_into_async`] / [`dispatch_into`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirectWriteResult { + /// A complete wire response occupies `out[0..n]`. + Complete(usize), + /// The response needs `required` bytes and `out` was too small. + /// `out` contents are **undefined** (a prefix may have been + /// written). `required` is exact — a retry with a buffer of at + /// least this size succeeds, but **re-runs the handler**. + Overflow(usize), +} + +/// Sync wrapper around [`dispatch_into_async`] for FFI callers that +/// own a [`tokio::runtime::Runtime`]. +pub fn dispatch_into( + input: Vec, + out: &mut [u8], + runtime: &tokio::runtime::Runtime, +) -> DirectWriteResult { + runtime.block_on(dispatch_into_async(input, out)) +} + +/// Dispatch a wire-format request and write the wire response +/// **directly into `out`** — the zero-materialisation sibling of +/// [`dispatch_from_bytes_async`]. +/// +/// On the success path the response is never assembled in an +/// intermediate `Vec`: the wire header is written to `out[0..h]` as +/// soon as axum produces status + headers, then each body frame is +/// copied straight to its final offset. Compared with +/// `dispatch_from_bytes_async` + caller-side copy, this removes one +/// full response memcpy and the response-sized allocation. +/// +/// # Exceptions to direct writing +/// +/// * **`422` responses** are materialised first so the +/// `validation_errors` hoisting into the wire header (see +/// [`dispatch_from_bytes`]) is preserved byte-for-byte — validation +/// failures are tiny and cold, correctness wins. +/// * **Pre-dispatch errors** (malformed wire, bad version, unknown +/// app, invalid method) write the small `error_wire` response. +/// +/// # Overflow semantics +/// +/// If `out` is too small the **exact** required size is reported via +/// [`DirectWriteResult::Overflow`]. An exact-length body (a `Full` +/// response / explicit `Content-Length`) reports it immediately from the +/// body's size hint **without draining**; an unknown-length (streaming) +/// body is drained (counting, not writing) to compute the size. Either +/// way the handler has already run; retrying runs it again — callers must +/// gate retries on idempotency. +pub async fn dispatch_into_async(input: Vec, out: &mut [u8]) -> DirectWriteResult { + // Ingress cap (defense-in-depth) — same policy as + // `dispatch_from_bytes_async`; 413 written into the caller buffer. + if let Some(err) = check_ingress_cap(input.len()) { + return write_wire_into(out, &err); + } + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => return write_wire_into(out, &error_wire(400, &msg)), + }; + let (header, router) = match parse_validate_resolve(&header_bytes) { + Ok(parts) => parts, + Err(wire) => return write_wire_into(out, &wire), + }; + + // Content-Type defaulting (body present, no content-type → + // application/json) is applied inside dispatch_and_split, which detects + // the header during its build pass; the body's emptiness is known here, + // so we just signal that a non-empty body should default. + let default_json_when_absent = !body_bytes.is_empty(); + + // Owned path: share the borrowed path's sub-`Bytes` with the URI builder + // (no query) so the URI is built zero-copy — see `dispatch_from_bytes_async`. + let path_bytes = if header.query.is_empty() { + slice_from_owner(&header_bytes, &header.path) + } else { + None + }; + + let (status, headers, metadata, body) = match dispatch_and_split( + router, + &header.method, + &header.path, + &header.query, + path_bytes, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + Body::from(body_bytes), + default_json_when_absent, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return write_wire_into(out, &error_wire(status, &msg)), + }; + + finish_direct_write(out, status, headers, metadata, body).await +} + +/// Dispatch a wire request from a **borrowed** input slice, writing the +/// wire response directly into `out` — the zero-input-copy sibling of +/// [`dispatch_into_async`]. +/// +/// Where [`dispatch_into_async`] takes an owned `Vec`, this borrows +/// `input` for the whole call: the wire header is parsed **in place** (no +/// copy) and only the request **body** region is copied into an owned +/// [`Bytes`] (axum's `Body` requires `'static` ownership). A **bodyless** +/// request — the common DIRECT `GET` — therefore copies nothing at all, +/// and any request saves the header-region copy `dispatch_into_async`'s +/// owned `Vec` pays. +/// +/// # Safety / lifetime +/// +/// The returned future borrows `input`; the caller MUST keep `input` +/// valid until the future completes. The JNI direct-buffer caller +/// satisfies this by pinning the source `ByteBuffer` (a live local ref) +/// for the entire `block_on`. +/// +/// Byte-identical to [`dispatch_into_async`] for the same wire bytes; all +/// the same error / `422` / overflow semantics apply. +pub async fn dispatch_into_async_borrowed(input: &[u8], out: &mut [u8]) -> DirectWriteResult { + // Ingress cap (defense-in-depth) — same policy as `dispatch_into_async`. + if let Some(err) = check_ingress_cap(input.len()) { + return write_wire_into(out, &err); + } + let (header_bytes, body_bytes) = match split_wire_borrowed(input) { + Ok(parts) => parts, + Err(msg) => return write_wire_into(out, &error_wire(400, &msg)), + }; + let (header, router) = match parse_validate_resolve(header_bytes) { + Ok(parts) => parts, + Err(wire) => return write_wire_into(out, &wire), + }; + + // dispatch_and_split detects Content-Type during its build pass; we + // only signal that a non-empty body should default to JSON. + let default_json_when_absent = !body_bytes.is_empty(); + + // Borrowed path: the header is parsed in place (borrowing `input`); + // only the body region is copied into an owned `Bytes`. An empty + // body (the common bodyless GET) allocates nothing. + let body = if body_bytes.is_empty() { + Body::empty() + } else { + Body::from(Bytes::copy_from_slice(body_bytes)) + }; + + // Borrowed path: `input` is not owned, so there is no request-lifetime + // `Bytes` to share into the URI — pass `None` and let `build_uri` parse the + // borrowed path (the zero-copy URI win applies only to the owned paths). + let (status, headers, metadata, resp_body) = match dispatch_and_split( + router, + &header.method, + &header.path, + &header.query, + None, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + body, + default_json_when_absent, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return write_wire_into(out, &error_wire(status, &msg)), + }; + + finish_direct_write(out, status, headers, metadata, resp_body).await +} + +/// Shared tail of the direct-write dispatchers ([`dispatch_into_async`] +/// and [`dispatch_into_async_borrowed`]): `422` responses are materialised +/// so `validation_errors` hoisting is preserved byte-for-byte; every other +/// status streams status + headers + body frames straight into `out`, +/// reporting the exact required size on overflow. +async fn finish_direct_write( + out: &mut [u8], + status: u16, + headers: http::HeaderMap, + metadata: ResponseMetadata, + mut body: Body, +) -> DirectWriteResult { + if status == 422 { + // Materialise to preserve validation_errors hoisting in the + // wire header — identical bytes to dispatch_from_bytes. + let Ok(collected) = body.collect().await else { + // Body aborted mid-collect: a failed 422 must surface as a 500, + // never as a clean (empty-bodied) 422 — same "truncated/failed + // response is never a success" contract as the streaming path + // below and `collect_response_parts`. + return write_wire_into(out, &error_wire(500, "response body stream error")); + }; + let body_bytes = collected.to_bytes(); + let wire = to_wire_bytes((status, headers, body_bytes, metadata)); + return write_wire_into(out, &wire); + } + + // Write the wire header straight into `out` — no intermediate Vec + // and no second copy. `header_total` is the exact header byte count + // whether or not it fit, so overflow reporting stays exact. + let header_total = write_wire_header_into_slice(out, status, &headers, &metadata); + let mut written = if header_total <= out.len() { + header_total + } else { + 0 + }; + let mut required = header_total; + + // Fast overflow: when the body length is known exactly (a `Full` body / + // explicit `Content-Length`) and the response cannot fit, report the + // exact required size immediately instead of draining every frame just + // to count bytes — this is the undersized-buffer retry path the pooled + // JNI `dispatchDirect` takes. Unknown-length (streaming) bodies have no + // exact hint and fall through to the drain loop unchanged. + if let Some(exact) = body.size_hint().exact() { + let required_u64 = u64::try_from(header_total) + .unwrap_or(u64::MAX) + .saturating_add(exact); + if required_u64 > u64::try_from(out.len()).unwrap_or(u64::MAX) { + return DirectWriteResult::Overflow( + usize::try_from(required_u64).unwrap_or(usize::MAX), + ); + } + } + + loop { + match body.frame().await { + Some(Ok(frame)) => { + if let Some(data) = frame.data_ref() + && !data.is_empty() + { + let len = data.len(); + // Write only while the output is still contiguous + // (`written == required` ⇒ nothing has been skipped yet). + // `checked_add` guards the bounds test against a + // pathological frame length wrapping `usize`; `written` + // then stays ≤ `out.len()` so the in-place add cannot + // overflow. + if written == required + && written.checked_add(len).is_some_and(|end| end <= out.len()) + { + out[written..written + len].copy_from_slice(data); + written += len; + } + // Saturating so an (impossible-in-practice) cumulative + // overflow reports `Overflow(usize::MAX)` rather than + // wrapping to a bogus small required size. + required = required.saturating_add(len); + } + } + // Response body aborted mid-stream. Nothing has been committed to + // the caller yet (we write into `out` and only return at the end), + // so discard the partial write and emit a 500 error wire instead + // of reporting truncated bytes as a successful response. + Some(Err(_)) => { + let wire = error_wire(500, "response body stream error"); + return write_wire_into(out, &wire); + } + None => break, + } + } + + if written == required { + DirectWriteResult::Complete(written) + } else { + DirectWriteResult::Overflow(required) + } +} + +/// Copy a fully-assembled wire response into `out`, or report the +/// exact required size. +fn write_wire_into(out: &mut [u8], wire: &[u8]) -> DirectWriteResult { + if wire.len() <= out.len() { + out[..wire.len()].copy_from_slice(wire); + DirectWriteResult::Complete(wire.len()) + } else { + DirectWriteResult::Overflow(wire.len()) + } +} diff --git a/crates/vespera_inprocess/src/envelope.rs b/crates/vespera_inprocess/src/envelope.rs new file mode 100644 index 00000000..c571361c --- /dev/null +++ b/crates/vespera_inprocess/src/envelope.rs @@ -0,0 +1,80 @@ +//! Public request/response envelope types for the direct (text) API. + +use std::borrow::Cow; +use std::collections::{BTreeMap, HashMap}; + +use serde::{Deserialize, Serialize}; + +// ── Envelope Types ─────────────────────────────────────────────────── + +/// Inbound request envelope (direct-API path). +#[derive(Debug, Default, Clone, Deserialize)] +pub struct RequestEnvelope { + pub method: String, + pub path: String, + #[serde(default)] + pub query: String, + #[serde(default)] + pub headers: HashMap, + #[serde(default)] + pub body: String, +} + +/// Response header value — single string or multiple values. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum HeaderValue { + Single(String), + Multi(Vec), +} + +/// Metadata included in every response envelope. +/// +/// `version` is a [`Cow`] so the engine can attach its own version +/// (`CARGO_PKG_VERSION`, a `&'static str`) without a per-response heap +/// allocation, while callers constructing envelopes manually can still +/// supply owned strings. +#[derive(Debug, Clone, Serialize)] +pub struct ResponseMetadata { + pub version: Cow<'static, str>, +} + +impl ResponseMetadata { + /// Metadata carrying this crate's compile-time version — zero + /// allocation (borrows the `'static` version string). + #[must_use] + pub const fn current() -> Self { + Self { + version: Cow::Borrowed(env!("CARGO_PKG_VERSION")), + } + } +} + +/// Outbound response envelope. +/// +/// `body` carries the response body decoded as UTF-8 text. For +/// binary responses that are not valid UTF-8, `body` will be the +/// empty string — callers that need raw bytes must use the binary +/// wire path ([`dispatch_from_bytes`]) instead of [`dispatch_typed`] +/// / [`dispatch_owned`]. +#[derive(Debug, Serialize)] +pub struct ResponseEnvelope { + pub status: u16, + pub headers: BTreeMap, + /// UTF-8 text body. Empty when the upstream response body is not + /// valid UTF-8 (binary responses). Use the binary wire path for + /// faithful byte round-trips. + pub body: String, + pub metadata: ResponseMetadata, +} + +/// Build an error [`ResponseEnvelope`] with status 500. +#[must_use] +pub fn error_envelope(message: &str) -> ResponseEnvelope { + ResponseEnvelope { + status: 500, + headers: BTreeMap::new(), + body: message.to_owned(), + metadata: ResponseMetadata::current(), + } +} diff --git a/crates/vespera_inprocess/src/internal.rs b/crates/vespera_inprocess/src/internal.rs new file mode 100644 index 00000000..a2c8c9ec --- /dev/null +++ b/crates/vespera_inprocess/src/internal.rs @@ -0,0 +1,597 @@ +//! Internal dispatch plumbing shared by every public entry point: +//! request building, router oneshot driving, and response collection. + +use std::collections::BTreeMap; +use std::collections::btree_map::Entry; +use std::ops::ControlFlow; + +use axum::body::Body; +use bytes::Bytes; +use http::{HeaderName, Method, Request, Uri, header::CONTENT_TYPE}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +use crate::Router; +use crate::envelope::{HeaderValue, ResponseEnvelope, ResponseMetadata}; + +// ── Internal Helpers ───────────────────────────────────────────────── + +/// Raw response parts on the wire path. Headers stay as the owned +/// [`http::HeaderMap`] taken from `Response::into_parts` — zero +/// per-header allocation; conversion to the public +/// `BTreeMap` shape happens only on the text +/// envelope path ([`to_response_envelope_text`]). +pub type ResponseParts = (u16, http::HeaderMap, Bytes, ResponseMetadata); + +/// Drive a [`Router`] with the supplied envelope fields and return +/// raw response parts. +/// +/// Returns `Err((status, msg))` only for pre-dispatch errors +/// (currently only "invalid HTTP method" → 405). Router/handler +/// errors cannot occur because axum routers are +/// `Service<_, Error = Infallible>`. +pub async fn dispatch_parts<'h>( + router: Router, + method_str: &str, + path: &str, + query: &str, + headers: impl Iterator, + body_bytes: Bytes, +) -> Result { + let request = build_request_from_bytes(method_str, path, query, headers, body_bytes)?; + + let response = match router.oneshot(request).await { + Ok(response) => response, + // axum routers are `Service<_, Error = Infallible>`; the `Err` + // variant is uninhabited, so this match is exhaustive and emits + // no panic/unwind site on this FFI-adjacent hot path. + Err(err) => match err {}, + }; + + collect_response_parts(response).await +} + +/// Start a request builder with method + URI. When `query` is empty +/// the borrowed `path` feeds `Uri` parsing directly — no intermediate +/// `String`; otherwise a single exact-capacity join is allocated. +#[cfg(any(test, feature = "bench-support"))] +fn request_builder(method: Method, path: &str, query: &str) -> http::request::Builder { + let builder = Request::builder().method(method); + if query.is_empty() { + builder.uri(path) + } else { + let mut uri = String::with_capacity(path.len() + 1 + query.len()); + uri.push_str(path); + uri.push('?'); + uri.push_str(query); + builder.uri(uri) + } +} + +/// Parse the request [`Uri`] from `path` (+ optional `query`), mirroring +/// [`request_builder`]'s borrowed-path optimization: an empty query parses +/// `path` directly (no intermediate `String`); otherwise a single +/// exact-capacity join is allocated. A malformed path/query that `http` +/// rejects becomes `Err((400, _))`, upholding the "every failure returns a +/// wire response" contract. +fn build_uri(path: &str, query: &str) -> Result { + let parsed = if query.is_empty() { + Uri::try_from(path) + } else { + let mut uri = String::with_capacity(path.len() + 1 + query.len()); + uri.push_str(path); + uri.push('?'); + uri.push_str(query); + Uri::try_from(uri) + }; + parsed.map_err(|e| (400, format!("invalid request: {e}"))) +} + +/// Build the axum request shared by the buffered ([`dispatch_parts`]) and +/// response-streaming ([`dispatch_response_streaming`]) paths — both take a +/// fully-buffered [`Bytes`] body and default a missing `Content-Type`. +/// +/// One borrowed-iterator pass applies every header while detecting +/// `Content-Type` (case-insensitive, RFC 7230 §3.2); a non-empty body with +/// no `Content-Type` defaults to `application/json`. Returns `Err((405, _))` +/// for an unparseable method and `Err((400, _))` for a malformed path / header, +/// upholding the "every failure returns a wire response" contract. +/// +/// Constructs the [`Request`] **directly** — `Request::new(body)` then +/// in-place method / URI / header assignment — instead of threading the +/// `http::request::Builder` state machine, which re-checks an internal +/// `Result` and is moved by value on every `.method`/`.uri`/`.header` +/// call. The `HeaderMap` is pre-reserved from the header count so insertion +/// never triggers an incremental grow; a bodyless, headerless request +/// reserves `0` and never allocates a bucket (preserving the DIRECT-`GET` +/// zero-allocation sweet spot). Header names/values are parsed with the same +/// `HeaderName::from_bytes` / `HeaderValue::from_str` the builder used and are +/// `append`ed (not `insert`ed), so the built request is byte-identical +/// including duplicate-name multi-value semantics. `#[inline]` so the two +/// call sites keep inlined codegen. +#[inline] +fn build_request_from_bytes<'h>( + method_str: &str, + path: &str, + query: &str, + headers: impl Iterator, + body_bytes: Bytes, +) -> Result, (u16, String)> { + let Ok(http_method) = method_str.parse::() else { + return Err(( + 405, + format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), + )); + }; + let uri = build_uri(path, query)?; + let body_is_empty = body_bytes.is_empty(); + + let mut request = Request::new(Body::from(body_bytes)); + *request.method_mut() = http_method; + *request.uri_mut() = uri; + + // Reserve exactly what we append: the wire headers plus, for a non-empty + // body, the possible default content-type. A bodyless, headerless + // request reserves 0 and never allocates a HeaderMap bucket. + let reserve = headers + .size_hint() + .0 + .saturating_add(usize::from(!body_is_empty)); + let header_map = request.headers_mut(); + if reserve > 0 { + header_map.reserve(reserve); + } + + // Case-insensitive Content-Type detection (RFC 7230 §3.2), tracked + // inside the single header pass. + let mut has_content_type = false; + for (name, value) in headers { + let header_name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|e| (400, format!("invalid request: {e}")))?; + let header_value = http::HeaderValue::from_str(value) + .map_err(|e| (400, format!("invalid request: {e}")))?; + // `HeaderName::from_bytes` already ASCII-lowercased the name, so the + // `== CONTENT_TYPE` standard-header comparison replaces the raw + // `eq_ignore_ascii_case` byte-fold scan with a (typically) cheap + // standard-header discriminant compare. Behaviour is identical: a + // name that case-insensitively equals "content-type" is always a + // valid token that `from_bytes` normalises to `CONTENT_TYPE`, and the + // comparison still happens before `append` consumes `header_name`. + has_content_type = has_content_type || header_name == CONTENT_TYPE; + header_map.append(header_name, header_value); + } + if !body_is_empty && !has_content_type { + header_map.append( + CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + } + Ok(request) +} + +/// **Bench-only** `http::request::Builder` twin of +/// [`build_request_from_bytes`], retained solely as the "before" arm of the +/// `request_build_ab` criterion A/B (same-run, noise-robust — mirroring the +/// `wire_header_serde` group's hand-vs-`serde_json` twin). Routes the request +/// through the builder state machine the production path replaced; produces a +/// byte-identical request. Not used on any production path. +#[cfg(any(test, feature = "bench-support"))] +fn build_request_from_bytes_builder_old<'h>( + method_str: &str, + path: &str, + query: &str, + headers: impl Iterator, + body_bytes: Bytes, +) -> Result, (u16, String)> { + let Ok(http_method) = method_str.parse::() else { + return Err(( + 405, + format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), + )); + }; + let mut builder = request_builder(http_method, path, query); + let mut has_content_type = false; + for (name, value) in headers { + has_content_type = has_content_type || name.eq_ignore_ascii_case("content-type"); + builder = builder.header(name, value); + } + if !body_bytes.is_empty() && !has_content_type { + builder = builder.header("content-type", "application/json"); + } + builder + .body(Body::from(body_bytes)) + .map_err(|e| (400, format!("invalid request: {e}"))) +} + +/// Sum a built request's method / path / query / header byte lengths so the +/// `request_build_ab` A/B cannot be optimised down to a partial build. +/// Bench-only. +#[cfg(any(test, feature = "bench-support"))] +fn request_field_len_sum(req: &Request) -> usize { + let mut acc = req.method().as_str().len() + req.uri().path().len(); + if let Some(query) = req.uri().query() { + acc += query.len(); + } + for (name, value) in req.headers() { + acc += name.as_str().len() + value.len(); + } + acc +} + +/// Bench A/B: production direct-construction request build cost. Returns a +/// summed length so the optimiser cannot elide the build. Bench-only. +#[cfg(any(test, feature = "bench-support"))] +#[doc(hidden)] +#[must_use] +pub fn bench_build_request_new( + method: &str, + path: &str, + query: &str, + headers: &[(&str, &str)], + body: Bytes, +) -> usize { + build_request_from_bytes(method, path, query, headers.iter().copied(), body) + .map_or(usize::MAX, |req| request_field_len_sum(&req)) +} + +/// Bench A/B: previous `http::request::Builder` request build cost. +/// Bench-only. +#[cfg(any(test, feature = "bench-support"))] +#[doc(hidden)] +#[must_use] +pub fn bench_build_request_old( + method: &str, + path: &str, + query: &str, + headers: &[(&str, &str)], + body: Bytes, +) -> usize { + build_request_from_bytes_builder_old(method, path, query, headers.iter().copied(), body) + .map_or(usize::MAX, |req| request_field_len_sum(&req)) +} + +/// Drive a [`Router`] and stream response body chunks through +/// `on_chunk`, returning the status/headers/metadata once the body +/// stream finishes. +/// +/// Same pre-dispatch error semantics as [`dispatch_parts`] (invalid +/// HTTP method → `Err((405, ...))`). A **response body stream error** +/// mid-drain returns `Err((500, ...))` so the caller emits a 500 wire +/// response instead of reporting the partially-streamed body as a +/// success — a truncated body must never be presented as complete. +/// (Chunks emitted via `on_chunk` before the error have already left, +/// but the 500 status the caller returns signals the failure.) +pub async fn dispatch_response_streaming<'h, F>( + router: Router, + method_str: &str, + path: &str, + query: &str, + headers: impl Iterator, + body_bytes: Bytes, + on_chunk: &mut F, +) -> Result<(u16, http::HeaderMap, ResponseMetadata), (u16, String)> +where + F: FnMut(&[u8]) -> ControlFlow<()>, +{ + let request = build_request_from_bytes(method_str, path, query, headers, body_bytes)?; + + let response = match router.oneshot(request).await { + Ok(response) => response, + // axum routers are `Service<_, Error = Infallible>`; the `Err` + // variant is uninhabited, so this match is exhaustive and emits + // no panic/unwind site on this FFI-adjacent hot path. + Err(err) => match err {}, + }; + + let (parts, mut body) = response.into_parts(); + + // Stream body chunks: pull frames one at a time and surface only + // data frames (trailers are dropped — wire format does not carry + // them). A frame error means the body aborted mid-stream; propagate + // it as a 500 so a truncated response is never reported as a clean + // success. + loop { + match body.frame().await { + Some(Ok(frame)) => { + if let Some(data) = frame.data_ref() + && !data.is_empty() + && on_chunk(data.as_ref()).is_break() + { + // The chunk sink asked to stop EARLY (e.g. the host's + // OutputStream failed mid-stream). The bytes already + // delivered are truncated, so surface a 500 — exactly + // like the body-error arm below — instead of falling + // through to the original success header, which would + // report a short, truncated response as a clean success. + return Err(( + 500, + "response body sink stopped before completion".to_owned(), + )); + } + } + Some(Err(_)) => { + return Err((500, "response body stream error".to_owned())); + } + None => break, + } + } + + Ok(( + parts.status.as_u16(), + parts.headers, + ResponseMetadata::current(), + )) +} + +/// Collapse an [`http::HeaderMap`] into the wire's name → value map. +/// Headers with repeated names (e.g. `set-cookie`) are preserved as +/// [`HeaderValue::Multi`] so their semantics survive the conversion. +fn collect_header_map(headers: &http::HeaderMap) -> BTreeMap { + let mut resp_headers: BTreeMap = BTreeMap::new(); + for (name, value) in headers { + let val_str = value.to_str().unwrap_or("").to_owned(); + match resp_headers.entry(name.as_str().to_owned()) { + Entry::Vacant(e) => { + e.insert(HeaderValue::Single(val_str)); + } + Entry::Occupied(mut e) => { + let slot = e.get_mut(); + let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { + HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), + HeaderValue::Multi(mut v) => { + v.push(val_str); + HeaderValue::Multi(v) + } + }; + *slot = new_slot; + } + } + } + resp_headers +} + +/// Collect status, headers, body bytes, and metadata from an axum +/// response. Headers with repeated names are collapsed into +/// [`HeaderValue::Multi`] so semantics (e.g. `set-cookie`) are +/// preserved. +/// +/// A body-stream error while collecting returns `Err((500, _))` instead +/// of silently yielding an empty body — a truncated/failed response must +/// never be reported as a clean success. This mirrors the +/// response-streaming path ([`dispatch_response_streaming`]), which +/// already surfaces mid-stream body errors as a 500. +async fn collect_response_parts( + response: axum::response::Response, +) -> Result { + let (parts, body) = response.into_parts(); + + let body_bytes = body + .collect() + .await + .map(http_body_util::Collected::to_bytes) + .map_err(|_| (500u16, "response body stream error".to_owned()))?; + + Ok(( + parts.status.as_u16(), + parts.headers, + body_bytes, + ResponseMetadata::current(), + )) +} + +/// Adapter: response parts → text envelope. Non-UTF-8 bodies become +/// the empty string. The owned-`String` header conversion happens +/// only here — the wire path serializes straight from the +/// [`http::HeaderMap`]. +pub fn to_response_envelope_text(parts: ResponseParts) -> ResponseEnvelope { + let (status, headers, body_bytes, metadata) = parts; + // `Vec::from(Bytes)` reuses the underlying buffer when the `Bytes` + // is uniquely owned (the common case for a collected response body), + // copying only for a shared/static slice — unlike `to_vec()`, which + // always allocates and copies. Semantics preserved: a non-UTF-8 + // body still yields the empty string. + let body = String::from_utf8(Vec::from(body_bytes)).unwrap_or_default(); + ResponseEnvelope { + status, + headers: collect_header_map(&headers), + body, + metadata, + } +} + +/// Dispatch a request and split the response into +/// `(status, headers, metadata, body)` — exposing `axum::body::Body` +/// so callers can stream it themselves (vs. collecting it eagerly). +/// +/// Used by the `*_with_header` streaming variants which need to emit +/// the wire-format header **before** body bytes start flowing. +/// +/// `default_json_when_absent` requests `content-type: application/json` +/// defaulting (mirroring [`dispatch_parts`]'s defaulting). This function +/// detects whether the caller's `headers` already carry a `Content-Type` +/// **during its single header-insertion pass** and appends the default +/// only when the flag is set AND none was present — folding in the +/// content-type detection each caller used to run as a separate pre-scan. +/// Callers that know the body is non-empty pass `!body.is_empty()`; +/// streaming callers whose body emptiness is unknowable up front pass +/// `true` (default whenever absent). +// 8 params: the request line (method / path / query / path_bytes), the +// borrowed header iterator, the body, and the content-type-default flag are +// each distinct per-request inputs. Bundling them into a struct would add +// indirection on this hot path without removing any genuinely-needed data. +#[allow(clippy::too_many_arguments)] +pub async fn dispatch_and_split<'h>( + router: Router, + method_str: &str, + path: &str, + query: &str, + path_bytes: Option, + headers: impl Iterator, + body: Body, + default_json_when_absent: bool, +) -> Result<(u16, http::HeaderMap, ResponseMetadata, Body), (u16, String)> { + let Ok(http_method) = method_str.parse::() else { + return Err(( + 405, + format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), + )); + }; + // Same contract as dispatch_parts: a malformed path/header must surface as + // a 400 wire response, not a panic. + // + // `path_bytes` is `Some` only on the OWNED wire path with an empty query + // and a path whose bytes already live in the request's owning `Bytes` + // (a borrowed `Cow` sliced from the wire header — see `slice_from_owner`). + // Building the `Uri` by SHARING those bytes skips the `Bytes::copy_from_slice` + // that `Uri::try_from(&str)` performs — one fewer per-request allocation. + // The parsed URI is byte-identical (same origin-form/absolute parse as + // `build_uri`); any owned/escaped path or non-empty query passes `None` and + // falls back to the copying join. + let uri = match path_bytes { + Some(bytes) => { + Uri::from_maybe_shared(bytes).map_err(|e| (400, format!("invalid request: {e}")))? + } + None => build_uri(path, query)?, + }; + + // Direct construction — see [`build_request_from_bytes`]: bypass the + // `http::request::Builder` state machine and pre-reserve the HeaderMap so + // header insertion never triggers an incremental grow. Headers are + // `append`ed (multi-value preserving); the body is opaque here, so + // content-type defaulting follows the caller's `default_json_content_type` + // flag rather than body-emptiness detection. + let mut request = Request::new(body); + *request.method_mut() = http_method; + *request.uri_mut() = uri; + + let reserve = headers + .size_hint() + .0 + .saturating_add(usize::from(default_json_when_absent)); + let header_map = request.headers_mut(); + if reserve > 0 { + header_map.reserve(reserve); + } + // Detect Content-Type during the single insertion pass (RFC 7230 §3.2 + // case-insensitive) instead of a separate caller-side pre-scan. + let mut has_content_type = false; + for (name, value) in headers { + let header_name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|e| (400, format!("invalid request: {e}")))?; + let header_value = http::HeaderValue::from_str(value) + .map_err(|e| (400, format!("invalid request: {e}")))?; + // `HeaderName::from_bytes` already ASCII-lowercased the name, so the + // `== CONTENT_TYPE` standard-header comparison replaces the raw + // `eq_ignore_ascii_case` byte-fold scan with a (typically) cheap + // standard-header discriminant compare. Behaviour is identical: a + // name that case-insensitively equals "content-type" is always a + // valid token that `from_bytes` normalises to `CONTENT_TYPE`, and the + // comparison still happens before `append` consumes `header_name`. + has_content_type = has_content_type || header_name == CONTENT_TYPE; + header_map.append(header_name, header_value); + } + if default_json_when_absent && !has_content_type { + header_map.append( + CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + } + + let response = match router.oneshot(request).await { + Ok(response) => response, + // axum routers are `Service<_, Error = Infallible>`; the `Err` + // variant is uninhabited, so this match is exhaustive and emits + // no panic/unwind site on this FFI-adjacent hot path. + Err(err) => match err {}, + }; + + let (parts, body) = response.into_parts(); + Ok(( + parts.status.as_u16(), + parts.headers, + ResponseMetadata::current(), + body, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn block_on(fut: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build current-thread runtime") + .block_on(fut) + } + + /// A wire `path` that cannot be parsed into an [`http::Uri`] (a raw + /// space is illegal) must surface as an `Err((4xx, _))` the caller + /// turns into a wire response — never a panic. Guards the + /// "all failure modes return a valid wire response" contract for + /// every `request_builder` call site. + #[test] + fn malformed_path_returns_error_not_panic() { + let result = block_on(async { + dispatch_parts( + crate::Router::new(), + "GET", + "bad path with spaces", + "", + std::iter::empty(), + Bytes::new(), + ) + .await + }); + match result { + Err((status, _)) => assert!( + (400..500).contains(&status), + "expected 4xx for malformed path, got {status}" + ), + Ok(_) => panic!("malformed path should not produce a successful dispatch"), + } + } + + #[test] + fn malformed_path_streaming_returns_error_not_panic() { + let result = block_on(async { + let mut sink = |_: &[u8]| ControlFlow::Continue(()); + dispatch_response_streaming( + crate::Router::new(), + "GET", + "bad path with spaces", + "", + std::iter::empty(), + Bytes::new(), + &mut sink, + ) + .await + }); + assert!( + result.is_err(), + "streaming dispatch must reject malformed path" + ); + } + + #[test] + fn malformed_path_split_returns_error_not_panic() { + let result = block_on(async { + dispatch_and_split( + crate::Router::new(), + "GET", + "bad path with spaces", + "", + None, + std::iter::empty(), + Body::empty(), + false, + ) + .await + }); + assert!( + result.is_err(), + "dispatch_and_split must reject malformed path" + ); + } +} diff --git a/crates/vespera_inprocess/src/lib.rs b/crates/vespera_inprocess/src/lib.rs index c47b6c12..188d6cd8 100644 --- a/crates/vespera_inprocess/src/lib.rs +++ b/crates/vespera_inprocess/src/lib.rs @@ -13,6 +13,18 @@ //! empty string. Callers that need raw bytes must use the //! binary wire API below. //! +//! This API is intended for **in-process Rust embedding** where a +//! typed envelope is convenient. It is not the throughput-oriented +//! path: the response headers are materialised into an owned +//! `BTreeMap` and the body is decoded to a +//! `String`. **FFI / high-throughput callers should prefer the +//! binary wire API** ([`dispatch_from_bytes`] / [`dispatch_into`]), +//! which borrows the wire header, serialises response headers +//! straight from the `http::HeaderMap`, and carries the body as raw +//! bytes (no UTF-8 round-trip). Within the direct API itself, +//! prefer [`dispatch_owned`] over [`dispatch`] / [`dispatch_typed`] +//! to avoid cloning the request envelope. +//! //! 2. **Binary wire API** — [`dispatch_from_bytes`] is the //! zero-overhead FFI entry point. Wire format (request and //! response use the same layout): @@ -23,7 +35,7 @@ //! (request) { "v":1, "method", "path", //! "query"?, "headers"? } //! (response) { "v":1, "status", "headers", -//! "metadata" } +//! "metadata", "validation_errors"? } //! bytes 4+N..end : raw body bytes (UTF-8 text or binary — //! no encoding applied) //! ``` @@ -56,1200 +68,52 @@ //! [`Router::clone`], which is cheap because axum's router is //! internally `Arc`-shared. -use std::collections::HashMap; -use std::collections::hash_map::Entry; -use std::convert::Infallible; -use std::pin::Pin; -use std::sync::{LazyLock, RwLock}; -use std::task::{Context, Poll}; - -use axum::body::Body; -use bytes::Bytes; -use http::{Method, Request}; -use http_body::{Body as HttpBody, Frame}; -use http_body_util::BodyExt; -use serde::{Deserialize, Serialize}; -use tower::ServiceExt; +mod config; +mod dispatch; +mod envelope; +mod internal; +mod registry; +mod streaming; +mod wire; /// Re-export `axum::Router` so consumers don't need a direct axum dependency. pub use axum::Router; - -/// Wire format protocol version. The JSON header's `v` field MUST -/// equal this for requests; responses always emit this value. -const WIRE_VERSION: u8 = 1; - -/// Canonical name of the default app — used when the wire header -/// omits `"app"` or sets it to an empty string, and when callers use -/// the BC [`register_app`] entry point. -pub const DEFAULT_APP_NAME: &str = "_default"; - -/// Maximum allowed length of an app name (after trimming). Sized so -/// names fit comfortably in URL path segments and log lines. -const MAX_APP_NAME_LEN: usize = 64; - -// ── Envelope Types ─────────────────────────────────────────────────── - -/// Inbound request envelope (direct-API path). -#[derive(Debug, Default, Clone, Deserialize)] -pub struct RequestEnvelope { - pub method: String, - pub path: String, - #[serde(default)] - pub query: String, - #[serde(default)] - pub headers: HashMap, - #[serde(default)] - pub body: String, -} - -/// Response header value — single string or multiple values. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(untagged)] -pub enum HeaderValue { - Single(String), - Multi(Vec), -} - -/// Metadata included in every response envelope. -#[derive(Debug, Clone, Serialize)] -pub struct ResponseMetadata { - pub version: String, -} - -/// Outbound response envelope. -/// -/// `body` carries the response body decoded as UTF-8 text. For -/// binary responses that are not valid UTF-8, `body` will be the -/// empty string — callers that need raw bytes must use the binary -/// wire path ([`dispatch_from_bytes`]) instead of [`dispatch_typed`] -/// / [`dispatch_owned`]. -#[derive(Debug, Serialize)] -pub struct ResponseEnvelope { - pub status: u16, - pub headers: HashMap, - /// UTF-8 text body. Empty when the upstream response body is not - /// valid UTF-8 (binary responses). Use the binary wire path for - /// faithful byte round-trips. - pub body: String, - pub metadata: ResponseMetadata, -} - -// ── Wire Format Types (internal) ───────────────────────────────────── - -#[derive(Debug, Deserialize)] -struct WireRequestHeader { - /// Wire protocol version; clients MUST send 1. - #[serde(default)] - v: u8, - method: String, - path: String, - #[serde(default)] - query: String, - #[serde(default)] - headers: HashMap, - /// Optional name of the target app for multi-app routing. When - /// omitted (or empty), the request is dispatched to the default - /// app registered via [`register_app`]. Use [`register_app_named`] - /// to register additional named apps. - #[serde(default)] - app: Option, -} - -#[derive(Debug, Serialize)] -struct WireResponseHeader<'a> { - v: u8, - status: u16, - headers: &'a HashMap, - metadata: &'a ResponseMetadata, - /// Validation errors hoisted from a 422 JSON body so Java decoders - /// can read them with a single header parse. `None` for any other - /// status; the original body is preserved verbatim regardless. - #[serde(skip_serializing_if = "Option::is_none")] - validation_errors: Option>, -} - -/// One entry in the wire header's `validation_errors` array. Fields -/// are best-effort: missing values in the source body become `None`. -#[derive(Debug, Serialize)] -struct ValidationErrorItem { - path: String, - #[serde(skip_serializing_if = "Option::is_none")] - code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, -} - -// ── Dispatch (direct API — backward compatible) ────────────────────── - -/// Dispatch a [`RequestEnvelope`] through an axum [`Router`] and -/// return the serialised [`ResponseEnvelope`] JSON. -/// -/// This borrows the envelope and clones its owned fields before -/// passing them to the hot path. Callers that already own a -/// [`RequestEnvelope`] should prefer [`dispatch_owned`] to skip the -/// clone. -pub async fn dispatch(router: Router, envelope: &RequestEnvelope) -> String { - let result = dispatch_owned(router, envelope.clone()).await; - serde_json::to_string(&result).expect("ResponseEnvelope serialization is infallible") -} - -/// Typed dispatch — returns a [`ResponseEnvelope`] directly. -/// -/// See [`dispatch`] for the clone trade-off; prefer [`dispatch_owned`] -/// when the envelope is already owned. -pub async fn dispatch_typed(router: Router, envelope: &RequestEnvelope) -> ResponseEnvelope { - dispatch_owned(router, envelope.clone()).await -} - -/// Dispatch an owned [`RequestEnvelope`] — moves the envelope into -/// the HTTP request so the body, path, and headers are never cloned. -/// -/// This is the hot path used by callers (e.g. custom FFI transports) -/// that already own a freshly built envelope. -pub async fn dispatch_owned(router: Router, envelope: RequestEnvelope) -> ResponseEnvelope { - let parts = match dispatch_parts( - router, - &envelope.method, - envelope.path, - envelope.query, - envelope.headers, - envelope.body.into_bytes(), - ) - .await - { - Ok(parts) => parts, - Err((status, msg)) => { - return ResponseEnvelope { - status, - headers: HashMap::new(), - body: msg, - metadata: ResponseMetadata { - version: env!("CARGO_PKG_VERSION").to_owned(), - }, - }; - } +pub use config::{ + DEFAULT_STREAMING_CHANNEL_CAPACITY, DEFAULT_STREAMING_CHUNK_BYTES, + effective_streaming_channel_capacity, max_request_bytes, request_exceeds_limit, + set_max_request_bytes, set_streaming_channel_capacity, set_streaming_chunk_bytes, + streaming_channel_capacity, streaming_chunk_bytes, +}; +pub use dispatch::{ + DirectWriteResult, dispatch, dispatch_from_bytes, dispatch_from_bytes_async, dispatch_into, + dispatch_into_async, dispatch_into_async_borrowed, dispatch_owned, dispatch_typed, +}; +pub use envelope::{ + HeaderValue, RequestEnvelope, ResponseEnvelope, ResponseMetadata, error_envelope, +}; +pub use registry::{DEFAULT_APP_NAME, register_app, register_app_named, try_register_app_named}; +pub use streaming::{ + RequestChunk, StreamAbort, StreamOutcome, dispatch_bidirectional_streaming, + dispatch_bidirectional_streaming_closing, dispatch_bidirectional_streaming_with_header, + dispatch_bidirectional_streaming_with_header_closing, dispatch_streaming_async, + dispatch_streaming_with_header_async, +}; +pub use wire::error_wire; + +/// Bench-only surface for the same-run hand-rolled vs `serde_json` A/B in +/// `benches/dispatch.rs` (the `wire_header_serde` criterion group). +/// +/// **Not a stable public API** — these thin wrappers exist purely so the +/// criterion harness (a separate compilation target that can only see +/// `pub` items) can call both the hand-rolled and the retained +/// `serde_json` wire-header paths in the same measurement run. Hidden +/// from docs; do not depend on it. +#[cfg(any(test, feature = "bench-support"))] +#[doc(hidden)] +pub mod bench_support { + pub use crate::internal::{bench_build_request_new, bench_build_request_old}; + pub use crate::wire::hoist::{bench_hoist_new, bench_hoist_old}; + pub use crate::wire::{ + bench_parse_hand, bench_parse_serde, bench_write_hand, bench_write_serde, }; - to_response_envelope_text(parts) -} - -/// Build an error [`ResponseEnvelope`] with status 500. -#[must_use] -pub fn error_envelope(message: &str) -> ResponseEnvelope { - ResponseEnvelope { - status: 500, - headers: HashMap::new(), - body: message.to_owned(), - metadata: ResponseMetadata { - version: env!("CARGO_PKG_VERSION").to_owned(), - }, - } -} - -// ── App Factory (shared FFI pattern) ───────────────────────────────── - -/// Per-name router cache. Indexed by app name; the default app uses -/// [`DEFAULT_APP_NAME`] (`"_default"`). -/// -/// Uses [`RwLock`] (not [`OnceLock`]) so multiple named apps can be -/// registered after init time, while keeping dispatch reads -/// contention-free. The map is read on every dispatch and written -/// only during `register_app*` calls (typically at process startup). -/// -/// Lock poisoning recovery: every read path uses -/// `unwrap_or_else(|e| e.into_inner())` so a panic in a producer -/// thread does not lock out the dispatch hot path. Factory closures -/// are also invoked **outside** the write lock so a factory panic -/// cannot poison the map. -static APP_ROUTERS: LazyLock>> = - LazyLock::new(|| RwLock::new(HashMap::new())); - -/// Validate an app name for registration / lookup. -/// -/// Constraints: -/// - non-empty after trimming whitespace -/// - at most [`MAX_APP_NAME_LEN`] bytes -/// - ASCII alphanumeric, `_`, or `-` only -/// -/// Returns the trimmed name on success. -fn validate_app_name(name: &str) -> Result<&str, String> { - let trimmed = name.trim(); - if trimmed.is_empty() { - return Err("app name must not be empty".to_owned()); - } - if trimmed.len() > MAX_APP_NAME_LEN { - return Err(format!( - "app name too long: {} chars (max {MAX_APP_NAME_LEN})", - trimmed.len() - )); - } - if !trimmed - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { - return Err(format!( - "app name '{trimmed}' contains invalid characters (allowed: alphanumeric, '_', '-')" - )); - } - Ok(trimmed) -} - -/// Register the **default** global router factory. -/// -/// Equivalent to `register_app_named(DEFAULT_APP_NAME, factory)`. -/// Wire requests without an `"app"` header (or with `"app": ""`) are -/// routed here. -/// -/// Any FFI boundary (JNI, C, WASM) calls this once at init time, then -/// uses [`dispatch_from_bytes`] on each request. -/// -/// # Second-call semantics -/// -/// Calling `register_app` more than once is a **no-op** — the first -/// registration wins, the new factory closure is NOT invoked. Friendly -/// for environments that legitimately load the cdylib twice (hot-reloading -/// JVM hosts, plugin systems). -pub fn register_app(factory: F) -where - F: Fn() -> Router + Send + Sync + 'static, -{ - register_app_named(DEFAULT_APP_NAME, factory); -} - -/// Register a **named** global router factory for multi-app routing. -/// -/// Wire requests carrying `"app": ""` in their header are -/// dispatched to this router. Multiple named apps can coexist in -/// the same process; register each once at init time. -/// -/// # First-wins per name -/// -/// Calling this more than once with the same `name` is a no-op — the -/// first registration wins. Registering different names is the -/// supported multi-app pattern. -/// -/// # Panic safety -/// -/// The `factory` closure is invoked **outside** the internal -/// `RwLock`'s write guard. A panic in `factory` cannot poison the -/// map; the registration is simply discarded and the slot remains -/// available for retry. -/// -/// # Invalid names -/// -/// Names that fail [`validate_app_name`] (empty, > 64 bytes, or -/// containing characters outside `[A-Za-z0-9_-]`) are silently -/// discarded — registration is a no-op. Dispatch with a matching -/// invalid name will return a `400` wire response. -pub fn register_app_named(name: &str, factory: F) -where - F: Fn() -> Router + Send + Sync + 'static, -{ - let name = match validate_app_name(name) { - Ok(n) => n.to_owned(), - Err(_) => return, - }; - // Fast path: existence check under a read lock. - { - let map = APP_ROUTERS - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if map.contains_key(&name) { - return; - } - } - // Build the router OUTSIDE the write lock so a panicking factory - // cannot poison the map. - let router = factory(); - let mut map = APP_ROUTERS - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // Double-check: another thread may have inserted between our read - // and write. First-wins still holds — use Entry to avoid the - // map.contains_key + map.insert double lookup. - map.entry(name).or_insert(router); -} - -/// Resolve a [`Router`] for a wire request, applying default-app -/// fallback and name validation. Returns the cloned router (cheap — -/// axum's router is `Arc`-backed) on success, or a wire error response -/// (`400` for invalid name, `404` for unregistered name) on failure. -fn resolve_app_router(header: &WireRequestHeader) -> Result> { - let raw = header - .app - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or(DEFAULT_APP_NAME); - let name = match validate_app_name(raw) { - Ok(n) => n, - Err(msg) => return Err(error_wire(400, &format!("invalid app name: {msg}"))), - }; - let map = APP_ROUTERS - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - map.get(name).cloned().ok_or_else(|| { - error_wire( - 404, - &format!( - "no app registered with name '{name}' — \ - use register_app() for the default app or \ - register_app_named(name, factory) for additional apps" - ), - ) - }) -} - -// ── Binary Wire API ────────────────────────────────────────────────── - -/// Dispatch a wire-format request through the registered app and -/// return a wire-format response. -/// -/// Wire format: -/// ```text -/// bytes 0..4 : u32 BE = header_json byte length N -/// bytes 4..4+N : UTF-8 JSON -/// (request) { "v":1, "method", "path", -/// "query"?, "headers"? } -/// (response) { "v":1, "status", "headers", -/// "metadata" } -/// bytes 4+N..end : raw body bytes (UTF-8 text or binary — -/// no encoding applied) -/// ``` -/// -/// All failure modes return a valid wire-format response (length- -/// prefixed) so the caller's decoder never has to special-case -/// errors. Specifically: -/// -/// * input shorter than 4 bytes → 400 with explanatory body -/// * `header_len` exceeds input → 400 -/// * header JSON parse failure → 400 -/// * wire version mismatch → 400 -/// * unknown HTTP method → 405 -/// * no app registered → 500 -/// * router/handler errors → surfaced verbatim as response wire -pub fn dispatch_from_bytes(input: Vec, runtime: &tokio::runtime::Runtime) -> Vec { - runtime.block_on(dispatch_from_bytes_async(input)) -} - -/// **Streaming** sibling of [`dispatch_from_bytes_async`]. -/// -/// Drives the dispatch end-to-end like the non-streaming variant but -/// emits the response body **chunk-by-chunk via `on_chunk`** instead -/// of materialising it in a single `Vec`. Returns the wire-format -/// header bytes only (`[u32 BE header_len | header JSON]`) — the body -/// is delivered through the callback while the dispatch is in flight, -/// so a 1 GiB response is never resident in memory. -/// -/// `on_chunk` is invoked one or more times in arrival order; the -/// borrowed slice is valid only for the duration of each call and the -/// callback should treat it as ephemeral (e.g. write it to an -/// `OutputStream`, accumulate it on disk, …). -/// -/// Failure modes are identical to [`dispatch_from_bytes_async`] — -/// returns a valid wire-format error response (header + body) when -/// the wire input is malformed, the version is wrong, no app is -/// registered, or the handler reports a pre-dispatch error. In the -/// error path the body is included inside the returned bytes (not -/// streamed via `on_chunk`) because the error message is small. -/// -/// `on_chunk` is NOT called if the response body is empty. -pub async fn dispatch_streaming_async(input: Vec, mut on_chunk: F) -> Vec -where - F: FnMut(&[u8]), -{ - let (header, body_bytes) = match parse_wire_request(input) { - Ok(parts) => parts, - Err(msg) => return error_wire(400, &msg), - }; - if header.v != WIRE_VERSION { - return error_wire( - 400, - &format!( - "unsupported wire version: got {}, expected {WIRE_VERSION}", - header.v - ), - ); - } - let router = match resolve_app_router(&header) { - Ok(r) => r, - Err(wire) => return wire, - }; - let (status, headers, metadata) = match dispatch_response_streaming( - router, - &header.method, - header.path, - header.query, - header.headers, - body_bytes, - &mut on_chunk, - ) - .await - { - Ok(parts) => parts, - Err((status, msg)) => return error_wire(status, &msg), - }; - // Emit header-only wire bytes; body was streamed via on_chunk. - let header_view = WireResponseHeader { - v: WIRE_VERSION, - status, - headers: &headers, - metadata: &metadata, - // Streaming path does not hoist 422 validation errors — - // hoisting requires materialising the full body, which is - // antithetical to the streaming contract. Callers needing - // validation hoisting should use dispatch_from_bytes_async. - validation_errors: None, - }; - let header_json = - serde_json::to_vec(&header_view).expect("WireResponseHeader serialization is infallible"); - let header_len = - u32::try_from(header_json.len()).expect("response header JSON exceeds u32::MAX bytes"); - let mut out = Vec::with_capacity(4 + header_json.len()); - out.extend_from_slice(&header_len.to_be_bytes()); - out.extend_from_slice(&header_json); - out -} - -/// Async sibling of [`dispatch_from_bytes`]. Use this when the caller -/// is already inside a Tokio runtime (e.g. an axum handler embedding -/// another vespera router, or a tokio-spawned task in the JNI bridge's -/// async dispatch path). -/// -/// All failure modes return a valid wire-format response (same -/// guarantees as [`dispatch_from_bytes`]), including `500` when no app -/// is registered. -pub async fn dispatch_from_bytes_async(input: Vec) -> Vec { - // Wire-level checks first: malformed input must report parse - // errors regardless of whether an app is registered. - let (header, body_bytes) = match parse_wire_request(input) { - Ok(parts) => parts, - Err(msg) => return error_wire(400, &msg), - }; - if header.v != WIRE_VERSION { - return error_wire( - 400, - &format!( - "unsupported wire version: got {}, expected {WIRE_VERSION}", - header.v - ), - ); - } - let router = match resolve_app_router(&header) { - Ok(r) => r, - Err(wire) => return wire, - }; - let parts = match dispatch_parts( - router, - &header.method, - header.path, - header.query, - header.headers, - body_bytes, - ) - .await - { - Ok(parts) => parts, - Err((status, msg)) => return error_wire(status, &msg), - }; - to_wire_bytes(parts) -} - -/// Build a wire-format error response with a plain-text body. -/// -/// Used by [`dispatch_from_bytes`] for malformed input and by the -/// JNI bridge for panic fallback. The response always carries -/// `content-type: text/plain; charset=utf-8`. -#[must_use] -pub fn error_wire(status: u16, msg: &str) -> Vec { - let mut headers = HashMap::new(); - headers.insert( - "content-type".to_owned(), - HeaderValue::Single("text/plain; charset=utf-8".to_owned()), - ); - let metadata = ResponseMetadata { - version: env!("CARGO_PKG_VERSION").to_owned(), - }; - let parts = ( - status, - headers, - Bytes::from(msg.as_bytes().to_vec()), - metadata, - ); - to_wire_bytes(parts) -} - -// ── Internal Helpers ───────────────────────────────────────────────── - -type ResponseParts = (u16, HashMap, Bytes, ResponseMetadata); - -/// Drive a [`Router`] with the supplied envelope fields and return -/// raw response parts. -/// -/// Returns `Err((status, msg))` only for pre-dispatch errors -/// (currently only "invalid HTTP method" → 405). Router/handler -/// errors cannot occur because axum routers are -/// `Service<_, Error = Infallible>`. -async fn dispatch_parts( - router: Router, - method_str: &str, - path: String, - query: String, - headers: HashMap, - body_bytes: Vec, -) -> Result { - let Ok(http_method) = method_str.parse::() else { - return Err(( - 405, - format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), - )); - }; - - let uri = if query.is_empty() { - path - } else { - format!("{path}?{query}") - }; - - // Case-insensitive Content-Type detection (RFC 7230 §3.2). - let has_content_type = headers - .keys() - .any(|k| k.eq_ignore_ascii_case("content-type")); - - let mut builder = Request::builder().method(http_method).uri(&uri); - for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_str()); - } - if !body_bytes.is_empty() && !has_content_type { - builder = builder.header("content-type", "application/json"); - } - - let request = builder - .body(Body::from(body_bytes)) - .expect("request construction should not fail with valid URI"); - - let response = router - .oneshot(request) - .await - .expect("router error is Infallible"); - - Ok(collect_response_parts(response).await) -} - -/// Drive a [`Router`] and stream response body chunks through -/// `on_chunk`, returning the status/headers/metadata once the body -/// stream finishes. -/// -/// Same pre-dispatch error semantics as [`dispatch_parts`] (invalid -/// HTTP method → `Err((405, ...))`). Body stream errors are silently -/// ended (the consumer sees a truncated response) because they -/// indicate the upstream handler aborted; the headers/status that -/// were already collected remain accurate. -async fn dispatch_response_streaming( - router: Router, - method_str: &str, - path: String, - query: String, - headers: HashMap, - body_bytes: Vec, - on_chunk: &mut F, -) -> Result<(u16, HashMap, ResponseMetadata), (u16, String)> -where - F: FnMut(&[u8]), -{ - let Ok(http_method) = method_str.parse::() else { - return Err(( - 405, - format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), - )); - }; - - let uri = if query.is_empty() { - path - } else { - format!("{path}?{query}") - }; - - let has_content_type = headers - .keys() - .any(|k| k.eq_ignore_ascii_case("content-type")); - - let mut builder = Request::builder().method(http_method).uri(&uri); - for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_str()); - } - if !body_bytes.is_empty() && !has_content_type { - builder = builder.header("content-type", "application/json"); - } - - let request = builder - .body(Body::from(body_bytes)) - .expect("request construction should not fail with valid URI"); - - let response = router - .oneshot(request) - .await - .expect("router error is Infallible"); - - let version = env!("CARGO_PKG_VERSION").to_owned(); - let status = response.status().as_u16(); - - let mut resp_headers: HashMap = - HashMap::with_capacity(response.headers().len()); - for (name, value) in response.headers() { - let val_str = value.to_str().unwrap_or("").to_owned(); - match resp_headers.entry(name.as_str().to_owned()) { - Entry::Vacant(e) => { - e.insert(HeaderValue::Single(val_str)); - } - Entry::Occupied(mut e) => { - let slot = e.get_mut(); - let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { - HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), - HeaderValue::Multi(mut v) => { - v.push(val_str); - HeaderValue::Multi(v) - } - }; - *slot = new_slot; - } - } - } - - // Stream body chunks: pull frames one at a time and surface only - // data frames (trailers are dropped — wire format does not carry - // them). Frame errors or end-of-stream both terminate cleanly. - let mut body = response.into_body(); - while let Some(Ok(frame)) = body.frame().await { - if let Some(data) = frame.data_ref() - && !data.is_empty() - { - on_chunk(data.as_ref()); - } - } - - Ok((status, resp_headers, ResponseMetadata { version })) -} - -/// Collect status, headers, body bytes, and metadata from an axum -/// response. Headers with repeated names are collapsed into -/// [`HeaderValue::Multi`] so semantics (e.g. `set-cookie`) are -/// preserved. -async fn collect_response_parts(response: axum::response::Response) -> ResponseParts { - let version = env!("CARGO_PKG_VERSION").to_owned(); - let status = response.status().as_u16(); - - let mut resp_headers: HashMap = - HashMap::with_capacity(response.headers().len()); - for (name, value) in response.headers() { - let val_str = value.to_str().unwrap_or("").to_owned(); - match resp_headers.entry(name.as_str().to_owned()) { - Entry::Vacant(e) => { - e.insert(HeaderValue::Single(val_str)); - } - Entry::Occupied(mut e) => { - let slot = e.get_mut(); - let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { - HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), - HeaderValue::Multi(mut v) => { - v.push(val_str); - HeaderValue::Multi(v) - } - }; - *slot = new_slot; - } - } - } - - let body_bytes = response - .into_body() - .collect() - .await - .map(http_body_util::Collected::to_bytes) - .unwrap_or_default(); - - ( - status, - resp_headers, - body_bytes, - ResponseMetadata { version }, - ) -} - -/// Adapter: response parts → text envelope. Non-UTF-8 bodies become -/// the empty string. -fn to_response_envelope_text(parts: ResponseParts) -> ResponseEnvelope { - let (status, headers, body_bytes, metadata) = parts; - let body = String::from_utf8(body_bytes.to_vec()).unwrap_or_default(); - ResponseEnvelope { - status, - headers, - body, - metadata, - } -} - -/// Adapter: response parts → wire-format bytes. Layout: -/// `[u32 BE header_len | JSON header | raw body]`. -/// -/// For `status == 422` JSON responses we **best-effort** hoist any -/// `{"errors": [...]}` payload into the wire header's -/// `validation_errors` field — Java decoders can read validation -/// failures with a single header parse, while the original body is -/// preserved verbatim for clients that still rely on it. -fn to_wire_bytes(parts: ResponseParts) -> Vec { - let (status, headers, body_bytes, metadata) = parts; - let validation_errors = if status == 422 { - try_hoist_validation_errors(&headers, &body_bytes) - } else { - None - }; - let header = WireResponseHeader { - v: WIRE_VERSION, - status, - headers: &headers, - metadata: &metadata, - validation_errors, - }; - let header_json = - serde_json::to_vec(&header).expect("WireResponseHeader serialization is infallible"); - let header_len = - u32::try_from(header_json.len()).expect("response header JSON exceeds u32::MAX bytes"); - let mut out = Vec::with_capacity(4 + header_json.len() + body_bytes.len()); - out.extend_from_slice(&header_len.to_be_bytes()); - out.extend_from_slice(&header_json); - out.extend_from_slice(&body_bytes); - out -} - -/// Dispatch a request and split the response into -/// `(status, headers, metadata, body)` — exposing `axum::body::Body` -/// so callers can stream it themselves (vs. collecting it eagerly). -/// -/// Used by the `*_with_header` streaming variants which need to emit -/// the wire-format header **before** body bytes start flowing. -async fn dispatch_and_split( - router: Router, - method_str: &str, - path: String, - query: String, - headers: HashMap, - body: Body, -) -> Result<(u16, HashMap, ResponseMetadata, Body), (u16, String)> { - let Ok(http_method) = method_str.parse::() else { - return Err(( - 405, - format!("Method Not Allowed: '{method_str}' is not a valid HTTP method"), - )); - }; - - let uri = if query.is_empty() { - path - } else { - format!("{path}?{query}") - }; - - let mut builder = Request::builder().method(http_method).uri(&uri); - for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_str()); - } - - let request = builder - .body(body) - .expect("request construction should not fail with valid URI"); - - let response = router - .oneshot(request) - .await - .expect("router error is Infallible"); - - let version = env!("CARGO_PKG_VERSION").to_owned(); - let status = response.status().as_u16(); - - let mut resp_headers: HashMap = - HashMap::with_capacity(response.headers().len()); - for (name, value) in response.headers() { - let val_str = value.to_str().unwrap_or("").to_owned(); - match resp_headers.entry(name.as_str().to_owned()) { - Entry::Vacant(e) => { - e.insert(HeaderValue::Single(val_str)); - } - Entry::Occupied(mut e) => { - let slot = e.get_mut(); - let new_slot = match std::mem::replace(slot, HeaderValue::Single(String::new())) { - HeaderValue::Single(prev) => HeaderValue::Multi(vec![prev, val_str]), - HeaderValue::Multi(mut v) => { - v.push(val_str); - HeaderValue::Multi(v) - } - }; - *slot = new_slot; - } - } - } - - let body = response.into_body(); - Ok((status, resp_headers, ResponseMetadata { version }, body)) -} - -/// Build wire-format header bytes (`[u32 BE header_len | JSON header]`) -/// without a body — used by the `*_with_header` callback variants. -fn build_wire_header_bytes( - status: u16, - headers: &HashMap, - metadata: &ResponseMetadata, -) -> Vec { - let view = WireResponseHeader { - v: WIRE_VERSION, - status, - headers, - metadata, - validation_errors: None, - }; - let header_json = - serde_json::to_vec(&view).expect("WireResponseHeader serialization is infallible"); - let header_len = - u32::try_from(header_json.len()).expect("response header JSON exceeds u32::MAX bytes"); - let mut out = Vec::with_capacity(4 + header_json.len()); - out.extend_from_slice(&header_len.to_be_bytes()); - out.extend_from_slice(&header_json); - out -} - -/// **Streaming dispatch with explicit header callback** — emits the -/// wire-format response header via `on_header` **before** any body -/// chunk is delivered to `on_chunk`. -/// -/// This is the variant Spring `HttpServletResponse`-based controllers -/// want: `on_header` fires while the response is still uncommitted, -/// so the controller can call `resp.setStatus(...)` / -/// `resp.setHeader(...)` from the callback. Then `on_chunk` streams -/// the body bytes one frame at a time. -/// -/// `on_header` is called **exactly once** in every code path — -/// success or error. On error (malformed wire, no app, invalid -/// method, …) the bytes passed to `on_header` are a normal -/// `error_wire(...)` response and `on_chunk` is **not** invoked. -pub async fn dispatch_streaming_with_header_async( - input: Vec, - mut on_header: H, - mut on_chunk: F, -) where - H: FnMut(&[u8]), - F: FnMut(&[u8]), -{ - let (header, body_bytes) = match parse_wire_request(input) { - Ok(parts) => parts, - Err(msg) => { - on_header(&error_wire(400, &msg)); - return; - } - }; - if header.v != WIRE_VERSION { - on_header(&error_wire( - 400, - &format!( - "unsupported wire version: got {}, expected {WIRE_VERSION}", - header.v - ), - )); - return; - } - let router = match resolve_app_router(&header) { - Ok(r) => r, - Err(wire) => { - on_header(&wire); - return; - } - }; - - let (status, headers, metadata, mut body) = match dispatch_and_split( - router, - &header.method, - header.path, - header.query, - header.headers, - Body::from(body_bytes), - ) - .await - { - Ok(parts) => parts, - Err((status, msg)) => { - on_header(&error_wire(status, &msg)); - return; - } - }; - - on_header(&build_wire_header_bytes(status, &headers, &metadata)); - - while let Some(Ok(frame)) = body.frame().await { - if let Some(data) = frame.data_ref() - && !data.is_empty() - { - on_chunk(data.as_ref()); - } - } -} - -/// Best-effort extract validation errors from a 422 JSON body. -/// -/// Returns `None` (silently) for: -/// - non-JSON content-types (anything that doesn't end in `/json` or -/// `+json`) -/// - body bytes that don't parse as JSON -/// - JSON without an `errors` array, or with an empty array -/// -/// This is intentionally lenient — a malformed 422 body must never -/// degrade to a 5xx; the original body is still surfaced verbatim. -fn try_hoist_validation_errors( - headers: &HashMap, - body_bytes: &Bytes, -) -> Option> { - let is_json = headers.iter().any(|(k, v)| { - if !k.eq_ignore_ascii_case("content-type") { - return false; - } - let s = match v { - HeaderValue::Single(s) => s.as_str(), - HeaderValue::Multi(vs) => vs.first().map_or("", String::as_str), - }; - let mime = s - .split(';') - .next() - .unwrap_or("") - .trim() - .to_ascii_lowercase(); - mime == "application/json" || mime.ends_with("+json") - }); - if !is_json { - return None; - } - let parsed: serde_json::Value = serde_json::from_slice(body_bytes).ok()?; - let errors = parsed.get("errors")?.as_array()?; - let items: Vec = errors - .iter() - .filter_map(|e| { - let path = e.get("path")?.as_str()?.to_owned(); - let code = e - .get("code") - .and_then(serde_json::Value::as_str) - .map(str::to_owned); - let message = e - .get("message") - .and_then(serde_json::Value::as_str) - .map(str::to_owned); - Some(ValidationErrorItem { - path, - code, - message, - }) - }) - .collect(); - if items.is_empty() { None } else { Some(items) } -} - -/// **Bidirectional streaming dispatch** — both request and response -/// bodies are streamed chunk-by-chunk; neither side materialises the -/// full payload in memory. -/// -/// - `input_header` is a wire-format request **without a body** -/// (just `[u32 BE header_len | JSON header]`). Send the body -/// chunks via `pull_chunk`, not embedded in this buffer. -/// - `pull_chunk` is called repeatedly to obtain request body -/// chunks. Return `Some(chunk)` for each chunk and `None` to -/// signal EOF. An empty `Some(Vec::new())` is treated as -/// "no more data right now, but keep the stream open" — rarely -/// useful; most callers should just return `None`. -/// - `on_chunk` receives response body chunks in arrival order, same -/// contract as [`dispatch_streaming_async`]. -/// -/// Returns the wire-format **header only** (`[u32 BE header_len | -/// header JSON]`) — the response body was delivered via `on_chunk`. -/// -/// `pull_chunk` runs on a Tokio blocking thread (`spawn_blocking`) -/// because the JNI implementation reads from a Java `InputStream`, -/// which is inherently blocking. Backpressure is enforced by a -/// bounded 16-slot mpsc channel: if axum reads slowly, the -/// `pull_chunk` call blocks naturally. -/// -/// Failure modes match [`dispatch_streaming_async`]: malformed -/// header / unknown version / no app / handler error → normal -/// `error_wire(...)` response (with the message inside the returned -/// bytes); neither callback is invoked in those paths. -pub async fn dispatch_bidirectional_streaming( - input_header: Vec, - pull_chunk: P, - on_chunk: F, -) -> Vec -where - P: FnMut() -> Option> + Send + 'static, - F: FnMut(&[u8]), -{ - let mut header_bytes: Vec = Vec::new(); - { - let on_header = |h: &[u8]| header_bytes.extend_from_slice(h); - bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header).await; - } - header_bytes -} - -/// **Bidirectional streaming with explicit header callback** — the -/// `with_header` counterpart of [`dispatch_bidirectional_streaming`]. -/// Emits the wire-format response header via `on_header` **before** -/// any response body byte reaches `on_chunk`, so Spring-style -/// `HttpServletResponse` controllers can commit status / headers -/// from the callback while the response is still uncommitted. -/// -/// `on_header` is called exactly once on every code path (success or -/// error). On any pre-dispatch / wire error the bytes passed to -/// `on_header` are a normal `error_wire(...)` response and neither -/// `pull_chunk` nor `on_chunk` is invoked beyond that point. -pub async fn dispatch_bidirectional_streaming_with_header( - input_header: Vec, - pull_chunk: P, - on_chunk: F, - on_header: H, -) where - P: FnMut() -> Option> + Send + 'static, - F: FnMut(&[u8]), - H: FnMut(&[u8]), -{ - bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header).await; -} - -async fn bidirectional_streaming_inner( - input_header: Vec, - pull_chunk: P, - mut on_chunk: F, - mut on_header: H, -) where - P: FnMut() -> Option> + Send + 'static, - F: FnMut(&[u8]), - H: FnMut(&[u8]), -{ - let (header, _ignored_body) = match parse_wire_request(input_header) { - Ok(parts) => parts, - Err(msg) => { - on_header(&error_wire(400, &msg)); - return; - } - }; - if header.v != WIRE_VERSION { - on_header(&error_wire( - 400, - &format!( - "unsupported wire version: got {}, expected {WIRE_VERSION}", - header.v - ), - )); - return; - } - let router = match resolve_app_router(&header) { - Ok(r) => r, - Err(wire) => { - on_header(&wire); - return; - } - }; - - // Bounded 16-slot mpsc — gives natural backpressure between the - // pull_chunk producer thread and the axum handler consumer. - let (tx, rx) = tokio::sync::mpsc::channel::(16); - - let producer_handle = tokio::task::spawn_blocking(move || { - let mut pull = pull_chunk; - // `None` from `pull()` ends the stream; an empty `Some(_)` is - // skipped (it's not EOF); a failed `blocking_send` means the - // receiver — axum's request body — was dropped because the - // handler aborted mid-stream, so we stop pulling. - while let Some(chunk) = pull() { - if chunk.is_empty() { - continue; - } - if tx.blocking_send(Bytes::from(chunk)).is_err() { - break; - } - } - // tx dropped at end of scope → axum sees end-of-stream. - }); - - let body = Body::new(ChannelBody { rx }); - let (status, headers, metadata, mut response_body) = match dispatch_and_split( - router.clone(), - &header.method, - header.path, - header.query, - header.headers, - body, - ) - .await - { - Ok(parts) => parts, - Err((status, msg)) => { - let _ = producer_handle.await; - on_header(&error_wire(status, &msg)); - return; - } - }; - - on_header(&build_wire_header_bytes(status, &headers, &metadata)); - - while let Some(Ok(frame)) = response_body.frame().await { - if let Some(data) = frame.data_ref() - && !data.is_empty() - { - on_chunk(data.as_ref()); - } - } - - let _ = producer_handle.await; -} - -/// Minimal `http_body::Body` implementation backed by an mpsc -/// `Receiver` — used by [`dispatch_bidirectional_streaming`] -/// to feed request body chunks into axum. -struct ChannelBody { - rx: tokio::sync::mpsc::Receiver, -} - -impl HttpBody for ChannelBody { - type Data = Bytes; - type Error = Infallible; - - fn poll_frame( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll, Self::Error>>> { - match self.rx.poll_recv(cx) { - Poll::Ready(Some(bytes)) => Poll::Ready(Some(Ok(Frame::data(bytes)))), - Poll::Ready(None) => Poll::Ready(None), - Poll::Pending => Poll::Pending, - } - } -} - -/// Parse a wire-format request. On success returns the deserialised -/// header and the owned body bytes (zero-copy via `Vec::split_off`). -fn parse_wire_request(mut input: Vec) -> Result<(WireRequestHeader, Vec), String> { - if input.len() < 4 { - return Err(format!( - "wire input too short: {} bytes, need at least 4", - input.len() - )); - } - let mut len_bytes = [0u8; 4]; - len_bytes.copy_from_slice(&input[..4]); - let header_len = u32::from_be_bytes(len_bytes) as usize; - let total_header_end = 4usize.saturating_add(header_len); - if total_header_end > input.len() { - return Err(format!( - "wire header_len ({header_len}) exceeds remaining input ({} bytes)", - input.len() - 4 - )); - } - // Take ownership of the body without copy. - let body = input.split_off(total_header_end); - let header_json = &input[4..total_header_end]; - let header: WireRequestHeader = serde_json::from_slice(header_json) - .map_err(|e| format!("wire header JSON parse error: {e}"))?; - Ok((header, body)) } diff --git a/crates/vespera_inprocess/src/registry.rs b/crates/vespera_inprocess/src/registry.rs new file mode 100644 index 00000000..c1994b09 --- /dev/null +++ b/crates/vespera_inprocess/src/registry.rs @@ -0,0 +1,298 @@ +//! App registry: named `Router` factories with a lock-free +//! `OnceLock` fast path for the default app. + +use std::cell::Cell; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex, OnceLock, PoisonError}; + +use arc_swap::ArcSwap; + +use crate::Router; +use crate::wire::{WireRequestHeader, error_wire}; + +/// Canonical name of the default app — used when the wire header +/// omits `"app"` or sets it to an empty string, and when callers use +/// the BC [`register_app`] entry point. +pub const DEFAULT_APP_NAME: &str = "_default"; + +/// Maximum allowed length of an app name (after trimming). Sized so +/// names fit comfortably in URL path segments and log lines. +const MAX_APP_NAME_LEN: usize = 64; + +// ── App Factory (shared FFI pattern) ───────────────────────────────── + +/// Per-name router cache. Indexed by app name; the default app uses +/// [`DEFAULT_APP_NAME`] (`"_default"`). +/// +/// Backed by [`ArcSwap`] so dispatch **reads are lock-free** — a named +/// app resolves with a single atomic load + hash lookup, no lock +/// acquisition and no reader parking under high concurrency (the same +/// quality the default app already gets from its [`OnceLock`] mirror). +/// +/// The map is append-only with first-wins semantics and is written only +/// during `register_app*` calls (typically at process startup). Writes +/// go through copy-on-write [`ArcSwap::rcu`]: clone the (small) map, +/// `entry().or_insert` the new router, and atomically publish the new +/// snapshot. Factory closures are invoked **outside** the update, so a +/// factory panic cannot corrupt the registry; there is no lock to +/// poison. +static APP_ROUTERS: LazyLock>> = + LazyLock::new(|| ArcSwap::from_pointee(HashMap::new())); + +/// Lock-free fast path for the **default** app. +/// +/// The overwhelmingly common dispatch case is a wire header without +/// an `"app"` field — routing to [`DEFAULT_APP_NAME`]. Resolving it +/// through `APP_ROUTERS` still costs an `ArcSwap` load + hash lookup +/// per request. This `OnceLock` mirror is set (exactly once, by the +/// first successful `_default` registration so it can never diverge +/// from the map) and read with a single atomic load + `Router::clone` +/// (`Arc` refcount bump) on every dispatch — skipping even the hash +/// lookup. +/// +/// Named apps resolve through the lock-free [`ArcSwap`] load — they are +/// the rare multi-app case and can be registered at any time. +static DEFAULT_ROUTER: OnceLock = OnceLock::new(); + +/// Serializes the registration **write path** (`register_app*`) so a given +/// app name's `factory` runs **at most once**, even under concurrent +/// same-name registration: without it, two racing registrations both pass +/// the `contains_key` pre-check and each invoke their `factory` (the loser's +/// router is then discarded by the first-wins insert) — observable when a +/// factory has side effects or is expensive. Dispatch is unaffected: the +/// read path ([`resolve_app_router`]) never touches this lock and stays +/// fully lock-free. +static REGISTER_LOCK: Mutex<()> = Mutex::new(()); + +thread_local! { + /// Set while a [`try_register_app_named`] call on this thread is running + /// its `factory` closure. A re-entrant `register_app*` call from inside a + /// factory would otherwise deadlock the non-reentrant [`REGISTER_LOCK`]; + /// the flag lets the re-entrant call be rejected with an error instead. + static REGISTERING: Cell = const { Cell::new(false) }; +} + +/// RAII reset for the [`REGISTERING`] thread-local flag: clears it on EVERY +/// exit path of the guarded `factory()` call — including a panic unwinding out +/// of the factory — so a panicking factory never wedges the thread into the +/// permanent "re-entrant" state where every future registration fails. +struct ReentryGuard; + +impl Drop for ReentryGuard { + fn drop(&mut self) { + REGISTERING.with(|r| r.set(false)); + } +} + +/// Validate an app name for registration / lookup. +/// +/// Constraints: +/// - non-empty after trimming whitespace +/// - at most [`MAX_APP_NAME_LEN`] bytes +/// - ASCII alphanumeric, `_`, or `-` only +/// +/// Returns the trimmed name on success. +fn validate_app_name(name: &str) -> Result<&str, String> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("app name must not be empty".to_owned()); + } + if trimmed.len() > MAX_APP_NAME_LEN { + return Err(format!( + "app name too long: {} bytes (max {MAX_APP_NAME_LEN})", + trimmed.len() + )); + } + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(format!( + "app name '{trimmed}' contains invalid characters (allowed: alphanumeric, '_', '-')" + )); + } + Ok(trimmed) +} + +/// Register the **default** global router factory. +/// +/// Equivalent to `register_app_named(DEFAULT_APP_NAME, factory)`. +/// Wire requests without an `"app"` header (or with `"app": ""`) are +/// routed here. +/// +/// Any FFI boundary (JNI, C, WASM) calls this once at init time, then +/// uses [`dispatch_from_bytes`] on each request. +/// +/// # Second-call semantics +/// +/// Calling `register_app` more than once is a **no-op** — the first +/// registration wins, the new factory closure is NOT invoked. Friendly +/// for environments that legitimately load the cdylib twice (hot-reloading +/// JVM hosts, plugin systems). +pub fn register_app(factory: F) +where + F: Fn() -> Router + Send + Sync + 'static, +{ + register_app_named(DEFAULT_APP_NAME, factory); +} + +/// Register a **named** global router factory for multi-app routing. +/// +/// Wire requests carrying `"app": ""` in their header are +/// dispatched to this router. Multiple named apps can coexist in +/// the same process; register each once at init time. +/// +/// # First-wins per name +/// +/// Calling this more than once with the same `name` is a no-op — the +/// first registration wins. Registering different names is the +/// supported multi-app pattern. +/// +/// # Panic safety +/// +/// The `factory` closure is invoked **outside** the [`ArcSwap`] +/// copy-on-write update. A panic in `factory` cannot corrupt the +/// registry; the registration is simply discarded and the slot remains +/// available for retry. +/// +/// # Invalid names +/// +/// Names that fail [`validate_app_name`] (empty, > 64 bytes, or +/// containing characters outside `[A-Za-z0-9_-]`) are silently +/// discarded — registration is a no-op. Dispatch with a matching +/// invalid name will return a `400` wire response. Use +/// [`try_register_app_named`] to surface an invalid name (or an +/// already-registered one) as a `Result` instead of a silent no-op. +pub fn register_app_named(name: &str, factory: F) +where + F: Fn() -> Router + Send + Sync + 'static, +{ + // BC sugar over the fallible form: an invalid or already-registered name + // is silently a no-op. Hosts that need to detect those outcomes call + // [`try_register_app_named`] directly. + let _ = try_register_app_named(name, factory); +} + +/// Fallible sibling of [`register_app_named`] that **reports the outcome** +/// instead of silently swallowing it: +/// +/// - `Ok(true)` — newly registered (the factory ran and the router was stored) +/// - `Ok(false)` — a router was already registered under this name; first-wins, +/// so the factory was NOT invoked +/// - `Err(msg)` — `name` failed [`validate_app_name`] (empty, > 64 bytes, or +/// characters outside `[A-Za-z0-9_-]`); nothing was registered +/// +/// A multi-app host can surface a typo'd app name at startup — instead of +/// discovering it only when every dispatch to that app silently returns +/// `404` / `400`. +/// +/// First-wins semantics, lock-free dispatch reads, and factory panic safety +/// are identical to [`register_app_named`]. +/// +/// # Re-entrancy +/// +/// `factory` runs while the registration write-path mutex ([`REGISTER_LOCK`]) +/// is held, so a given name's factory runs **at most once** even under a +/// concurrent same-name race. It therefore MUST NOT call back into +/// `register_app*` from within itself — doing so re-enters the non-reentrant +/// lock and deadlocks. Registration is a startup-time operation: build the +/// `Router` inside `factory` without registering further apps from within it. +pub fn try_register_app_named(name: &str, factory: F) -> Result +where + F: Fn() -> Router + Send + Sync + 'static, +{ + let name = validate_app_name(name)?.to_owned(); + // Re-entrancy guard: `factory` runs while [`REGISTER_LOCK`] is held (so a + // name's factory runs at most once under a concurrent same-name race). + // `std::sync::Mutex` is non-reentrant, so a factory that calls back into + // `register_app*` on the SAME thread would deadlock process startup. + // Detect that re-entrancy BEFORE taking the lock and return an error + // instead of hanging — the documented contract is now enforced, not just + // warned about. + if REGISTERING.with(Cell::get) { + return Err("re-entrant app registration: a router factory must not \ + register apps from within itself" + .to_owned()); + } + // Serialize the registration write path (dispatch reads stay lock-free) + // so a given name's `factory` runs at most once — see [`REGISTER_LOCK`]. + let _guard = REGISTER_LOCK.lock().unwrap_or_else(PoisonError::into_inner); + // Re-check under the lock: first-wins, so an already-present name means + // `factory` is NOT invoked. + if APP_ROUTERS.load().contains_key(&name) { + return Ok(false); + } + // Build the router OUTSIDE the copy-on-write update so a panicking + // factory cannot corrupt the registry: the panic propagates before any + // insert, leaving the registry untouched (the poisoned lock is recovered + // by the next registration). The re-entrancy flag is set only around the + // `factory()` call and cleared by `ReentryGuard::drop` on every exit path + // (incl. a factory panic), so a re-entrant `register_app*` from inside the + // factory is rejected with an error rather than deadlocking. + let router = { + REGISTERING.with(|r| r.set(true)); + let _reentry = ReentryGuard; + factory() + }; + let is_default = name == DEFAULT_APP_NAME; + APP_ROUTERS.rcu(|current| { + let mut next: HashMap = (**current).clone(); + // First-wins: `or_insert_with` leaves an existing entry (from a + // racing registration) untouched, so the first inserter wins. + next.entry(name.clone()).or_insert_with(|| router.clone()); + next + }); + if is_default { + // Mirror the first-wins default winner into the lock-free + // OnceLock fast path. The map is append-only, so the + // `_default` entry is stable once present. + if let Some(stored) = APP_ROUTERS.load().get(DEFAULT_APP_NAME) { + let _ = DEFAULT_ROUTER.set(stored.clone()); + } + } + Ok(true) +} + +/// Resolve a [`Router`] for a wire request, applying default-app +/// fallback and name validation. Returns the cloned router (cheap — +/// axum's router is `Arc`-backed) on success, or a wire error response +/// (`400` for invalid name, `404` for unregistered name) on failure. +/// +/// Lookup-first: registered names are validated at registration time +/// ([`register_app_named`] discards invalid names), so a map hit is +/// valid by construction. Validation runs only on a miss, purely to +/// pick the right error status (`400` invalid vs `404` unregistered) +/// — keeping the per-request hot path to trim + hash lookup. +#[inline] +pub fn resolve_app_router(header: &WireRequestHeader) -> Result> { + let name = header + .app + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_APP_NAME); + // Lock-free fast path: default-app dispatch (the common case) + // resolves with one atomic load — no lock acquisition. + if name == DEFAULT_APP_NAME + && let Some(router) = DEFAULT_ROUTER.get() + { + return Ok(router.clone()); + } + // Named-app resolution is also lock-free: a single `ArcSwap` load + // (atomic) + hash lookup, no reader parking under concurrency. + if let Some(router) = APP_ROUTERS.load().get(name) { + return Ok(router.clone()); + } + // Miss: decide between 400 (invalid name) and 404 (unregistered). + match validate_app_name(name) { + Err(msg) => Err(error_wire(400, &format!("invalid app name: {msg}"))), + Ok(name) => Err(error_wire( + 404, + &format!( + "no app registered with name '{name}' — \ + use register_app() for the default app or \ + register_app_named(name, factory) for additional apps" + ), + )), + } +} diff --git a/crates/vespera_inprocess/src/streaming.rs b/crates/vespera_inprocess/src/streaming.rs new file mode 100644 index 00000000..afd9c21e --- /dev/null +++ b/crates/vespera_inprocess/src/streaming.rs @@ -0,0 +1,857 @@ +//! Streaming dispatch variants: response streaming, header-callback +//! streaming, and bidirectional (request + response) streaming. + +use std::ops::ControlFlow; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; + +use axum::body::Body; +use bytes::Bytes; +use http_body::{Body as HttpBody, Frame}; +use http_body_util::BodyExt; + +use crate::config::effective_streaming_channel_capacity; +use crate::dispatch::{check_ingress_cap, parse_validate_resolve}; +use crate::internal::{dispatch_and_split, dispatch_response_streaming}; +use crate::wire::{ + WIRE_HEADER_RESERVE, build_wire_header_bytes, build_wire_header_bytes_hoisting, error_wire, + split_wire_request, +}; + +/// Outcome of one request-body pull on the bidirectional streaming +/// path (the `pull_chunk` callback). +/// +/// `Data(empty)` means "nothing right now, keep the stream open" — it +/// is skipped, not treated as EOF. [`RequestChunk::Error`] terminates +/// the request body with a [`StreamAbort`] so axum and the handler see +/// a failed body rather than a clean EOF — a truncated upload (e.g. the +/// source `InputStream` threw mid-stream) is never silently accepted as +/// complete. +pub enum RequestChunk { + /// A request body chunk (an empty vec is a no-op "keep open" signal). + Data(Vec), + /// Clean end of the request body. + End, + /// The producer failed; the request body errors out instead of + /// ending cleanly. + Error, +} + +/// Upper bound on consecutive empty request-body pulls before the +/// producer aborts the stream. A conformant blocking `InputStream` +/// never returns 0 for a non-empty buffer, so sustained empty reads +/// indicate a stuck or hostile producer; the cap stops a DoS busy-spin +/// on a blocking-pool thread. +const MAX_CONSECUTIVE_EMPTY_READS: u32 = 1024; + +/// Error yielded by the request body when the producer reports +/// [`RequestChunk::Error`]. Surfaced to axum so a truncated upload is +/// not mistaken for a complete one. +#[derive(Debug)] +pub struct StreamAbort; + +impl std::fmt::Display for StreamAbort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("request body stream aborted by producer") + } +} + +impl std::error::Error for StreamAbort {} + +/// **Streaming** sibling of [`dispatch_from_bytes_async`]. +/// +/// Drives the dispatch end-to-end like the non-streaming variant but +/// emits the response body **chunk-by-chunk via `on_chunk`** instead +/// of materialising it in a single `Vec`. Returns the wire-format +/// header bytes only (`[u32 BE header_len | header JSON]`) — the body +/// is delivered through the callback while the dispatch is in flight, +/// so a 1 GiB response is never resident in memory. +/// +/// # Header ordering (important) +/// +/// The returned header bytes become available only **after** the body has +/// been fully drained through `on_chunk`: the status + headers are read off +/// the response after its body stream completes. This variant therefore +/// suits sinks that buffer the body, or callers that can backfill the +/// status/headers afterwards (the JNI `dispatchStreaming` bridge returns the +/// header to Java only once the native call returns). Callers that must +/// commit the response status/headers **before** the first body byte — e.g. +/// a Spring `HttpServletResponse` controller streaming straight to the +/// client — MUST instead use [`dispatch_streaming_with_header_async`], which +/// fires a dedicated header callback before any `on_chunk` invocation. +/// +/// `on_chunk` is invoked one or more times in arrival order; the +/// borrowed slice is valid only for the duration of each call and the +/// callback should treat it as ephemeral (e.g. write it to an +/// `OutputStream`, accumulate it on disk, …). +/// +/// Failure modes are identical to [`dispatch_from_bytes_async`] — +/// returns a valid wire-format error response (header + body) when +/// the wire input is malformed, the version is wrong, no app is +/// registered, or the handler reports a pre-dispatch error. In the +/// error path the body is included inside the returned bytes (not +/// streamed via `on_chunk`) because the error message is small. +/// +/// `on_chunk` is NOT called if the response body is empty. +pub async fn dispatch_streaming_async(input: Vec, mut on_chunk: F) -> Vec +where + F: FnMut(&[u8]) -> ControlFlow<()>, +{ + // Response streaming still buffers the full REQUEST in memory + // (`input` is a complete `Vec`), so it gets the same ingress cap as + // the buffered entry points. Only *bidirectional* streaming, which + // pulls the request body chunk-by-chunk, is exempt. + if let Some(err) = check_ingress_cap(input.len()) { + return err; + } + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => return error_wire(400, &msg), + }; + let (header, router) = match parse_validate_resolve(&header_bytes) { + Ok(parts) => parts, + Err(wire) => return wire, + }; + let (status, headers, metadata) = match dispatch_response_streaming( + router, + &header.method, + &header.path, + &header.query, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + body_bytes, + &mut on_chunk, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return error_wire(status, &msg), + }; + // Emit header-only wire bytes; body was streamed via on_chunk. + // NOTE: this header-LAST streaming variant cannot hoist 422 validation + // errors — the body has already been streamed through on_chunk before the + // header is built, so it is no longer available to hoist (the caller has + // received it regardless). The header-FIRST `*_with_header` variants DO + // hoist (they buffer the small 422 body before committing the header); + // callers needing hoisting should use those or dispatch_from_bytes_async. + build_wire_header_bytes(status, &headers, &metadata) +} + +/// Outcome of a **header-first** streaming dispatch +/// (`dispatch_streaming_with_header_async`, +/// `dispatch_bidirectional_streaming_with_header*`). +/// +/// These functions commit the response header (`on_header`) **before** +/// the body is drained, so a failure that happens afterwards can no +/// longer be turned into an error status. This value surfaces that +/// failure to the host so it can abort the transport (drop the +/// connection / skip the clean chunked terminator) instead of letting a +/// truncated body masquerade as a complete `2xx` response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamOutcome { + /// The response body drained to clean EOF — every chunk delivered, + /// or the dispatch failed *before* the header was committed (the + /// error response was delivered in full via `on_header`). + Complete, + /// The response body stream errored **after** the header was + /// committed; the bytes delivered via `on_chunk` are truncated. + BodyError, + /// `on_chunk` returned [`ControlFlow::Break`] — the chunk sink asked + /// to stop early (e.g. the host's output sink failed mid-stream). + /// The response delivered via `on_chunk` is truncated. + SinkStopped, +} + +/// Shared tail of the **header-first** streaming variants +/// ([`dispatch_streaming_with_header_async`] and +/// [`bidirectional_streaming_inner`]): emit the wire-format response header via +/// `on_header`, then deliver the response body via `on_chunk`. +/// +/// On the **422 path** the (small, framework-generated) validation body is +/// collected up front so its errors are hoisted into the wire header — the same +/// contract the buffered [`crate::wire::to_wire_bytes`] path upholds, so a +/// Java/FFI decoder reads validation failures from the header in EVERY dispatch +/// mode, not just buffered/direct. The body is still delivered verbatim via +/// `on_chunk`. Because the 422 body is collected *before* the header is +/// committed, a body error there cleanly becomes a `500` via `on_header` with +/// nothing truncated. +/// +/// Every other status keeps the original behaviour exactly: a hoist-free header +/// followed by frame-by-frame body streaming (so a 1 GiB response is never +/// resident), with a post-commit body error / sink stop surfaced via the +/// returned [`StreamOutcome`]. +async fn emit_header_then_stream_body( + status: u16, + headers: http::HeaderMap, + metadata: crate::envelope::ResponseMetadata, + mut body: Body, + on_header: &mut H, + on_chunk: &mut F, +) -> StreamOutcome +where + H: FnMut(&[u8]), + F: FnMut(&[u8]) -> ControlFlow<()>, +{ + if status == 422 { + // Collect the small validation envelope first so it can be hoisted into + // the header. Collecting BEFORE committing the header means a body + // error here is a clean 500 (nothing truncated), unlike the post-commit + // streaming path below. + let Ok(collected) = body.collect().await else { + on_header(&error_wire(500, "response body stream error")); + return StreamOutcome::Complete; + }; + let collected = collected.to_bytes(); + on_header(&build_wire_header_bytes_hoisting( + status, &headers, &metadata, &collected, + )); + if !collected.is_empty() && on_chunk(&collected).is_break() { + return StreamOutcome::SinkStopped; + } + return StreamOutcome::Complete; + } + + on_header(&build_wire_header_bytes(status, &headers, &metadata)); + let mut outcome = StreamOutcome::Complete; + while let Some(frame_result) = body.frame().await { + if let Ok(frame) = frame_result { + if let Some(data) = frame.data_ref() + && !data.is_empty() + && on_chunk(data.as_ref()).is_break() + { + // The chunk sink asked to stop (e.g. the host's output sink + // failed). The header is already committed, so report the + // truncation to the caller. + outcome = StreamOutcome::SinkStopped; + break; + } + } else { + // The response body aborted mid-stream after the header was + // committed: status/headers can no longer change, so surface the + // truncation so the host can abort the transport instead of sending + // a clean terminator over a short body. + outcome = StreamOutcome::BodyError; + break; + } + } + outcome +} + +/// **Streaming dispatch with explicit header callback** — emits the +/// wire-format response header via `on_header` **before** any body +/// chunk is delivered to `on_chunk`. +/// +/// This is the variant Spring `HttpServletResponse`-based controllers +/// want: `on_header` fires while the response is still uncommitted, +/// so the controller can call `resp.setStatus(...)` / +/// `resp.setHeader(...)` from the callback. Then `on_chunk` streams +/// the body bytes one frame at a time. +/// +/// `on_header` is called **exactly once** in every code path — +/// success or error. On error (malformed wire, no app, invalid +/// method, …) the bytes passed to `on_header` are a normal +/// `error_wire(...)` response and `on_chunk` is **not** invoked. +pub async fn dispatch_streaming_with_header_async( + input: Vec, + mut on_header: H, + mut on_chunk: F, +) -> StreamOutcome +where + H: FnMut(&[u8]), + F: FnMut(&[u8]) -> ControlFlow<()>, +{ + // Response streaming buffers the full request (see + // `dispatch_streaming_async`): apply the ingress cap, delivering the + // 413 through the header callback so the contract (header fires + // exactly once) holds. Pre-header error paths return `Complete`: the + // (error) response was delivered in full via `on_header`, nothing is + // truncated. + if let Some(err) = check_ingress_cap(input.len()) { + on_header(&err); + return StreamOutcome::Complete; + } + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => { + on_header(&error_wire(400, &msg)); + return StreamOutcome::Complete; + } + }; + let (header, router) = match parse_validate_resolve(&header_bytes) { + Ok(parts) => parts, + Err(wire) => { + on_header(&wire); + return StreamOutcome::Complete; + } + }; + + // Content-Type defaulting (INP-03): a non-empty body with no explicit + // `Content-Type` defaults to `application/json`. dispatch_and_split + // detects the header during its build pass, so this variant stays + // identical to its siblings while skipping the separate pre-scan; we + // signal only that a non-empty body should default. Computed before + // `body_bytes` is moved. + let default_json_when_absent = !body_bytes.is_empty(); + // Streaming is dominated by body throughput, so the owned-path URI + // zero-copy is not worth threading here — pass `None` (the URI is parsed + // from the borrowed path by `build_uri`, exactly as before). + let (status, headers, metadata, body) = match dispatch_and_split( + router, + &header.method, + &header.path, + &header.query, + None, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + Body::from(body_bytes), + default_json_when_absent, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => { + on_header(&error_wire(status, &msg)); + return StreamOutcome::Complete; + } + }; + + emit_header_then_stream_body( + status, + headers, + metadata, + body, + &mut on_header, + &mut on_chunk, + ) + .await +} + +/// **Bidirectional streaming dispatch** — both request and response +/// bodies are streamed chunk-by-chunk; neither side materialises the +/// full payload in memory. +/// +/// - `input_header` is a wire-format request **without a body** +/// (just `[u32 BE header_len | JSON header]`). Send the body +/// chunks via `pull_chunk`, not embedded in this buffer. +/// - `pull_chunk` is called repeatedly to obtain request body +/// chunks. Return [`RequestChunk::Data`] for each chunk and +/// [`RequestChunk::End`] to signal clean EOF. An empty +/// `Data(Vec::new())` is treated as "no more data right now, but +/// keep the stream open" — rarely useful; most callers should just +/// return `End`. Return [`RequestChunk::Error`] to abort the +/// request body (e.g. the source stream threw) so the truncated +/// upload is rejected rather than seen as complete. +/// - `on_chunk` receives response body chunks in arrival order, same +/// contract as [`dispatch_streaming_async`]. +/// +/// Returns the wire-format **header only** (`[u32 BE header_len | +/// header JSON]`) — the response body was delivered via `on_chunk`. +/// +/// `pull_chunk` runs on a Tokio blocking thread (`spawn_blocking`) +/// because the JNI implementation reads from a Java `InputStream`, +/// which is inherently blocking. That blocking producer is started +/// lazily on the first request-body poll, so handlers that never read +/// the body never touch the `InputStream`. Backpressure is enforced by +/// a bounded mpsc channel ([`streaming_channel_capacity`] slots, +/// default 16): if axum reads slowly, the `pull_chunk` call blocks +/// naturally. +/// +/// Failure modes match [`dispatch_streaming_async`]: malformed +/// header / unknown version / no app / handler error → normal +/// `error_wire(...)` response (with the message inside the returned +/// bytes); neither callback is invoked in those paths. +/// +/// This is the ergonomic form with **no request-source close hook** — +/// the request producer is awaited to its natural completion. Callers +/// with a blocking request source that can park forever (e.g. a Java +/// `InputStream` that never reaches EOF) should use +/// [`dispatch_bidirectional_streaming_closing`] to supply a close hook. +pub async fn dispatch_bidirectional_streaming( + input_header: Vec, + pull_chunk: P, + on_chunk: F, +) -> Vec +where + P: FnMut() -> RequestChunk + Send + 'static, + F: FnMut(&[u8]) -> ControlFlow<()>, +{ + dispatch_bidirectional_streaming_closing(input_header, pull_chunk, on_chunk, || {}).await +} + +/// **Bidirectional streaming with a request-source close hook** — the +/// [`dispatch_bidirectional_streaming`] variant that takes a +/// `request_close` callback. +/// +/// `request_close` is invoked once, after the response body is fully +/// drained, **only if** the request producer was started (the handler +/// read at least one body chunk). It must close/abort the request body +/// source (e.g. the Java `InputStream`) so a producer parked in a +/// blocking read is unblocked and this call cannot hang on a stuck upload +/// that never reaches EOF. It is a no-op for full reads (already at EOF) +/// and is never called when the handler ignored the body. +pub async fn dispatch_bidirectional_streaming_closing( + input_header: Vec, + pull_chunk: P, + on_chunk: F, + request_close: C, +) -> Vec +where + P: FnMut() -> RequestChunk + Send + 'static, + F: FnMut(&[u8]) -> ControlFlow<()>, + C: FnOnce(), +{ + let mut header_bytes: Vec = Vec::with_capacity(4 + WIRE_HEADER_RESERVE); + let outcome = { + let on_header = |h: &[u8]| header_bytes.extend_from_slice(h); + bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header, request_close) + .await + }; + match outcome { + // `Complete` covers a clean drain AND the pre-dispatch error paths + // (which deliver a full `error_wire(...)` via `on_header`), so the + // captured bytes are authoritative. + StreamOutcome::Complete => header_bytes, + // The response body errored, or the chunk sink stopped, AFTER the + // success header was captured into `header_bytes` — the delivered + // body is truncated. Replace the captured success header with a 500 + // so a truncated bidirectional response is never returned as a clean + // success (mirrors `dispatch_streaming_async`). + StreamOutcome::BodyError => error_wire(500, "response body stream error"), + StreamOutcome::SinkStopped => { + error_wire(500, "response body sink stopped before completion") + } + } +} + +/// **Bidirectional streaming with explicit header callback** — the +/// `with_header` counterpart of [`dispatch_bidirectional_streaming`]. +/// Emits the wire-format response header via `on_header` **before** +/// any response body byte reaches `on_chunk`, so Spring-style +/// `HttpServletResponse` controllers can commit status / headers +/// from the callback while the response is still uncommitted. +/// +/// `on_header` is called exactly once on every code path (success or +/// error). On any pre-dispatch / wire error the bytes passed to +/// `on_header` are a normal `error_wire(...)` response and neither +/// `pull_chunk` nor `on_chunk` is invoked beyond that point. +/// +/// Ergonomic form with no request-source close hook; see +/// [`dispatch_bidirectional_streaming_with_header_closing`] for the +/// variant that supplies one. +pub async fn dispatch_bidirectional_streaming_with_header( + input_header: Vec, + pull_chunk: P, + on_chunk: F, + on_header: H, +) -> StreamOutcome +where + P: FnMut() -> RequestChunk + Send + 'static, + F: FnMut(&[u8]) -> ControlFlow<()>, + H: FnMut(&[u8]), +{ + dispatch_bidirectional_streaming_with_header_closing( + input_header, + pull_chunk, + on_chunk, + on_header, + || {}, + ) + .await +} + +/// **Bidirectional streaming with header callback and request-source +/// close hook** — the [`dispatch_bidirectional_streaming_with_header`] +/// variant that takes a `request_close` callback (see +/// [`dispatch_bidirectional_streaming_closing`] for its contract). +pub async fn dispatch_bidirectional_streaming_with_header_closing( + input_header: Vec, + pull_chunk: P, + on_chunk: F, + on_header: H, + request_close: C, +) -> StreamOutcome +where + P: FnMut() -> RequestChunk + Send + 'static, + F: FnMut(&[u8]) -> ControlFlow<()>, + H: FnMut(&[u8]), + C: FnOnce(), +{ + bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header, request_close) + .await +} + +async fn bidirectional_streaming_inner( + input_header: Vec, + pull_chunk: P, + mut on_chunk: F, + mut on_header: H, + request_close: C, +) -> StreamOutcome +where + P: FnMut() -> RequestChunk + Send + 'static, + F: FnMut(&[u8]) -> ControlFlow<()>, + H: FnMut(&[u8]), + C: FnOnce(), +{ + let (header_bytes, body_tail) = match split_wire_request(input_header) { + Ok(parts) => parts, + Err(msg) => { + on_header(&error_wire(400, &msg)); + return StreamOutcome::Complete; + } + }; + // `input_header` MUST be header-only on the bidirectional path — the + // request body arrives via `pull_chunk`. A non-empty tail means the + // caller mis-built the frame; reject it (400) instead of silently + // retaining (then discarding) a full body allocation, which would also + // violate the advertised O(chunk) memory contract. + if !body_tail.is_empty() { + on_header(&error_wire( + 400, + "bidirectional streaming input_header must be header-only \ + (no trailing body bytes); send the request body via pull_chunk", + )); + return StreamOutcome::Complete; + } + let (header, router) = match parse_validate_resolve(&header_bytes) { + Ok(parts) => parts, + Err(wire) => { + on_header(&wire); + return StreamOutcome::Complete; + } + }; + + let producer_handle: RequestProducerHandle = Arc::new(Mutex::new(None)); + let body = Body::new(ChannelBody::new(pull_chunk, Arc::clone(&producer_handle))); + // RAII guard: closes the request source iff the producer was started, on + // EVERY exit path — including a panic unwinding out of the handler or out + // of the response-body poll below. Without it, a handler that read part of + // the body (starting the producer) and then panicked would leave the + // producer parked forever in a blocking source read: the JNI boundary's + // `catch_unwind` turns the panic into a 500 but skips the explicit close, + // so the parked producer never gets unblocked. This is the panic-path + // sibling of the M3 hang. + let mut closer = RequestSourceCloser::new(Arc::clone(&producer_handle), request_close); + + // Content-Type parity with the buffered / direct / response-streaming + // paths: a request with no explicit Content-Type defaults to + // `application/json`. The streamed body's emptiness is unknowable up + // front (unlike the buffered paths, which gate on a non-empty body), so + // default whenever the header is absent — matching sibling behaviour for + // the bodyful bidirectional requests that are this path's reason to + // exist, instead of leaving extractor behaviour mode-dependent. + // dispatch_and_split detects Content-Type during its build pass, so we + // pass `true` (default-when-absent) instead of running a separate + // pre-scan: the streamed body's emptiness is unknowable up front, so we + // default whenever no `Content-Type` header is present — byte-identical + // to the prior `!has_content_type` semantics. + let default_json_when_absent = true; + // See the response-streaming sibling: streaming is body-throughput bound, + // so pass `None` rather than threading the owned-path URI zero-copy here. + let (status, headers, metadata, response_body) = match dispatch_and_split( + router, + &header.method, + &header.path, + &header.query, + None, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + body, + default_json_when_absent, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => { + // Pre-dispatch failure (bad method/path → 405/400): the producer + // almost never started, but close defensively (no-op if it did + // not) before awaiting so we cannot hang here either. + closer.close_if_started(); + await_request_producer(&producer_handle).await; + on_header(&error_wire(status, &msg)); + return StreamOutcome::Complete; + } + }; + + let outcome = emit_header_then_stream_body( + status, + headers, + metadata, + response_body, + &mut on_header, + &mut on_chunk, + ) + .await; + + // The response is fully drained, so the handler has finished and will + // not read more of the request body. If the producer was started (the + // handler read at least one chunk) it may be parked in a blocking source + // read; close the request source to unblock it so the await below cannot + // hang on a stuck / slow upload that never reaches EOF. A full read + // already hit EOF (close is a no-op) and a producer that never started + // leaves the source untouched. `close_if_started` is idempotent, so the + // guard's Drop becomes a no-op on this happy path. + closer.close_if_started(); + await_request_producer(&producer_handle).await; + outcome +} + +/// Lock the producer handle, transparently recovering the guard if the mutex +/// was poisoned. A poison here only means a prior holder panicked while the +/// streaming path was tearing down; the guarded `Option` is still +/// structurally valid, so recovering and proceeding is correct — and keeps this +/// FFI-adjacent path free of the `unwrap` panic site each of the three call +/// sites would otherwise carry. (Same idiom as the registry/bench read paths.) +fn lock_producer_handle( + producer_handle: &RequestProducerHandle, +) -> std::sync::MutexGuard<'_, Option>> { + producer_handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Whether the request producer task was started — i.e. the handler read +/// at least one body chunk, which lazily spawns the producer. +fn producer_was_started(producer_handle: &RequestProducerHandle) -> bool { + lock_producer_handle(producer_handle).is_some() +} + +/// RAII guard that closes the request body source **exactly once** if the +/// request producer was started. [`bidirectional_streaming_inner`] uses it so +/// the close runs on every exit path, including a panic that unwinds out of +/// the handler or the response-body poll — the JNI boundary's `catch_unwind` +/// would otherwise turn the panic into a 500 and skip the explicit close, +/// leaking a producer parked in a blocking source read. +struct RequestSourceCloser { + producer_handle: RequestProducerHandle, + close: Option, +} + +impl RequestSourceCloser { + fn new(producer_handle: RequestProducerHandle, close: C) -> Self { + Self { + producer_handle, + close: Some(close), + } + } + + /// Close the request source iff the producer was started. Idempotent: the + /// close hook is consumed on the first call, so later calls (including the + /// one in `Drop`) are no-ops. If the producer never started the hook is + /// dropped uncalled — there is nothing to close. + /// + /// The hook runs under `catch_unwind`: `close_if_started` is also invoked + /// from `Drop`, which can run while a panic is already unwinding out of the + /// handler or the response-body poll, where a hook panic would be a + /// double-panic → process `abort()` (taking the host JVM down with it). The + /// close is best-effort cleanup (unblock a producer parked in a blocking + /// read) that runs only AFTER the response is fully drained, so a panicking + /// hook is contained rather than allowed to abort the process or fail an + /// already-produced response. + fn close_if_started(&mut self) { + if let Some(close) = self.close.take() + && producer_was_started(&self.producer_handle) + { + // `AssertUnwindSafe`: the hook is `FnOnce()` best-effort cleanup and + // the producer is being torn down regardless, so swallowing its + // panic leaves no observable state inconsistent. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(close)); + } + } +} + +impl Drop for RequestSourceCloser { + fn drop(&mut self) { + // Runs on unwind when the happy-path `close_if_started()` did not. + self.close_if_started(); + } +} + +type RequestProducerHandle = Arc>>>; +type PullChunk = Box RequestChunk + Send + 'static>; +type RequestFrame = Result; + +struct RequestProducer { + pull_chunk: PullChunk, + capacity: usize, +} + +/// Minimal `http_body::Body` implementation backed by an mpsc +/// `Receiver>` — used by +/// [`dispatch_bidirectional_streaming`] to feed request body chunks +/// into axum. A producer error is forwarded as a body error so a +/// truncated upload is not seen as a clean EOF. +struct ChannelBody { + rx: Option>, + producer: Option, + producer_handle: RequestProducerHandle, +} + +impl ChannelBody { + fn new

(pull_chunk: P, producer_handle: RequestProducerHandle) -> Self + where + P: FnMut() -> RequestChunk + Send + 'static, + { + Self { + rx: None, + producer: Some(RequestProducer { + pull_chunk: Box::new(pull_chunk), + // Product-capped (chunk_bytes * slots <= 64 MiB) so a large + // configured chunk size can't multiply with the channel + // capacity into multi-GB peak buffering. See + // `effective_streaming_channel_capacity`. + capacity: effective_streaming_channel_capacity(), + }), + producer_handle, + } + } + + fn start_producer_if_needed(&mut self) { + if self.rx.is_some() { + return; + } + + let Some(producer) = self.producer.take() else { + return; + }; + + // Bounded mpsc (default 16 slots, see streaming_channel_capacity) + // — gives natural backpressure between the pull_chunk producer + // thread and the axum handler consumer. The channel is created + // with the producer so unpolled bodies avoid both pieces of setup. + let (tx, rx) = tokio::sync::mpsc::channel::(producer.capacity); + self.rx = Some(rx); + let handle = spawn_request_producer(producer.pull_chunk, tx); + store_request_producer_handle(&self.producer_handle, handle); + } +} + +impl HttpBody for ChannelBody { + type Data = Bytes; + type Error = StreamAbort; + + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + self.start_producer_if_needed(); + + let Some(rx) = self.rx.as_mut() else { + return Poll::Ready(None); + }; + + match rx.poll_recv(cx) { + Poll::Ready(Some(Ok(bytes))) => Poll::Ready(Some(Ok(Frame::data(bytes)))), + // Producer reported an abort: surface it as a body error so + // axum/the handler rejects the truncated upload. + Poll::Ready(Some(Err(abort))) => Poll::Ready(Some(Err(abort))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +fn spawn_request_producer( + mut pull: PullChunk, + tx: tokio::sync::mpsc::Sender, +) -> tokio::task::JoinHandle<()> { + tokio::task::spawn_blocking(move || { + // `End` ends the stream; an empty `Data(_)` is skipped (it's not + // EOF); `Error` forwards a `StreamAbort` so the body errors out + // instead of ending cleanly. A failed `blocking_send` means the + // receiver — axum's request body — was dropped because the + // handler aborted mid-stream, so we stop pulling. + let mut consecutive_empty: u32 = 0; + // Read once: the configured max bytes per queued frame. A host + // `pull()` may return an arbitrarily large `Vec`; splitting it into + // `<= max_chunk` pieces below keeps the channel's `slots * chunk_bytes` + // memory bound REAL instead of `slots * arbitrary` — without it a + // hostile/buggy producer returning multi-MiB chunks defeats the + // `O(chunk)` RAM guarantee and can OOM the host under load. + let max_chunk = crate::config::streaming_chunk_bytes(); + loop { + // A panic inside the user / JNI-supplied `pull()` must NOT be + // turned into a clean end-of-stream — that would accept a + // TRUNCATED upload as a complete request body (silent data + // loss). Catch it and forward a `StreamAbort`, exactly like the + // explicit `RequestChunk::Error` path, so axum/the handler + // rejects the body instead of seeing a short, "successful" read. + let Ok(next) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(&mut pull)) else { + let _ = tx.blocking_send(Err(StreamAbort)); + break; + }; + match next { + RequestChunk::Data(chunk) => { + if chunk.is_empty() { + // A conformant blocking `InputStream.read(byte[])` + // never returns 0 for a non-empty buffer — it + // blocks until ≥1 byte or returns -1 at EOF. + // Sustained empty reads therefore mean a stuck or + // hostile producer; cap them (with a yield so we + // don't peg a blocking-pool core) and abort instead + // of busy-spinning this thread forever. + consecutive_empty += 1; + if consecutive_empty >= MAX_CONSECUTIVE_EMPTY_READS { + let _ = tx.blocking_send(Err(StreamAbort)); + break; + } + std::thread::yield_now(); + continue; + } + consecutive_empty = 0; + // Enforce the per-frame size cap: split an oversized host + // chunk into `<= max_chunk` pieces so each QUEUED frame is + // bounded and the channel's slot accounting reflects real + // bytes (a 100 MiB host chunk no longer occupies a slot as + // 100 MiB). `Bytes::split_to` is an O(1) refcount slice — + // no copy — and a conformant `<= max_chunk` chunk (the JNI + // reader always reads into a `chunk_bytes` buffer, and the + // benches pre-chunk at `chunk_bytes`) sends in a single + // iteration exactly as before. + let mut bytes = Bytes::from(chunk); + let mut receiver_gone = false; + while !bytes.is_empty() { + let piece = if bytes.len() > max_chunk { + bytes.split_to(max_chunk) + } else { + std::mem::take(&mut bytes) + }; + if tx.blocking_send(Ok(piece)).is_err() { + receiver_gone = true; + break; + } + } + if receiver_gone { + break; + } + } + RequestChunk::End => break, + RequestChunk::Error => { + // Best-effort: if the receiver is already gone there + // is nothing to abort. + let _ = tx.blocking_send(Err(StreamAbort)); + break; + } + } + } + // tx dropped at end of scope → axum sees end-of-stream (or the + // forwarded error above). + }) +} + +fn store_request_producer_handle( + producer_handle: &RequestProducerHandle, + handle: tokio::task::JoinHandle<()>, +) { + *lock_producer_handle(producer_handle) = Some(handle); +} + +async fn await_request_producer(producer_handle: &RequestProducerHandle) { + // Take the handle and release the guard on the same statement: a + // `MutexGuard` is not `Send` and must never be held across the `.await`. + let handle = lock_producer_handle(producer_handle).take(); + if let Some(handle) = handle { + let _ = handle.await; + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/vespera_inprocess/src/streaming/tests.rs b/crates/vespera_inprocess/src/streaming/tests.rs new file mode 100644 index 00000000..d33b1e35 --- /dev/null +++ b/crates/vespera_inprocess/src/streaming/tests.rs @@ -0,0 +1,31 @@ +use super::{RequestProducerHandle, RequestSourceCloser}; +use std::sync::{Arc, Mutex}; + +/// A panicking user close hook must be CONTAINED by `close_if_started`: +/// the method also runs from `Drop` during unwind, where an escaping panic +/// would be a double-panic → process `abort()`. Build a "started" producer +/// handle (a real `JoinHandle`, so `producer_was_started` is true and the +/// hook actually runs), then assert the call returns normally despite the +/// hook panicking, and that a second call is a consumed-hook no-op. +/// +/// Without the `catch_unwind` in `close_if_started`, the first call would +/// unwind out of this `#[test]` (and, on a real `Drop`-during-unwind path, +/// abort the process). +#[test] +fn close_hook_panic_is_contained() { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .expect("current-thread runtime"); + // `Runtime::spawn` hands back a live `JoinHandle` without entering the + // runtime (the empty task is never driven or awaited) — we only need a + // handle present so the producer counts as "started". + let join_handle = runtime.spawn(async {}); + let producer_handle: RequestProducerHandle = Arc::new(Mutex::new(Some(join_handle))); + + let mut closer = RequestSourceCloser::new(Arc::clone(&producer_handle), || panic!("hook boom")); + // Returns normally — the panic is caught inside `close_if_started`. + closer.close_if_started(); + // Idempotent: the hook was consumed on the first call, so this is a + // no-op and does not panic a second time. + closer.close_if_started(); +} diff --git a/crates/vespera_inprocess/src/wire.rs b/crates/vespera_inprocess/src/wire.rs new file mode 100644 index 00000000..b8b9f7d6 --- /dev/null +++ b/crates/vespera_inprocess/src/wire.rs @@ -0,0 +1,805 @@ +//! Binary wire format: request-header borrowing deserialization, +//! response-header serialization (straight from `http::HeaderMap`), +//! frame split/parse, and 422 `validation_errors` hoisting. +//! +//! The serialized byte layout is **locked** by tests/wire_contract.rs. + +use std::borrow::Cow; + +use bytes::Bytes; +// `Serialize` is used only by the bench-only serde wire-header twins. +#[cfg(any(test, feature = "bench-support"))] +use serde::Serialize; + +use crate::envelope::ResponseMetadata; +use crate::internal::ResponseParts; + +/// Hand-rolled request-header parser (byte-compatible replacement for +/// the `serde_json` derive path; the serde version is retained as +/// [`parse_wire_header_serde`] for the criterion A/B). +mod header_read; +/// Hand-rolled response-header serializer (byte-identical to the +/// `serde_json` path retained as [`write_wire_header_into_slice_serde`] +/// for the criterion A/B). +mod header_write; +pub mod hoist; + +use header_write::JsonSink; + +#[cfg(test)] +mod tests; + +/// Wire format protocol version. The JSON header's `v` field MUST +/// equal this for requests; responses always emit this value. +pub const WIRE_VERSION: u8 = 1; + +// ── Wire Format Types (internal) ───────────────────────────────────── + +/// Request wire header. In production it is built **by the hand-rolled +/// [`header_read`] parser**, which borrows every plain string straight from +/// the wire bytes (zero allocation) and owns only escaped strings. +/// +/// The `serde` `Deserialize` derive (plus the [`BorrowableCow`] / +/// `de_cow_pairs` / `de_opt_cow` helpers) is compiled **only under the +/// `bench-support` feature**, where [`parse_wire_header_serde`] uses it as +/// the criterion A/B "before" arm — the production path never goes through +/// serde, so it is not part of the shipped build. +#[derive(Debug)] +#[cfg_attr(any(test, feature = "bench-support"), derive(serde::Deserialize))] +pub struct WireRequestHeader<'a> { + /// Wire protocol version; clients MUST send 1. + #[cfg_attr(any(test, feature = "bench-support"), serde(default))] + pub v: u8, + #[cfg_attr(any(test, feature = "bench-support"), serde(borrow))] + pub method: Cow<'a, str>, + #[cfg_attr(any(test, feature = "bench-support"), serde(borrow))] + pub path: Cow<'a, str>, + #[cfg_attr(any(test, feature = "bench-support"), serde(default, borrow))] + pub query: Cow<'a, str>, + /// Request headers as a flat list — dispatch only ever *iterates* + /// them (never looks one up by key), so a `Vec` skips the + /// `HashMap` bucket allocation + per-key hashing entirely. + /// Repeated names are forwarded as repeated request headers + /// (valid HTTP; the previous `HashMap` silently kept the last + /// duplicate of a degenerate duplicate-key JSON header). + #[cfg_attr( + any(test, feature = "bench-support"), + serde(default, borrow, deserialize_with = "de_cow_pairs") + )] + pub headers: CowPairs<'a>, + /// Optional name of the target app for multi-app routing. When + /// omitted (or empty), the request is dispatched to the default + /// app registered via [`register_app`]. Use [`register_app_named`] + /// to register additional named apps. + #[cfg_attr( + any(test, feature = "bench-support"), + serde(default, borrow, deserialize_with = "de_opt_cow") + )] + pub app: Option>, +} + +/// `Cow` wrapper whose `Deserialize` impl borrows from the input +/// when the JSON string carries no escape sequences. Bench-only — feeds +/// the `serde` A/B twin; production parsing is hand-rolled ([`header_read`]). +#[cfg(any(test, feature = "bench-support"))] +struct BorrowableCow<'a>(Cow<'a, str>); + +#[cfg(any(test, feature = "bench-support"))] +impl<'de> serde::Deserialize<'de> for BorrowableCow<'de> { + fn deserialize>(deserializer: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = BorrowableCow<'de>; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a string") + } + + fn visit_borrowed_str( + self, + v: &'de str, + ) -> Result { + Ok(BorrowableCow(Cow::Borrowed(v))) + } + + fn visit_str(self, v: &str) -> Result { + Ok(BorrowableCow(Cow::Owned(v.to_owned()))) + } + + fn visit_string(self, v: String) -> Result { + Ok(BorrowableCow(Cow::Owned(v))) + } + } + deserializer.deserialize_str(V) + } +} + +/// Flat list of `(name, value)` request-header pairs borrowing from +/// the wire input. +type CowPairs<'a> = Vec<(Cow<'a, str>, Cow<'a, str>)>; + +/// Deserialize a JSON object into a flat `Vec` of `(name, value)` +/// pairs whose strings borrow from the input where possible — one +/// `Vec` allocation instead of `HashMap` buckets + per-key hashing. +/// Bench-only (feeds the serde A/B twin). +#[cfg(any(test, feature = "bench-support"))] +fn de_cow_pairs<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = CowPairs<'de>; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a map of strings") + } + + fn visit_map>( + self, + mut access: A, + ) -> Result { + let mut out = Vec::with_capacity(access.size_hint().unwrap_or(0)); + while let Some((k, v)) = + access.next_entry::, BorrowableCow<'de>>()? + { + out.push((k.0, v.0)); + } + Ok(out) + } + } + deserializer.deserialize_map(V) +} + +/// Deserialize an `Option` that borrows from the input where +/// possible. Bench-only (feeds the serde A/B twin). +#[cfg(any(test, feature = "bench-support"))] +fn de_opt_cow<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Option>; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a string or null") + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + + fn visit_some>( + self, + deserializer: D2, + ) -> Result { + ::deserialize(deserializer).map(|c| Some(c.0)) + } + } + deserializer.deserialize_option(V) +} + +// wire-order locked — field order defines the serialized wire header +// byte layout (`v`, `status`, `headers`, `metadata`, +// `validation_errors?`). See tests/wire_contract.rs. +#[cfg(any(test, feature = "bench-support"))] +#[derive(Debug, Serialize)] +struct WireResponseHeader<'a, H: Serialize> { + v: u8, + status: u16, + headers: &'a H, + metadata: &'a ResponseMetadata, + /// Validation errors hoisted from a 422 JSON body so Java decoders + /// can read them with a single header parse. `None` for any other + /// status; the original body is preserved verbatim regardless. + #[serde(skip_serializing_if = "Option::is_none")] + validation_errors: Option>, +} + +/// Zero-allocation serializer for response headers: renders an +/// [`http::HeaderMap`] as the wire's sorted name → value JSON map, +/// borrowing every name and value straight from the map. +/// +/// Byte-compatible with the previous `BTreeMap` +/// representation (locked by tests/wire_contract.rs): +/// - names sort in byte order (`HeaderName`s are lowercase ASCII, so +/// `sort_unstable` equals `BTreeMap` ordering) +/// - single-valued headers render as a JSON string, repeated names as +/// a JSON array in insertion order (the untagged `HeaderValue` +/// shape) +/// - non-UTF-8 header values render as `""` (same `unwrap_or("")` +/// behaviour as the old owned conversion) +#[cfg(any(test, feature = "bench-support"))] +struct WireHeaders<'a>(&'a http::HeaderMap); + +#[cfg(any(test, feature = "bench-support"))] +impl Serialize for WireHeaders<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + // `HeaderMap::keys` yields each distinct name exactly once. The + // overwhelmingly common response carries only a handful of header + // names, so sort them in a stack buffer and skip the per-response + // heap `Vec`; header sets larger than the stack cap fall back to a + // heap `Vec`. Output is byte-identical either way (same sorted + // order over the same names), as locked by tests/wire_contract.rs. + const STACK_CAP: usize = 32; + let key_count = self.0.keys_len(); + let mut stack_names: [&str; STACK_CAP] = [""; STACK_CAP]; + let mut heap_names: Vec<&str>; + let names: &mut [&str] = if key_count <= STACK_CAP { + for (slot, name) in stack_names.iter_mut().zip(self.0.keys()) { + *slot = name.as_str(); + } + &mut stack_names[..key_count] + } else { + heap_names = Vec::with_capacity(key_count); + heap_names.extend(self.0.keys().map(http::HeaderName::as_str)); + &mut heap_names[..] + }; + names.sort_unstable(); + let mut map = serializer.serialize_map(Some(names.len()))?; + for &name in names.iter() { + let mut values = self.0.get_all(name).iter(); + let first = values + .next() + .expect("HeaderMap::keys yields only present names"); + if values.next().is_none() { + map.serialize_entry(name, first.to_str().unwrap_or(""))?; + } else { + map.serialize_entry(name, &WireHeaderValues(self.0, name))?; + } + } + map.end() + } +} + +/// Serializes the repeated values of one header name as a JSON array. +#[cfg(any(test, feature = "bench-support"))] +struct WireHeaderValues<'a>(&'a http::HeaderMap, &'a str); + +#[cfg(any(test, feature = "bench-support"))] +impl Serialize for WireHeaderValues<'_> { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq( + self.0 + .get_all(self.1) + .iter() + .map(|v| v.to_str().unwrap_or("")), + ) + } +} + +/// Append `[u32 BE header_len | header JSON]` to `out`, serializing +/// the header **directly into the output buffer** with the hand-rolled +/// [`header_write`] serializer — no intermediate `Vec` and no second +/// memcpy of the header JSON. Byte-identical to the previous +/// `serde_json::to_writer(WireResponseHeader { .. })` path (locked by +/// tests/wire_contract.rs). +/// +/// Typical wire headers are well under this reservation, so the +/// serializer usually writes without reallocating. +pub const WIRE_HEADER_RESERVE: usize = 192; + +/// Cheap upper-ish estimate of the serialized response wire-header JSON +/// byte length (excluding the 4-byte length prefix), so the response +/// `Vec` can be sized to serialize a header-heavy response **without +/// reallocating**. Counts the fixed JSON scaffolding + version string + +/// each header's `"name":"value",` rendering (a repeated name is counted +/// once per value — a safe over-estimate). Escape-heavy values can still +/// exceed it (rare → one growth); this only sets capacity, never the +/// emitted bytes. Always combined with a [`WIRE_HEADER_RESERVE`] floor by +/// callers, so a small-header response never reserves less than before. +pub fn header_capacity_estimate(headers: &http::HeaderMap, metadata: &ResponseMetadata) -> usize { + // {"v":1,"status":NNN,"headers":{},"metadata":{"version":""}} scaffold. + const SCAFFOLD: usize = 56; + let mut est = SCAFFOLD + metadata.version.len(); + for (name, value) in headers { + est += name.as_str().len() + value.len() + 8; + } + est +} + +/// Cheap upper-ish estimate of the serialized `validation_errors` JSON +/// array byte length, added to the response-`Vec` capacity **only on the +/// 422 path** (`validation_errors` is `None` for every other status, so the +/// hot success path pays nothing). Each item renders as +/// `{"path":"…","code":"…","message":"…"},` inside the +/// `,"validation_errors":[…]` wrapper — count the field bytes plus a fixed +/// per-item scaffold. A safe over-estimate (absent `code`/`message` only +/// shrink the real output), so it only ever prevents the mid-serialize +/// realloc the hoisted errors would otherwise force; it never changes the +/// emitted bytes. +fn validation_errors_capacity_estimate(items: &[ValidationErrorItem]) -> usize { + // `,"validation_errors":[]` wrapper, plus per item the + // `{"path":"","code":"","message":""},` scaffold. + const WRAPPER: usize = 24; + const ITEM_SCAFFOLD: usize = 36; + let mut est = WRAPPER; + for item in items { + est += ITEM_SCAFFOLD + + item.path.len() + + item.code.as_deref().map_or(0, str::len) + + item.message.as_deref().map_or(0, str::len); + } + est +} + +/// Append `[u32 BE header_len | header JSON]` to `out`. Returns `false` +/// when the serialized header JSON exceeds `u32::MAX` bytes — unreachable for +/// any real `HeaderMap` (4 GiB of header JSON), so callers map it to a `500` +/// wire response instead of panicking on the response path. +#[must_use] +fn write_wire_header_into( + out: &mut Vec, + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, + validation_errors: Option<&[ValidationErrorItem]>, +) -> bool { + out.extend_from_slice(&[0u8; 4]); + let start = out.len(); + header_write::write_response_header(out, status, headers, metadata, validation_errors); + // A serialized response header never approaches `u32::MAX` (4 GiB of + // header JSON is unreachable for any real `HeaderMap`); on the impossible + // overflow report `false` so the caller emits a `500` rather than + // panicking on the response path. + let Ok(header_len) = u32::try_from(out.len() - start) else { + return false; + }; + out[start - 4..start].copy_from_slice(&header_len.to_be_bytes()); + true +} + +/// Append `[u32 BE header_len | header JSON]` (no `validation_errors`) +/// straight into `out` — the `Vec`-appending sibling of +/// [`write_wire_header_into_slice`], used by the buffered direct-streaming +/// response assembler (`dispatch::finish_buffered_wire`). Wraps the +/// private [`write_wire_header_into`] so the internal [`ValidationErrorItem`] +/// type stays out of the crate-visible surface. +#[must_use] +pub fn write_wire_header_into_vec( + out: &mut Vec, + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, +) -> bool { + write_wire_header_into(out, status, headers, metadata, None) +} + +/// One entry in the wire header's `validation_errors` array. Fields +/// are best-effort: missing values in the source body become `None`. +/// The `Serialize` derive is **bench-only** — production serializes these +/// fields with the hand-rolled `header_write` writer, never via serde. +#[derive(Debug)] +#[cfg_attr(any(test, feature = "bench-support"), derive(Serialize))] +struct ValidationErrorItem { + path: String, + #[cfg_attr( + any(test, feature = "bench-support"), + serde(skip_serializing_if = "Option::is_none") + )] + code: Option, + #[cfg_attr( + any(test, feature = "bench-support"), + serde(skip_serializing_if = "Option::is_none") + )] + message: Option, +} + +/// Build a wire-format error response with a plain-text body. +/// +/// Used by [`dispatch_from_bytes`] for malformed input and by the +/// JNI bridge for panic fallback. The response always carries +/// `content-type: text/plain; charset=utf-8`. +#[must_use] +pub fn error_wire(status: u16, msg: &str) -> Vec { + let mut headers = http::HeaderMap::with_capacity(1); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("text/plain; charset=utf-8"), + ); + let metadata = ResponseMetadata::current(); + // Write the header + plain-text body straight into one buffer. An error + // body is never JSON, so it never participates in 422 `validation_errors` + // hoisting — routing through `to_wire_bytes` would only add an + // intermediate `Bytes::copy_from_slice(msg)` allocation plus a second copy + // of the same bytes into the final `Vec`. The error header is a single + // `content-type`, so it can never approach `u32::MAX`; the + // `write_wire_header_into` overflow signal is unreachable here and ignored. + let body = msg.as_bytes(); + let header_cap = header_capacity_estimate(&headers, &metadata).max(WIRE_HEADER_RESERVE); + let mut out = Vec::with_capacity(4 + header_cap + body.len()); + let _ = write_wire_header_into(&mut out, status, &headers, &metadata, None); + out.extend_from_slice(body); + out +} + +/// Adapter: response parts → wire-format bytes. Layout: +/// `[u32 BE header_len | JSON header | raw body]`. +/// +/// For `status == 422` JSON responses we **best-effort** hoist any +/// `{"errors": [...]}` payload into the wire header's +/// `validation_errors` field — Java decoders can read validation +/// failures with a single header parse, while the original body is +/// preserved verbatim for clients that still rely on it. +pub fn to_wire_bytes(parts: ResponseParts) -> Vec { + let (status, headers, body_bytes, metadata) = parts; + let validation_errors = if status == 422 { + hoist::try_hoist_validation_errors(&headers, &body_bytes) + } else { + None + }; + let header_cap = header_capacity_estimate(&headers, &metadata).max(WIRE_HEADER_RESERVE) + + validation_errors + .as_deref() + .map_or(0, validation_errors_capacity_estimate); + // `4 + header_cap + body_bytes.len()` cannot overflow `usize` on a + // 64-bit target (it would require a multi-exabyte body); plain `+` is + // used so the hot response path keeps its exact arithmetic — a + // `saturating_add` variant was benchmarked and cost ~2-3% on the small + // `wire_path`/`request_headers_path` cases for zero real-world benefit. + // The `validation_errors` term is `0` for every non-422 response (the hot + // success path is byte-for-byte unchanged); on the 422 path it sizes the + // `Vec` to serialise the hoisted errors without the mid-write realloc a + // hoist-blind estimate paid (locked by tests/alloc_budget.rs case F). + let mut out = Vec::with_capacity(4 + header_cap + body_bytes.len()); + if !write_wire_header_into( + &mut out, + status, + &headers, + &metadata, + validation_errors.as_deref(), + ) { + // Unreachable for a real `HeaderMap` (would need 4 GiB+ of header + // JSON); never panic on the response path — emit a 500 instead. + return error_wire(500, "response header exceeds u32::MAX bytes"); + } + out.extend_from_slice(&body_bytes); + out +} + +/// Build wire-format header bytes (`[u32 BE header_len | JSON header]`) +/// without a body — used by the `*_with_header` callback variants. +/// +/// Sizes the buffer with the adaptive [`header_capacity_estimate`] (floored +/// at [`WIRE_HEADER_RESERVE`] so small-header responses never reserve less +/// than before), matching [`to_wire_bytes`] / `finish_buffered_wire`: a +/// many-header streaming response now serializes its header without the +/// mid-write reallocation the flat `WIRE_HEADER_RESERVE` reserve forced. +pub fn build_wire_header_bytes( + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, +) -> Vec { + let header_cap = header_capacity_estimate(headers, metadata).max(WIRE_HEADER_RESERVE); + let mut out = Vec::with_capacity(4 + header_cap); + if !write_wire_header_into(&mut out, status, headers, metadata, None) { + // Unreachable for a real `HeaderMap`; never panic on the response path. + return error_wire(500, "response header exceeds u32::MAX bytes"); + } + out +} + +/// Build wire-format header bytes (`[u32 BE header_len | JSON header]`) for the +/// header-first streaming paths, **hoisting 422 `validation_errors`** from +/// `body` into the header — the same contract the buffered [`to_wire_bytes`] +/// upholds. Java/FFI decoders can then read validation failures from the wire +/// header in EVERY dispatch mode (not just buffered / direct); the caller still +/// delivers the original body verbatim through its chunk sink. +/// +/// For any non-422 status `body` is ignored and the output is byte-identical to +/// [`build_wire_header_bytes`] (the hot success path pays nothing). +pub fn build_wire_header_bytes_hoisting( + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, + body: &Bytes, +) -> Vec { + if status != 422 { + return build_wire_header_bytes(status, headers, metadata); + } + let validation_errors = hoist::try_hoist_validation_errors(headers, body); + let header_cap = header_capacity_estimate(headers, metadata).max(WIRE_HEADER_RESERVE) + + validation_errors + .as_deref() + .map_or(0, validation_errors_capacity_estimate); + let mut out = Vec::with_capacity(4 + header_cap); + if !write_wire_header_into( + &mut out, + status, + headers, + metadata, + validation_errors.as_deref(), + ) { + // Unreachable for a real `HeaderMap`; never panic on the response path. + return error_wire(500, "response header exceeds u32::MAX bytes"); + } + out +} + +/// `io::Write` adapter over a fixed `&mut [u8]`: copies the prefix that +/// fits and *counts* the rest, so a serializer can fill the caller's +/// buffer and still report the exact size it needed on overflow — +/// without allocating or panicking. `pos` is the running total of bytes +/// the writer was asked to write (it may exceed `buf.len()`). +#[cfg(any(test, feature = "bench-support"))] +struct SliceWriter<'a> { + buf: &'a mut [u8], + pos: usize, +} + +#[cfg(any(test, feature = "bench-support"))] +impl<'a> SliceWriter<'a> { + fn new(buf: &'a mut [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn put(&mut self, data: &[u8]) { + if self.pos < self.buf.len() { + let n = data.len().min(self.buf.len() - self.pos); + self.buf[self.pos..self.pos + n].copy_from_slice(&data[..n]); + } + self.pos += data.len(); + } +} + +#[cfg(any(test, feature = "bench-support"))] +impl std::io::Write for SliceWriter<'_> { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.put(data); + Ok(data.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Write `[u32 BE header_len | JSON header]` **straight into `out`** +/// with the hand-rolled [`header_write`] serializer, returning the exact +/// total header byte count regardless of whether it fit. The +/// direct-write sibling of [`build_wire_header_bytes`] — no intermediate +/// `Vec`, byte-identical output to the previous `serde_json` path +/// (retained as [`write_wire_header_into_slice_serde`] for the criterion +/// A/B). +/// +/// When the header fits (`returned <= out.len()`) `out[0..returned]` +/// holds the complete header. When it does not fit, `out`'s contents are +/// partial/undefined (per the direct-write `Overflow` contract) but the +/// returned count is still exact, so the caller can report the precise +/// required size. +pub fn write_wire_header_into_slice( + out: &mut [u8], + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, +) -> usize { + let header_total = { + let mut sink = header_write::SliceSink::new(out); + // Reserve the 4-byte length prefix, then serialize the JSON body + // straight after it; backfilled below once the length is known. + sink.put(&[0u8; 4]); + header_write::write_response_header(&mut sink, status, headers, metadata, None); + sink.pos + }; + if header_total <= out.len() + && let Ok(json_len) = u32::try_from(header_total - 4) + { + // `json_len` only overflows `u32` when the header JSON exceeds 4 GiB, + // which requires `out` itself to exceed 4 GiB — unreachable for any + // real buffer. Leave the length prefix zeroed in that impossible + // case rather than panicking; the exact `header_total` is still + // returned so the caller reports the precise required size. + out[0..4].copy_from_slice(&json_len.to_be_bytes()); + } + header_total +} + +/// `serde_json`-backed twin of [`write_wire_header_into_slice`], retained +/// **only** as the "before" arm of the criterion A/B in +/// `benches/dispatch.rs` (via [`crate::bench_support`]) so hand-rolled vs +/// `serde_json` are measured in the same run. Not part of the public +/// API and not used on any production path. +#[cfg(any(test, feature = "bench-support"))] +fn write_wire_header_into_slice_serde( + out: &mut [u8], + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, +) -> usize { + let view = WireResponseHeader { + v: WIRE_VERSION, + status, + headers: &WireHeaders(headers), + metadata, + validation_errors: None, + }; + let header_total = { + let mut writer = SliceWriter::new(out); + writer.put(&[0u8; 4]); + serde_json::to_writer(&mut writer, &view) + .expect("WireResponseHeader serialization is infallible"); + writer.pos + }; + if header_total <= out.len() { + let json_len = + u32::try_from(header_total - 4).expect("response header JSON exceeds u32::MAX bytes"); + out[0..4].copy_from_slice(&json_len.to_be_bytes()); + } + header_total +} + +/// Hard upper bound on the wire header-JSON region, enforced **before** +/// any parse or allocation work. The header carries method/path/query +/// plus the request headers as JSON; a legitimate header set is at most a +/// few tens of KiB, so 1 MiB is generous headroom while bounding the parse +/// work + header-vector allocation an attacker-controlled `header_len` can +/// force on a direct FFI caller (the Spring proxy is already +/// servlet-header-capped upstream). An oversized header is rejected with a +/// wire `400` rather than parsed. +const MAX_WIRE_HEADER_BYTES: usize = 1024 * 1024; + +/// Reject a decoded `header_len` that exceeds [`MAX_WIRE_HEADER_BYTES`] +/// before the header region is sliced or parsed. +fn check_header_len(header_len: usize) -> Result<(), String> { + if header_len > MAX_WIRE_HEADER_BYTES { + return Err(format!( + "wire header_len ({header_len}) exceeds maximum of {MAX_WIRE_HEADER_BYTES} bytes" + )); + } + Ok(()) +} + +/// Split a wire-format request into its header-JSON region and body — +/// both true zero-copy O(1) refcount views of the input allocation +/// (unlike `Vec::split_off`, which allocates a new vector and memcpys +/// the tail). +/// +/// Two-phase with [`parse_wire_header`] so the deserialized header +/// can **borrow** its strings from the returned header bytes (the +/// caller keeps them alive on its stack frame). +pub fn split_wire_request(input: Vec) -> Result<(Bytes, Bytes), String> { + if input.len() < 4 { + return Err(format!( + "wire input too short: {} bytes, need at least 4", + input.len() + )); + } + let mut input = Bytes::from(input); + let mut len_bytes = [0u8; 4]; + len_bytes.copy_from_slice(&input[..4]); + let header_len = u32::from_be_bytes(len_bytes) as usize; + check_header_len(header_len)?; + let total_header_end = 4usize.saturating_add(header_len); + if total_header_end > input.len() { + return Err(format!( + "wire header_len ({header_len}) exceeds remaining input ({} bytes)", + input.len() - 4 + )); + } + // O(1) splits: all views share the original allocation. + let body = input.split_off(total_header_end); + let header_json = input.slice(4..); + Ok((header_json, body)) +} + +/// Borrowing sibling of [`split_wire_request`]: returns the header-JSON +/// region and body region as **sub-slices of `input`** — zero allocation, +/// zero refcount (unlike [`split_wire_request`], which wraps the input in +/// a `Bytes`). The caller MUST keep `input` alive for as long as the +/// returned slices — and anything borrowing from them — are used. +pub fn split_wire_borrowed(input: &[u8]) -> Result<(&[u8], &[u8]), String> { + if input.len() < 4 { + return Err(format!( + "wire input too short: {} bytes, need at least 4", + input.len() + )); + } + let mut len_bytes = [0u8; 4]; + len_bytes.copy_from_slice(&input[..4]); + let header_len = u32::from_be_bytes(len_bytes) as usize; + check_header_len(header_len)?; + let total_header_end = 4usize.saturating_add(header_len); + if total_header_end > input.len() { + return Err(format!( + "wire header_len ({header_len}) exceeds remaining input ({} bytes)", + input.len() - 4 + )); + } + Ok((&input[4..total_header_end], &input[total_header_end..])) +} + +/// Deserialize the wire request header, borrowing every string from +/// `header_json` where possible (see [`WireRequestHeader`]). +/// +/// Uses the hand-rolled [`header_read`] parser — byte-behaviour-identical +/// to the previous `serde_json` derive path (retained as +/// [`parse_wire_header_serde`] for the criterion A/B): any key order, +/// unknown keys ignored, plain strings borrowed / escaped strings owned. +#[inline] +pub fn parse_wire_header(header_json: &[u8]) -> Result, String> { + header_read::parse(header_json).map_err(|e| format!("wire header JSON parse error: {e}")) +} + +/// `serde_json`-backed twin of [`parse_wire_header`], retained **only** +/// as the "before" arm of the criterion A/B in `benches/dispatch.rs` +/// (via [`crate::bench_support`]) so hand-rolled vs `serde_json` are +/// measured in the same run. Not part of the public API and not used on +/// any production path. +#[cfg(any(test, feature = "bench-support"))] +fn parse_wire_header_serde(header_json: &[u8]) -> Result, String> { + serde_json::from_slice(header_json).map_err(|e| format!("wire header JSON parse error: {e}")) +} + +// ── Criterion A/B bench surface (doc-hidden, not a public API) ──────── +// +// These thin wrappers expose the hand-rolled and `serde_json` paths to +// `benches/dispatch.rs` (re-exported via `crate::bench_support`) so both +// are measured in the SAME criterion run — the noise-robust same-run A/B +// the existing `direct_write_path/bodyless_*` group uses. Each parse +// wrapper sums every decoded field length so the optimiser cannot elide +// any field's materialisation (representative of the full production +// parse), and returns a plain `usize` so no borrowed/private type leaks +// into the (hidden) public surface. + +/// Bench A/B: full hand-rolled request-header parse cost. +#[cfg(any(test, feature = "bench-support"))] +#[doc(hidden)] +#[must_use] +pub fn bench_parse_hand(header_json: &[u8]) -> usize { + parse_wire_header(header_json).map_or(usize::MAX, |h| header_field_len_sum(&h)) +} + +/// Bench A/B: full `serde_json` request-header parse cost. +#[cfg(any(test, feature = "bench-support"))] +#[doc(hidden)] +#[must_use] +pub fn bench_parse_serde(header_json: &[u8]) -> usize { + parse_wire_header_serde(header_json).map_or(usize::MAX, |h| header_field_len_sum(&h)) +} + +/// Sum of every decoded field's byte length — forces materialisation of +/// each `Cow` (UTF-8 validation / escape decode) so neither A/B arm can +/// be optimised down to a partial parse. Takes the header by reference; +/// the owned value is still dropped inside the timed `bench_parse_*` call. +#[cfg(any(test, feature = "bench-support"))] +fn header_field_len_sum(header: &WireRequestHeader<'_>) -> usize { + let mut acc = header.method.len() + + header.path.len() + + header.query.len() + + header.app.as_deref().map_or(0, str::len) + + usize::from(header.v); + for (name, value) in &header.headers { + acc += name.len() + value.len(); + } + acc +} + +/// Bench A/B: hand-rolled response-header slice serialize cost. +#[cfg(any(test, feature = "bench-support"))] +#[doc(hidden)] +#[must_use] +pub fn bench_write_hand( + out: &mut [u8], + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, +) -> usize { + write_wire_header_into_slice(out, status, headers, metadata) +} + +/// Bench A/B: `serde_json` response-header slice serialize cost. +#[cfg(any(test, feature = "bench-support"))] +#[doc(hidden)] +#[must_use] +pub fn bench_write_serde( + out: &mut [u8], + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, +) -> usize { + write_wire_header_into_slice_serde(out, status, headers, metadata) +} diff --git a/crates/vespera_inprocess/src/wire/header_read.rs b/crates/vespera_inprocess/src/wire/header_read.rs new file mode 100644 index 00000000..9d049326 --- /dev/null +++ b/crates/vespera_inprocess/src/wire/header_read.rs @@ -0,0 +1,739 @@ +//! Hand-rolled deserializer for the **fixed-schema request wire header** +//! — the byte-for-byte replacement for the `serde_json::from_slice` +//! path that used to drive [`super::WireRequestHeader`]'s derive. +//! +//! Behaviour matches `serde_json` + the serde derive on +//! [`super::WireRequestHeader`] (locked by the in-crate round-trip +//! property test in `wire.rs` and the fuzz harness in +//! `tests/wire_robustness.rs`): +//! +//! * accepts the object in **any key order** and **ignores unknown +//! keys** (forward-compat); +//! * every string **borrows** straight from the input +//! ([`Cow::Borrowed`]) when it carries no escapes, and falls back to +//! an owned decode ([`Cow::Owned`]) for `\" \\ \/ \b \f \n \r \t +//! \uXXXX` — including UTF-16 surrogate pairs; +//! * `v` defaults to `0`, `query` to empty, `headers` to empty, `app` +//! to `None`; `method`/`path` are required; +//! * duplicate known keys, lone/invalid surrogates, bad escapes, +//! unescaped control characters, invalid UTF-8, and trailing content +//! are **parse errors** (never a panic). + +use std::borrow::Cow; + +use super::{CowPairs, WireRequestHeader}; + +/// Container-nesting levels tracked **inline** (zero-allocation) while +/// skipping the value of an unknown (forward-compat) header field, before +/// the rare deep-nesting spill to a heap `Vec`. 128 covers every realistic +/// forward-compat value; the unknown-value skip is *iterative* (see +/// [`Parser::skip_value`]) so deeper nesting is still accepted exactly as +/// `serde_json`'s iterative `ignore_value` does — never via native +/// recursion, so hostile depth can never overflow the stack and crash the +/// host JVM across the JNI boundary (a stack overflow is NOT catchable by +/// the `catch_unwind` guards at the JNI entry points). +const INLINE_SKIP_DEPTH: usize = 128; + +/// Initial capacity for the request-header `(name, value)` pair `Vec`. +/// +/// Sized for a realistic browser / reverse-proxy / API request header set +/// (host, user-agent, accept*, content-type, authorization, cookie, +/// forwarded / trace headers, cache-control, ...) so the common case fills +/// without a single reallocation. The previous capacity of `8` reallocated +/// once at the 9th header — the exact realloc the 16-header +/// `tests/alloc_budget.rs` Case C documented. A small request transiently +/// over-reserves a few hundred bytes (same alloc *count*); removing the +/// realloc on the larger, common request shape is the priority (speed first). +const TYPICAL_HEADER_CAP: usize = 16; + +/// Parse the request wire header, borrowing every plain string straight +/// from `input`. Returns a bare error message; the caller +/// ([`super::parse_wire_header`]) adds the `wire header JSON parse +/// error:` prefix to match the previous `serde_json` shape. +pub(super) fn parse(input: &[u8]) -> Result, String> { + let mut parser = Parser { input, pos: 0 }; + let header = parser.parse_header()?; + parser.skip_ws(); + if parser.pos != parser.input.len() { + return Err("trailing characters after wire header object".to_owned()); + } + Ok(header) +} + +struct Parser<'a> { + input: &'a [u8], + pos: usize, +} + +impl<'a> Parser<'a> { + fn parse_header(&mut self) -> Result, String> { + self.expect(b'{')?; + + let mut v_val: u8 = 0; + let mut v_seen = false; + let mut method: Option> = None; + let mut path: Option> = None; + let mut query: Cow<'a, str> = Cow::Borrowed(""); + let mut query_seen = false; + let mut headers: CowPairs<'a> = Vec::new(); + let mut headers_seen = false; + let mut app: Option> = None; + let mut app_seen = false; + + self.skip_ws(); + if self.peek() == Some(b'}') { + self.pos += 1; + // Empty object: method/path missing -> reported below. + } else { + loop { + let key = self.read_string()?; + self.expect(b':')?; + // Match known fields by content; serde rejects a duplicate + // of ANY known field (even the `#[serde(default)]` ones) + // while skipping unknown keys' values. + match key.as_ref() { + "v" => { + if v_seen { + return Err("duplicate field `v`".to_owned()); + } + v_val = self.read_u8()?; + v_seen = true; + } + "method" => { + if method.is_some() { + return Err("duplicate field `method`".to_owned()); + } + method = Some(self.read_string()?); + } + "path" => { + if path.is_some() { + return Err("duplicate field `path`".to_owned()); + } + path = Some(self.read_string()?); + } + "query" => { + if query_seen { + return Err("duplicate field `query`".to_owned()); + } + query = self.read_string()?; + query_seen = true; + } + "headers" => { + if headers_seen { + return Err("duplicate field `headers`".to_owned()); + } + headers = self.read_headers()?; + headers_seen = true; + } + "app" => { + if app_seen { + return Err("duplicate field `app`".to_owned()); + } + app = self.read_opt_string()?; + app_seen = true; + } + // Unknown (forward-compat) key: iteratively + // validate-and-skip its value (no native recursion). + _ => self.skip_value()?, + } + self.skip_ws(); + match self.cur() { + Some(b',') => { + self.pos += 1; + self.skip_ws(); + } + Some(b'}') => { + self.pos += 1; + break; + } + _ => return Err("expected ',' or '}' in wire header object".to_owned()), + } + } + } + + let method = method.ok_or_else(|| "missing field `method`".to_owned())?; + let path = path.ok_or_else(|| "missing field `path`".to_owned())?; + Ok(WireRequestHeader { + v: v_val, + method, + path, + query, + headers, + app, + }) + } + + /// Parse a JSON object of `string -> string` into a flat `Vec` of + /// `(name, value)` pairs, each borrowing from the input where + /// possible. Repeated names are preserved (matching the previous + /// `de_cow_pairs` `Vec` behaviour — no dedup). + fn read_headers(&mut self) -> Result, String> { + self.expect(b'{')?; + self.skip_ws(); + if self.peek() == Some(b'}') { + // Zero-allocation fast path for the common bodyless / + // headerless request — no capacity is reserved for `{}`. + self.pos += 1; + return Ok(Vec::new()); + } + // Pre-reserve for a typical request's header count so the pushes + // don't trigger the Vec's early doubling reallocations (the previous + // `Vec::new()` reallocated at 1, 2, 4, 8, ...). See + // [`TYPICAL_HEADER_CAP`] for the chosen size and rationale. + let mut out: CowPairs<'a> = Vec::with_capacity(TYPICAL_HEADER_CAP); + loop { + let name = self.read_string()?; + self.expect(b':')?; + let value = self.read_string()?; + out.push((name, value)); + self.skip_ws(); + match self.cur() { + Some(b',') => { + self.pos += 1; + self.skip_ws(); + } + Some(b'}') => { + self.pos += 1; + break; + } + _ => return Err("expected ',' or '}' in headers object".to_owned()), + } + } + Ok(out) + } + + /// Parse `app`: a JSON string (borrow/owned) or `null` (`None`), + /// matching serde's `deserialize_option` -> string-or-null contract. + fn read_opt_string(&mut self) -> Result>, String> { + self.skip_ws(); + if self.cur() == Some(b'n') { + self.expect_literal(b"null")?; + Ok(None) + } else { + Ok(Some(self.read_string()?)) + } + } + + /// Read a JSON string starting at the current position, returning a + /// borrowed slice when the value has no escapes and an owned decode + /// otherwise. Errors on unterminated strings, unescaped control + /// characters, and invalid UTF-8 (all of which `serde_json` rejects). + fn read_string(&mut self) -> Result, String> { + self.skip_ws(); + if self.cur() != Some(b'"') { + return Err("expected string".to_owned()); + } + self.pos += 1; + // Copy the `&'a [u8]` reference out of `self` so the borrowed + // slice carries lifetime `'a` (tied to the input data), not the + // shorter `&mut self` borrow. + let input = self.input; + let start = self.pos; + // Scalar single-pass scan. A `memchr2(b'"', b'\\')` variant was + // benchmarked (2026) and REGRESSED `request_parse_hand` ~13% and + // `request_headers_path` ~3-4%: header values are short, so the + // SIMD setup cost plus a second control-character pass over the + // span outweighs the vectorised search. The branchy scalar loop + // wins for the small-string sizes this parser actually sees. + loop { + match input.get(self.pos) { + None => return Err("unterminated string".to_owned()), + Some(&b'"') => { + let slice = &input[start..self.pos]; + self.pos += 1; + let s = std::str::from_utf8(slice) + .map_err(|_| "invalid UTF-8 in string".to_owned())?; + return Ok(Cow::Borrowed(s)); + } + Some(&b'\\') => return self.read_string_escaped(start), + Some(&b) if b < 0x20 => { + return Err("control character in string".to_owned()); + } + Some(_) => self.pos += 1, + } + } + } + + /// Owned-decode tail of [`Self::read_string`]: copies the already + /// scanned plain prefix `[start, pos)`, then decodes escape + /// sequences (`\" \\ \/ \b \f \n \r \t \uXXXX`, incl. surrogate + /// pairs) until the closing quote. + fn read_string_escaped(&mut self, start: usize) -> Result, String> { + // Reserve ~2× the already-scanned plain prefix (+16 floor): an + // escaped value's tail is typically the same order of magnitude as + // its plain head, so this absorbs most of it without the doubling + // reallocations the old flat `+16` paid on longer escaped values. + // Sizing off the prefix (never the total input length) keeps a + // short escaped value early in a large request from over-reserving. + let prefix = self.pos - start; + let mut buf: Vec = Vec::with_capacity(prefix.saturating_mul(2).saturating_add(16)); + buf.extend_from_slice(&self.input[start..self.pos]); + loop { + match self.input.get(self.pos) { + None => return Err("unterminated string".to_owned()), + Some(&b'"') => { + self.pos += 1; + let s = + String::from_utf8(buf).map_err(|_| "invalid UTF-8 in string".to_owned())?; + return Ok(Cow::Owned(s)); + } + Some(&b'\\') => { + self.pos += 1; + self.decode_escape(&mut buf)?; + } + Some(&b) if b < 0x20 => { + return Err("control character in string".to_owned()); + } + Some(&b) => { + buf.push(b); + self.pos += 1; + } + } + } + } + + /// Decode the escape sequence whose backslash has already been + /// consumed, appending the decoded UTF-8 to `buf`. + fn decode_escape(&mut self, buf: &mut Vec) -> Result<(), String> { + let escape = self + .input + .get(self.pos) + .copied() + .ok_or_else(|| "dangling escape".to_owned())?; + self.pos += 1; + match escape { + b'"' => buf.push(b'"'), + b'\\' => buf.push(b'\\'), + b'/' => buf.push(b'/'), + b'b' => buf.push(0x08), + b'f' => buf.push(0x0C), + b'n' => buf.push(0x0A), + b'r' => buf.push(0x0D), + b't' => buf.push(0x09), + b'u' => self.decode_unicode_escape(buf)?, + _ => return Err("invalid escape".to_owned()), + } + Ok(()) + } + + /// Decode a `\uXXXX` escape (the `\u` already consumed), resolving + /// UTF-16 surrogate pairs and rejecting lone/invalid surrogates. + fn decode_unicode_escape(&mut self, buf: &mut Vec) -> Result<(), String> { + let hi = self.read_hex4()?; + let code_point = if (0xD800..=0xDBFF).contains(&hi) { + // High surrogate: must be followed by `\uYYYY` low surrogate. + if self.input.get(self.pos) != Some(&b'\\') + || self.input.get(self.pos + 1) != Some(&b'u') + { + return Err("unpaired surrogate in unicode escape".to_owned()); + } + self.pos += 2; + let lo = self.read_hex4()?; + if !(0xDC00..=0xDFFF).contains(&lo) { + return Err("invalid low surrogate in unicode escape".to_owned()); + } + 0x1_0000 + ((u32::from(hi) - 0xD800) << 10) + (u32::from(lo) - 0xDC00) + } else if (0xDC00..=0xDFFF).contains(&hi) { + return Err("lone low surrogate in unicode escape".to_owned()); + } else { + u32::from(hi) + }; + let ch = char::from_u32(code_point) + .ok_or_else(|| "invalid code point in unicode escape".to_owned())?; + let mut tmp = [0u8; 4]; + buf.extend_from_slice(ch.encode_utf8(&mut tmp).as_bytes()); + Ok(()) + } + + /// Read exactly four hex digits as a `u16` (case-insensitive). + fn read_hex4(&mut self) -> Result { + let mut value: u16 = 0; + for _ in 0..4 { + let digit = self + .input + .get(self.pos) + .copied() + .ok_or_else(|| "truncated unicode escape".to_owned())?; + let nibble = match digit { + b'0'..=b'9' => digit - b'0', + b'a'..=b'f' => digit - b'a' + 10, + b'A'..=b'F' => digit - b'A' + 10, + _ => return Err("invalid hex digit in unicode escape".to_owned()), + }; + value = (value << 4) | u16::from(nibble); + self.pos += 1; + } + Ok(value) + } + + /// Read the `v` field as a `u8` — a non-negative JSON integer in + /// `[0, 255]`. Rejects a leading `-`, a **leading zero** (`01`, `00` + /// — JSON forbids them, only a bare `0` is legal), a + /// fractional/exponent tail, out-of-range values, and non-numeric + /// tokens (matching serde's `u8` deserialization decisions). + fn read_u8(&mut self) -> Result { + self.skip_ws(); + if self.cur() == Some(b'-') { + return Err("invalid negative value for `v`".to_owned()); + } + // JSON forbids leading zeros: a `0` may only stand alone, never be + // followed by another digit. `serde_json` rejects `01`/`00`. + let first_is_zero = self.cur() == Some(b'0'); + let mut value: u32 = 0; + let mut digits = 0u32; + while let Some(&byte) = self.input.get(self.pos) { + if byte.is_ascii_digit() { + value = value + .saturating_mul(10) + .saturating_add(u32::from(byte - b'0')); + self.pos += 1; + digits += 1; + } else { + break; + } + } + if digits == 0 { + return Err("expected integer for `v`".to_owned()); + } + if first_is_zero && digits > 1 { + return Err("invalid leading zero in `v`".to_owned()); + } + if matches!(self.cur(), Some(b'.' | b'e' | b'E')) { + return Err("invalid non-integer value for `v`".to_owned()); + } + u8::try_from(value).map_err(|_| "`v` out of range for u8".to_owned()) + } + + /// Iteratively **validate-and-skip** one JSON value — the value of an + /// unknown (forward-compat) header field — enforcing `serde_json`'s full + /// grammar (including bracket matching) so a malformed value under an + /// ignored key is rejected, not silently skipped. + /// + /// Matches `serde_json`'s `ignore_value`: nesting is walked with an + /// explicit container-type stack ([`ContainerStack`]) instead of native + /// recursion, so an arbitrarily deep value is accepted/rejected exactly + /// as serde does WITHOUT ever overflowing the native stack (which would + /// crash the host JVM across the JNI boundary, uncatchable by + /// `catch_unwind`). Allocates nothing for the common shallow value: the + /// stack is inline for the first [`INLINE_SKIP_DEPTH`] levels and the + /// non-allocating [`Self::skip_string`] is used throughout. + fn skip_value(&mut self) -> Result<(), String> { + let mut stack = ContainerStack::new(); + loop { + // ── Parse one value at the current position. ── + self.skip_ws(); + match self.cur() { + Some(b'{') => { + self.pos += 1; + self.skip_ws(); + if self.cur() == Some(b'}') { + self.pos += 1; // empty object: a complete value + } else { + stack.push(true); + self.skip_string()?; // first key + self.expect(b':')?; + continue; // descend to parse its value + } + } + Some(b'[') => { + self.pos += 1; + self.skip_ws(); + if self.cur() == Some(b']') { + self.pos += 1; // empty array: a complete value + } else { + stack.push(false); + continue; // descend to parse the first element + } + } + Some(b'"') => self.skip_string()?, + Some(b't') => self.expect_literal(b"true")?, + Some(b'f') => self.expect_literal(b"false")?, + Some(b'n') => self.expect_literal(b"null")?, + Some(b'-' | b'0'..=b'9') => self.skip_number()?, + _ => return Err("unexpected value".to_owned()), + } + // ── A complete value was parsed. Ascend: step past commas to + // the next sibling, or pop finished containers. An empty stack + // means the whole top-level value is done. ── + loop { + let Some(is_object) = stack.top() else { + return Ok(()); + }; + self.skip_ws(); + match self.cur() { + Some(b',') => { + self.pos += 1; + if is_object { + self.skip_ws(); + self.skip_string()?; // next key + self.expect(b':')?; + } + break; // parse the next value / element + } + Some(b'}') if is_object => { + self.pos += 1; + stack.pop(); + } + Some(b']') if !is_object => { + self.pos += 1; + stack.pop(); + } + _ => { + return Err(if is_object { + "expected ',' or '}' in object".to_owned() + } else { + "expected ',' or ']' in array".to_owned() + }); + } + } + } + } + } + + /// Validate-and-skip a JSON string (cursor at the opening quote) + /// **without allocating** — the byte-for-byte accept/reject twin of + /// [`Self::read_string`] (escape set, unescaped control-character + /// rejection, UTF-8 validation, surrogate-pair rules) that discards the + /// value instead of decoding it into a `String`. + /// + /// The previous implementation delegated to `read_string`, paying a + /// throwaway heap `String` decode for an escaped string under an ignored + /// key. This scans in place: every unescaped run is UTF-8-validated + /// against the source bytes (a multi-byte UTF-8 sequence never straddles + /// a `\`-escape, so per-run validation equals validating the whole + /// decoded string) and every escape is validated, never decoded. + fn skip_string(&mut self) -> Result<(), String> { + self.skip_ws(); + if self.cur() != Some(b'"') { + return Err("expected string".to_owned()); + } + self.pos += 1; + let input = self.input; + // Start of the current unescaped byte run, UTF-8-validated when it + // ends (at the closing quote or the next escape). + let mut run_start = self.pos; + loop { + match input.get(self.pos) { + None => return Err("unterminated string".to_owned()), + Some(&b'"') => { + std::str::from_utf8(&input[run_start..self.pos]) + .map_err(|_| "invalid UTF-8 in string".to_owned())?; + self.pos += 1; + return Ok(()); + } + Some(&b'\\') => { + std::str::from_utf8(&input[run_start..self.pos]) + .map_err(|_| "invalid UTF-8 in string".to_owned())?; + self.pos += 1; + self.validate_escape()?; + run_start = self.pos; + } + Some(&b) if b < 0x20 => { + return Err("control character in string".to_owned()); + } + Some(_) => self.pos += 1, + } + } + } + + /// Validate (but do not decode) the escape sequence whose backslash has + /// already been consumed — the non-allocating twin of + /// [`Self::decode_escape`], used by [`Self::skip_string`]. + fn validate_escape(&mut self) -> Result<(), String> { + let escape = self + .input + .get(self.pos) + .copied() + .ok_or_else(|| "dangling escape".to_owned())?; + self.pos += 1; + match escape { + b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't' => Ok(()), + b'u' => self.validate_unicode_escape(), + _ => Err("invalid escape".to_owned()), + } + } + + /// Validate a `\uXXXX` escape (the `\u` already consumed), enforcing the + /// same surrogate-pair rules as [`Self::decode_unicode_escape`] without + /// computing the code point. A validated high+low pair always forms a + /// scalar (`<= 0x10FFFF`) and a non-surrogate BMP unit is always a + /// scalar, so the decoder's `char::from_u32` check can never reject here + /// — accept/reject parity with `decode_unicode_escape` is preserved. + fn validate_unicode_escape(&mut self) -> Result<(), String> { + let hi = self.read_hex4()?; + if (0xD800..=0xDBFF).contains(&hi) { + if self.input.get(self.pos) != Some(&b'\\') + || self.input.get(self.pos + 1) != Some(&b'u') + { + return Err("unpaired surrogate in unicode escape".to_owned()); + } + self.pos += 2; + let lo = self.read_hex4()?; + if !(0xDC00..=0xDFFF).contains(&lo) { + return Err("invalid low surrogate in unicode escape".to_owned()); + } + Ok(()) + } else if (0xDC00..=0xDFFF).contains(&hi) { + Err("lone low surrogate in unicode escape".to_owned()) + } else { + Ok(()) + } + } + + /// Validate-and-skip a JSON number, enforcing the JSON number grammar + /// `-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?` so malformed + /// numbers like `1e+`, `1.`, or a leading-zero `01` are rejected the + /// same way `serde_json` rejects them. (A leading-zero integer such + /// as `01` consumes the `0` and leaves the `1`, so the surrounding + /// container's delimiter check rejects it — matching serde.) + fn skip_number(&mut self) -> Result<(), String> { + if self.cur() == Some(b'-') { + self.pos += 1; + } + // Integer part: a bare `0`, or `[1-9][0-9]*` (no leading zero). + match self.cur() { + Some(b'0') => self.pos += 1, + Some(b'1'..=b'9') => { + self.pos += 1; + while matches!(self.cur(), Some(b'0'..=b'9')) { + self.pos += 1; + } + } + _ => return Err("invalid number: expected a digit".to_owned()), + } + // Optional fraction: `.` then at least one digit. + if self.cur() == Some(b'.') { + self.pos += 1; + if !matches!(self.cur(), Some(b'0'..=b'9')) { + return Err("invalid number: expected a digit after '.'".to_owned()); + } + while matches!(self.cur(), Some(b'0'..=b'9')) { + self.pos += 1; + } + } + // Optional exponent: `e`/`E`, optional sign, at least one digit. + if matches!(self.cur(), Some(b'e' | b'E')) { + self.pos += 1; + if matches!(self.cur(), Some(b'+' | b'-')) { + self.pos += 1; + } + if !matches!(self.cur(), Some(b'0'..=b'9')) { + return Err("invalid number: expected a digit in the exponent".to_owned()); + } + while matches!(self.cur(), Some(b'0'..=b'9')) { + self.pos += 1; + } + } + Ok(()) + } + + /// Skip JSON whitespace (space, tab, newline, carriage return). + fn skip_ws(&mut self) { + while let Some(&byte) = self.input.get(self.pos) { + if matches!(byte, b' ' | b'\t' | b'\n' | b'\r') { + self.pos += 1; + } else { + break; + } + } + } + + /// Current byte without advancing. + fn cur(&self) -> Option { + self.input.get(self.pos).copied() + } + + /// Skip whitespace, then return the current byte without advancing. + fn peek(&mut self) -> Option { + self.skip_ws(); + self.cur() + } + + /// Skip whitespace and consume the expected byte, or error. + fn expect(&mut self, byte: u8) -> Result<(), String> { + self.skip_ws(); + if self.cur() == Some(byte) { + self.pos += 1; + Ok(()) + } else { + Err(format!("expected '{}'", byte as char)) + } + } + + /// Consume an exact ASCII literal (e.g. `null`), or error. + fn expect_literal(&mut self, literal: &[u8]) -> Result<(), String> { + if self.input[self.pos..].starts_with(literal) { + self.pos += literal.len(); + Ok(()) + } else { + Err("invalid literal".to_owned()) + } + } +} + +/// Explicit open-container stack for the iterative unknown-value skip in +/// [`Parser::skip_value`]: one bit per open container (`true` = object, +/// `false` = array) so a `]` is validated to close an array and a `}` an +/// object (matching `serde_json`'s grammar). +/// +/// The first [`INLINE_SKIP_DEPTH`] levels live in an inline bitset, so the +/// overwhelmingly common shallow value skips **without allocating**; only +/// pathologically deep nesting (reachable solely from hostile input) spills +/// to the heap `overflow` vec — and even then the walk stays iterative, so +/// the native stack is never at risk. +struct ContainerStack { + inline: [u64; INLINE_SKIP_DEPTH / 64], + depth: usize, + overflow: Vec, +} + +impl ContainerStack { + fn new() -> Self { + Self { + inline: [0; INLINE_SKIP_DEPTH / 64], + depth: 0, + overflow: Vec::new(), + } + } + + /// Push a newly-opened container (`is_object` selects `{` vs `[`). + fn push(&mut self, is_object: bool) { + if self.depth < INLINE_SKIP_DEPTH { + let (word, bit) = (self.depth / 64, self.depth % 64); + if is_object { + self.inline[word] |= 1u64 << bit; + } else { + self.inline[word] &= !(1u64 << bit); + } + } else { + self.overflow.push(is_object); + } + self.depth += 1; + } + + /// Pop the innermost container (no-op when already empty). + fn pop(&mut self) { + if self.depth == 0 { + return; + } + self.depth -= 1; + if self.depth >= INLINE_SKIP_DEPTH { + self.overflow.pop(); + } + } + + /// The innermost open container's type (`Some(true)` = object, + /// `Some(false)` = array), or `None` when the stack is empty. + fn top(&self) -> Option { + if self.depth == 0 { + return None; + } + let idx = self.depth - 1; + if idx < INLINE_SKIP_DEPTH { + let (word, bit) = (idx / 64, idx % 64); + Some(self.inline[word] & (1u64 << bit) != 0) + } else { + self.overflow.last().copied() + } + } +} diff --git a/crates/vespera_inprocess/src/wire/header_write.rs b/crates/vespera_inprocess/src/wire/header_write.rs new file mode 100644 index 00000000..b83271fa --- /dev/null +++ b/crates/vespera_inprocess/src/wire/header_write.rs @@ -0,0 +1,330 @@ +//! Hand-rolled serializer for the **fixed-schema response wire header** +//! — the byte-for-byte replacement for the `serde_json::to_writer` path +//! that used to render [`super::WireResponseHeader`]. +//! +//! Output is **byte-identical** to `serde_json`'s compact serialization +//! (locked by `tests/wire_contract.rs` and the in-crate round-trip +//! property test in `wire.rs`): +//! +//! ```text +//! {"v":1,"status":,"headers":, +//! "metadata":{"version":""}[,"validation_errors":[...]]} +//! ``` +//! +//! The string escaper reproduces exactly the set `serde_json` (and the +//! Java `VesperaBridge.writeJsonString`) emit: only `"`, `\`, and the +//! C0 controls (`\b \t \n \f \r`, else `\u00XX` in lowercase hex) are +//! escaped; `/` and 0x7F pass through, and every byte `>= 0x80` is +//! copied through verbatim (raw UTF-8). + +use crate::envelope::ResponseMetadata; + +use super::{ValidationErrorItem, WIRE_VERSION}; + +/// Byte sink abstraction so one serializer serves both the growable +/// `Vec` path ([`super::write_wire_header_into`]) and the fixed +/// `&mut [u8]` direct-write path ([`super::write_wire_header_into_slice`], +/// which copies the prefix that fits and counts the overflow). +pub(super) trait JsonSink { + fn put(&mut self, data: &[u8]); +} + +impl JsonSink for Vec { + #[inline] + fn put(&mut self, data: &[u8]) { + self.extend_from_slice(data); + } +} + +/// Fixed-slice sink: copies the prefix that fits into `buf` and *counts* +/// the rest, so the caller can report the exact size needed on overflow +/// without allocating or panicking. `pos` is the running total of bytes +/// the serializer asked to write (it may exceed `buf.len()`) — the +/// direct-write `Overflow` contract. +pub(super) struct SliceSink<'a> { + buf: &'a mut [u8], + pub(super) pos: usize, +} + +impl<'a> SliceSink<'a> { + pub(super) fn new(buf: &'a mut [u8]) -> Self { + Self { buf, pos: 0 } + } +} + +impl JsonSink for SliceSink<'_> { + #[inline] + fn put(&mut self, data: &[u8]) { + if self.pos < self.buf.len() { + let n = data.len().min(self.buf.len() - self.pos); + self.buf[self.pos..self.pos + n].copy_from_slice(&data[..n]); + } + self.pos += data.len(); + } +} + +// ── serde_json-exact string escaping ───────────────────────────────── +// +// Reproduces `serde_json`'s `ESCAPE` lookup table + `write_char_escape` +// byte-for-byte: index by source byte, `0` means "copy verbatim", +// anything else selects an escape. Identical to the table the Java +// `writeJsonString` encodes by hand. + +const BB: u8 = b'b'; // \x08 -> \b +const TT: u8 = b't'; // \x09 -> \t +const NN: u8 = b'n'; // \x0A -> \n +const FF: u8 = b'f'; // \x0C -> \f +const RR: u8 = b'r'; // \x0D -> \r +const QU: u8 = b'"'; // \x22 -> \" +const BS: u8 = b'\\'; // \x5C -> \\ +const UU: u8 = b'u'; // other C0 control -> \u00XX +const XX: u8 = 0; // verbatim (no escape) + +#[rustfmt::skip] +static ESCAPE: [u8; 256] = [ + // 0 1 2 3 4 5 6 7 8 9 A B C D E F + UU, UU, UU, UU, UU, UU, UU, UU, BB, TT, NN, UU, FF, RR, UU, UU, // 0 + UU, UU, UU, UU, UU, UU, UU, UU, UU, UU, UU, UU, UU, UU, UU, UU, // 1 + XX, XX, QU, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // 2 + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // 3 + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // 4 + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, BS, XX, XX, XX, // 5 + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // 6 + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // 7 + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // 8 + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // 9 + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // A + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // B + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // C + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // D + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // E + XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, XX, // F +]; + +const HEX: &[u8; 16] = b"0123456789abcdef"; + +/// Append `s` as a quoted, escaped JSON string straight into `sink` — +/// the byte-for-byte analogue of `serde_json`'s `format_escaped_str`. +/// Runs of non-escaped bytes are copied in bulk; only the escape set +/// above is rewritten. +fn write_json_string(sink: &mut S, s: &str) { + sink.put(b"\""); + let bytes = s.as_bytes(); + let mut start = 0; + for (i, &byte) in bytes.iter().enumerate() { + let escape = ESCAPE[byte as usize]; + if escape == XX { + continue; + } + if start < i { + sink.put(&bytes[start..i]); + } + match escape { + BB => sink.put(b"\\b"), + TT => sink.put(b"\\t"), + NN => sink.put(b"\\n"), + FF => sink.put(b"\\f"), + RR => sink.put(b"\\r"), + QU => sink.put(b"\\\""), + BS => sink.put(b"\\\\"), + // `UU`: a C0 control with no short form -> `\u00XX` (lowercase hex). + _ => sink.put(&[ + b'\\', + b'u', + b'0', + b'0', + HEX[(byte >> 4) as usize], + HEX[(byte & 0xF) as usize], + ]), + } + start = i + 1; + } + if start < bytes.len() { + sink.put(&bytes[start..]); + } + sink.put(b"\""); +} + +/// Append the decimal representation of `v` (no leading zeros, `0` for +/// zero) — byte-identical to `serde_json`'s `itoa` integer output for +/// the `u8`/`u16` header fields. +fn write_u64(sink: &mut S, mut v: u64) { + let mut buf = [0u8; 20]; + let mut i = buf.len(); + loop { + i -= 1; + buf[i] = b'0' + u8::try_from(v % 10).unwrap_or(0); + v /= 10; + if v == 0 { + break; + } + } + sink.put(&buf[i..]); +} + +/// Serialize an [`http::HeaderMap`] as the wire's sorted name -> value +/// JSON map — byte-compatible with [`super::WireHeaders`]: +/// - names sort in byte order (`HeaderName`s are lowercase ASCII, so +/// `sort_unstable` equals the prior `BTreeMap` ordering); +/// - single-valued headers render as a JSON string, repeated names as a +/// JSON array in insertion order; +/// - non-UTF-8 values render as `""` (same `to_str().unwrap_or("")`). +fn write_headers(sink: &mut S, headers: &http::HeaderMap) { + const STACK_CAP: usize = 32; + let key_count = headers.keys_len(); + // Fast paths for the overwhelmingly common tiny-header responses: skip + // initialising the 32-slot stack name array AND the (no-op) sort that the + // general path below always pays — a bodyless `GET` returning a bare string + // has ZERO response headers, and a single `content-type` is the next most + // common shape. Output is byte-identical (an N<=1 set is trivially sorted). + if key_count == 0 { + sink.put(b"{}"); + return; + } + if key_count == 1 { + // `keys_len() == 1` guarantees exactly one key. On the impossible + // `None` we emit an empty object instead of panicking, keeping this + // FFI-adjacent response serializer free of unwind sites (mirrors the + // no-panic/unwind discipline the dispatch internals document). + if let Some(name) = headers.keys().next() { + sink.put(b"{"); + write_header_name_json_string(sink, name.as_str()); + sink.put(b":"); + write_header_value(sink, headers, name.as_str()); + sink.put(b"}"); + } else { + debug_assert!(false, "keys_len()==1 yields exactly one name"); + sink.put(b"{}"); + } + return; + } + + // >=2 distinct names: sort in a stack buffer for the common small-header + // response; larger sets fall back to a heap `Vec`. Output is byte-identical + // either way (same sorted order over the same names). + let mut stack_names: [&str; STACK_CAP] = [""; STACK_CAP]; + let mut heap_names: Vec<&str>; + let names: &mut [&str] = if key_count <= STACK_CAP { + for (slot, name) in stack_names.iter_mut().zip(headers.keys()) { + *slot = name.as_str(); + } + &mut stack_names[..key_count] + } else { + heap_names = Vec::with_capacity(key_count); + heap_names.extend(headers.keys().map(http::HeaderName::as_str)); + &mut heap_names[..] + }; + names.sort_unstable(); + + sink.put(b"{"); + for (idx, &name) in names.iter().enumerate() { + if idx > 0 { + sink.put(b","); + } + write_header_name_json_string(sink, name); + sink.put(b":"); + write_header_value(sink, headers, name); + } + sink.put(b"}"); +} + +/// Append an HTTP header **name** as a quoted JSON string WITHOUT the +/// escape-table scan. An `http::HeaderName` is a validated HTTP field-name +/// token (RFC 9110 §5.6.2 — only `!#$%&'*+-.^_`|~`, digits, and ASCII letters, +/// lowercase here), so it can contain NONE of the `"`, `\`, or C0-control bytes +/// `write_json_string` rewrites. Byte-identical to `write_json_string(sink, +/// name)` for any valid header name, but skips the per-byte escape lookup. +fn write_header_name_json_string(sink: &mut S, name: &str) { + sink.put(b"\""); + sink.put(name.as_bytes()); + sink.put(b"\""); +} + +/// Write the JSON value for header `name`: a scalar string for a single value, +/// or a JSON array (insertion order) for a repeated name (e.g. `set-cookie`). +/// Reuses the already-advanced `get_all` iterator for the multi-value case +/// (first, second, then the rest) — byte-identical, no second hash lookup. +fn write_header_value(sink: &mut S, headers: &http::HeaderMap, name: &str) { + let mut values = headers.get_all(name).iter(); + // `write_header_value` is only invoked for names taken from `headers.keys()`, + // so `get_all(name)` is always non-empty. On the impossible `None` we emit an + // empty-string value rather than panicking on this response hot path. + let Some(first) = values.next() else { + debug_assert!(false, "write_header_value is only called for present names"); + write_json_string(sink, ""); + return; + }; + match values.next() { + // Single value: emit the scalar string. + None => write_json_string(sink, first.to_str().unwrap_or("")), + // Multiple values: emit a JSON array. + Some(second) => { + sink.put(b"["); + write_json_string(sink, first.to_str().unwrap_or("")); + sink.put(b","); + write_json_string(sink, second.to_str().unwrap_or("")); + for value in values { + sink.put(b","); + write_json_string(sink, value.to_str().unwrap_or("")); + } + sink.put(b"]"); + } + } +} + +/// Serialize one `validation_errors` entry — fields in struct order +/// (`path`, then `code`/`message` when present), matching the +/// `#[serde(skip_serializing_if = "Option::is_none")]` derive. +fn write_validation_item(sink: &mut S, item: &ValidationErrorItem) { + sink.put(b"{\"path\":"); + write_json_string(sink, &item.path); + if let Some(code) = &item.code { + sink.put(b",\"code\":"); + write_json_string(sink, code); + } + if let Some(message) = &item.message { + sink.put(b",\"message\":"); + write_json_string(sink, message); + } + sink.put(b"}"); +} + +/// Serialize the full response wire header into `sink` (no length +/// prefix) — the byte-for-byte replacement for +/// `serde_json::to_writer(WireResponseHeader { .. })`. Field order is +/// locked: `v`, `status`, `headers`, `metadata`, optional +/// `validation_errors`. +pub(super) fn write_response_header( + sink: &mut S, + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, + validation_errors: Option<&[ValidationErrorItem]>, +) { + sink.put(b"{\"v\":"); + write_u64(sink, u64::from(WIRE_VERSION)); + sink.put(b",\"status\":"); + write_u64(sink, u64::from(status)); + sink.put(b",\"headers\":"); + write_headers(sink, headers); + // COUPLING: this hand-written `metadata` object mirrors + // `ResponseMetadata`'s serde shape field-for-field. Adding a + // serialized field to `ResponseMetadata` (envelope.rs) without + // updating this line breaks the byte-identity guard + // `hand_serialize_matches_serde_serialize` (wire/tests.rs) — that test + // is the drift tripwire, so keep the two in lockstep. + sink.put(b",\"metadata\":{\"version\":"); + write_json_string(sink, &metadata.version); + sink.put(b"}"); + if let Some(items) = validation_errors { + sink.put(b",\"validation_errors\":["); + for (idx, item) in items.iter().enumerate() { + if idx > 0 { + sink.put(b","); + } + write_validation_item(sink, item); + } + sink.put(b"]"); + } + sink.put(b"}"); +} diff --git a/crates/vespera_inprocess/src/wire/hoist.rs b/crates/vespera_inprocess/src/wire/hoist.rs new file mode 100644 index 00000000..226a6a30 --- /dev/null +++ b/crates/vespera_inprocess/src/wire/hoist.rs @@ -0,0 +1,217 @@ +//! 422 validation-error hoisting, split out to keep `wire.rs` under the +//! 1000-line cap. Pure code move: no logic or byte-behaviour change. + +use bytes::Bytes; +use serde::Deserialize; + +use super::ValidationErrorItem; + +/// Upper bound on a `422` response body that [`try_hoist_validation_errors`] +/// will reparse to hoist validation errors into the wire header. A +/// canonical validation envelope is at most a few KiB even with many field +/// errors; beyond this the (cold-path) hoist is skipped and the body is +/// surfaced verbatim, so a large 422 body never forces a full +/// `serde_json::Value` reparse. +const MAX_HOIST_BODY_BYTES: usize = 64 * 1024; + +/// First content-type value decides whether a 422 body is JSON for the +/// validation-error hoist (matches the previous first-of-`Multi` +/// behaviour). Comparisons are case-insensitive in place — no +/// lowercased copy. +fn body_is_json(headers: &http::HeaderMap) -> bool { + headers + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|s| { + // Any `application/json`, `*/json`, or `*+json` media type. The + // trailing-5-byte suffix is compared on raw bytes (not a `str` + // slice), so an exotic non-ASCII value can never panic on a + // non-char-boundary index — and `/json` (e.g. `text/json`) now + // hoists too, matching the documented contract. + let mime = s.split(';').next().unwrap_or("").trim().as_bytes(); + mime.len() >= 5 && { + let suffix = &mime[mime.len() - 5..]; + suffix.eq_ignore_ascii_case(b"/json") || suffix.eq_ignore_ascii_case(b"+json") + } + }) +} + +/// Typed shape of the validation envelope, deserialized **directly** from the +/// 422 body — skips building the intermediate `serde_json::Value` DOM (the +/// object map + array vec + per-error maps + interned string keys) the +/// previous reparse allocated, going straight to the `Vec` whose +/// owned strings [`ValidationErrorItem`] needs anyway. +/// +/// This is the **fast strict path**: the common, framework-generated envelope +/// has all-string fields, so the plain derive parses it with no per-field +/// visitor overhead. A body with a wrong-typed field (`"code": 123`) fails +/// this strict parse and is retried via the inline `serde_json::Value` +/// fallback walk in [`try_hoist_validation_errors`], so the hoist stays +/// genuinely best-effort without taxing the common case. +#[derive(Deserialize)] +struct HoistEnvelope { + errors: Vec, +} + +#[derive(Deserialize)] +struct HoistErrorIn { + #[serde(default)] + path: Option, + #[serde(default)] + code: Option, + #[serde(default)] + message: Option, +} + +/// Collect hoistable `(path, code, message)` triples into wire items, +/// skipping any error that lacks a usable `path` (matches the previous +/// `e.get("path")?.as_str()?` behaviour). Shared by the strict fast path +/// and the lenient fallback so both apply identical selection rules. +fn hoist_items( + errors: impl Iterator, Option, Option)>, +) -> Vec { + errors + .filter_map(|(path, code, message)| { + Some(ValidationErrorItem { + path: path?, + code, + message, + }) + }) + .collect() +} + +/// Best-effort extract validation errors from a 422 JSON body. +/// +/// Returns `None` (silently) for: +/// - non-JSON content-types (anything that doesn't end in `/json` or +/// `+json`) +/// - body bytes that don't parse as the `{"errors":[...]}` envelope +/// - an envelope whose hoistable errors (those carrying a `path`) are empty +/// +/// This is intentionally lenient — a malformed 422 body must never +/// degrade to a 5xx; the original body is still surfaced verbatim. +pub(super) fn try_hoist_validation_errors( + headers: &http::HeaderMap, + body_bytes: &Bytes, +) -> Option> { + if !body_is_json(headers) { + return None; + } + // Cold-path guard: a 422 validation envelope is framework-generated and + // tiny. For an unexpectedly large body, skip the parse + per-item owned + // allocations rather than churning heap on it; the original body is still + // surfaced verbatim on the wire. + if body_bytes.len() > MAX_HOIST_BODY_BYTES { + return None; + } + // Fast path: strict typed deserialize (no intermediate `serde_json::Value` + // DOM, no per-field visitor) — the common all-string framework envelope + // parses here directly. + let items = if let Ok(envelope) = serde_json::from_slice::(body_bytes) { + hoist_items( + envelope + .errors + .into_iter() + .map(|e| (e.path, e.code, e.message)), + ) + } else { + // The strict typed parse aborted — either a wrong-typed field + // (`"code": 123`) OR a non-object array element (`null`, a bare + // string). Retry through a `serde_json::Value` walk that extracts + // each field with `as_str` (wrong types → `None`) and SKIPS any + // element lacking a usable `path` (non-objects: `Value::get` returns + // `None`). This keeps every still-valid error instead of discarding + // the whole array when one entry is malformed — the bug a typed + // `Vec` fallback had, since one bad element failed the entire + // `Vec` deserialize. Cold (only a hand-crafted 422 body reaches here) + // and size-capped at `MAX_HOIST_BODY_BYTES`, so the `Value` DOM here + // is negligible and never touches the common all-string fast path. + let parsed: serde_json::Value = serde_json::from_slice(body_bytes).ok()?; + let errors = parsed.get("errors")?.as_array()?; + hoist_items(errors.iter().map(|e| { + let field = |key| { + e.get(key) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }; + (field("path"), field("code"), field("message")) + })) + }; + if items.is_empty() { None } else { Some(items) } +} + +/// **Bench-only** `serde_json::Value` twin of [`try_hoist_validation_errors`], +/// retained as the "before" arm of the `hoist_422_ab` criterion A/B +/// (same-run, noise-robust — mirroring the `wire_header_serde` / +/// `request_build_ab` twins). Parses the body into a full `Value` DOM then +/// re-extracts each field — the allocation-heavier path the typed deserialize +/// replaced; byte-identical result for the framework-generated envelope. Not +/// used on any production path. +#[cfg(any(test, feature = "bench-support"))] +fn try_hoist_validation_errors_value_old( + headers: &http::HeaderMap, + body_bytes: &Bytes, +) -> Option> { + if !body_is_json(headers) { + return None; + } + if body_bytes.len() > MAX_HOIST_BODY_BYTES { + return None; + } + let parsed: serde_json::Value = serde_json::from_slice(body_bytes).ok()?; + let errors = parsed.get("errors")?.as_array()?; + let items: Vec = errors + .iter() + .filter_map(|e| { + let path = e.get("path")?.as_str()?.to_owned(); + let code = e + .get("code") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let message = e + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + Some(ValidationErrorItem { + path, + code, + message, + }) + }) + .collect(); + if items.is_empty() { None } else { Some(items) } +} + +/// Sum every hoisted item's field byte lengths so neither `hoist_422_ab` arm +/// can be optimised down to a partial parse. `None` (no hoist) sums to 0. +#[cfg(any(test, feature = "bench-support"))] +fn hoist_field_len_sum(items: Option>) -> usize { + items.map_or(0, |v| { + v.iter() + .map(|i| { + i.path.len() + + i.code.as_deref().map_or(0, str::len) + + i.message.as_deref().map_or(0, str::len) + }) + .sum() + }) +} + +/// Bench A/B: production typed-deserialize 422 validation hoist cost. +/// Bench-only. +#[cfg(any(test, feature = "bench-support"))] +#[doc(hidden)] +#[must_use] +pub fn bench_hoist_new(headers: &http::HeaderMap, body: &Bytes) -> usize { + hoist_field_len_sum(try_hoist_validation_errors(headers, body)) +} + +/// Bench A/B: previous `serde_json::Value` DOM 422 validation hoist cost. +/// Bench-only. +#[cfg(any(test, feature = "bench-support"))] +#[doc(hidden)] +#[must_use] +pub fn bench_hoist_old(headers: &http::HeaderMap, body: &Bytes) -> usize { + hoist_field_len_sum(try_hoist_validation_errors_value_old(headers, body)) +} diff --git a/crates/vespera_inprocess/src/wire/tests.rs b/crates/vespera_inprocess/src/wire/tests.rs new file mode 100644 index 00000000..eb4c56e8 --- /dev/null +++ b/crates/vespera_inprocess/src/wire/tests.rs @@ -0,0 +1,472 @@ +use std::borrow::Cow; + +use crate::envelope::ResponseMetadata; + +use super::{ + ValidationErrorItem, WIRE_VERSION, WireHeaders, WireRequestHeader, WireResponseHeader, + parse_wire_header, parse_wire_header_serde, split_wire_request, write_wire_header_into, + write_wire_header_into_slice, write_wire_header_into_slice_serde, +}; + +/// Pins the zero-copy contract: the returned body must point into +/// the original input allocation (no memcpy of the tail). +#[test] +fn split_wire_request_body_is_zero_copy() { + let header = br#"{"v":1,"method":"POST","path":"/x"}"#; + let body = vec![0xABu8; 1024]; + let mut wire = Vec::new(); + wire.extend_from_slice(&u32::try_from(header.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(header); + wire.extend_from_slice(&body); + + let input_ptr = wire.as_ptr() as usize; + let body_offset = 4 + header.len(); + let (_, parsed_body) = split_wire_request(wire).expect("valid wire request"); + + assert_eq!(parsed_body.len(), 1024); + assert_eq!( + parsed_body.as_ptr() as usize, + input_ptr + body_offset, + "body must alias the original input buffer (zero-copy)" + ); +} + +/// Pins the borrowed-deserialization contract: header strings +/// without JSON escapes must borrow straight from the wire bytes +/// (no per-string allocation), with `Cow::Owned` reserved for +/// escaped values. +#[test] +fn parse_wire_header_borrows_plain_strings() { + let header_json = + br#"{"v":1,"method":"POST","path":"/users","query":"a=1","headers":{"x-a":"plain","x-b":"esc\"aped"},"app":"admin"}"#; + let header = parse_wire_header(header_json).expect("valid header"); + + let header_value = |name: &str| { + header + .headers + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v) + }; + + assert!(matches!(header.method, Cow::Borrowed("POST"))); + assert!(matches!(header.path, Cow::Borrowed("/users"))); + assert!(matches!(header.query, Cow::Borrowed("a=1"))); + assert!(matches!(header.app.as_ref(), Some(Cow::Borrowed("admin")))); + assert!(matches!(header_value("x-a"), Some(Cow::Borrowed("plain")))); + // Escaped value falls back to owned — correctness over borrow. + assert_eq!( + header_value("x-b").map(std::convert::AsRef::as_ref), + Some("esc\"aped") + ); +} + +// ── hand-rolled vs serde_json round-trip (value / byte identity) ── + +/// Owned, comparable projection of a parsed header — the borrow vs +/// owned `Cow` distinction does not affect VALUE equality. +type OwnedHeader = ( + u8, + String, + String, + String, + Option, + Vec<(String, String)>, +); + +fn owned(h: &WireRequestHeader<'_>) -> OwnedHeader { + ( + h.v, + h.method.to_string(), + h.path.to_string(), + h.query.to_string(), + h.app.as_ref().map(ToString::to_string), + h.headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ) +} + +/// The hand-rolled request parser must produce the SAME values as +/// `serde_json` across arbitrary key order, ignored unknown keys, +/// escapes (quote / backslash / control), `\uXXXX` + surrogate pairs, +/// non-ASCII UTF-8, escaped keys, duplicate header names, and +/// string-or-null `app`. +#[test] +fn hand_parse_matches_serde_parse() { + let cases: &[&[u8]] = &[ + br#"{"v":1,"method":"GET","path":"/health"}"#, + // arbitrary key order + query + br#"{"method":"POST","path":"/users","v":1,"query":"a=1&b=2"}"#, + // escaped values: quote, backslash, newline, tab + br#"{"v":1,"method":"GET","path":"/p","headers":{"x-q":"he said \"hi\"","x-bs":"a\\b","x-nl":"l1\nl2\ttab"}}"#, + // escaped key (\u0065 == 'e') -> owned key + br#"{"v":1,"method":"GET","path":"/p","headers":{"x-\u0065sc":"v"}}"#, + // non-ASCII / UTF-8 (borrowed) path + emoji value + "{\"v\":1,\"method\":\"GET\",\"path\":\"/café\",\"headers\":{\"x-emoji\":\"😀\"}}".as_bytes(), + // \uXXXX BMP + UTF-16 surrogate pair + br#"{"v":1,"method":"GET","path":"/p","headers":{"x-smile":"\uD83D\uDE00","x-e":"\u00e9"}}"#, + // app: null and app: trimmed string + br#"{"v":1,"method":"GET","path":"/p","app":null}"#, + br#"{"v":1,"method":"GET","path":"/p","app":" admin "}"#, + // unknown fields (object / array / number / bool / null) ignored + br#"{"v":1,"method":"GET","path":"/p","extra":{"nested":[1,2,3]},"flag":true,"n":42,"z":null}"#, + // empty headers object + duplicate header NAMES preserved + br#"{"v":1,"method":"GET","path":"/p","headers":{}}"#, + br#"{"v":1,"method":"GET","path":"/p","headers":{"x-a":"1","x-a":"2"}}"#, + // VALID but complex values under an UNKNOWN key — the strict + // skip must still ACCEPT every JSON-legal form (negative / + // float / exponent numbers, escaped strings, nested arrays and + // objects, the three literals) so forward-compat fields aren't + // over-rejected. + br#"{"v":1,"method":"GET","path":"/p","a":-3.14e10,"b":"esc\"d\n","c":[true,null,{"x":1}],"d":0}"#, + ]; + for case in cases { + match (parse_wire_header(case), parse_wire_header_serde(case)) { + (Ok(hand), Ok(serde)) => assert_eq!( + owned(&hand), + owned(&serde), + "value drift on {}", + String::from_utf8_lossy(case) + ), + (Err(_), Err(_)) => {} + (hand, serde) => panic!( + "accept/reject divergence on {}: hand_ok={} serde_ok={}", + String::from_utf8_lossy(case), + hand.is_ok(), + serde.is_ok() + ), + } + } +} + +/// Malformed inputs the serde derive rejects must also be rejected by +/// the hand-rolled parser (and never panic). +#[test] +fn hand_parse_rejects_what_serde_rejects() { + let bad: &[&[u8]] = &[ + b"not json", + br#"{"v":1,"path":"/p"}"#, // missing method + br#"{"v":1,"method":"GET"}"#, // missing path + br#"{"v":1,"method":"GET","path":"/p"}x"#, // trailing chars + br#"{"v":1,"method":42,"path":"/p"}"#, // method not a string + br#"{"v":300,"method":"GET","path":"/p"}"#, // v out of u8 range + br#"{"v":1,"v":1,"method":"GET","path":"/p"}"#, // duplicate known field + br#"{"v":1,"method":"GET","path":"/p","headers":{"x":1}}"#, // header value not string + br#"{"v":1,"method":"GET","path":"/p","app":7}"#, // app not string/null + br#"{"v":1,"method":"GET","path":"/p","headers":[]}"#, // headers not object + // Malformed values under UNKNOWN keys must still be rejected + // (the skip path validates the full JSON grammar, matching + // serde_json — not the prior permissive skip that accepted them). + br#"{"v":1,"method":"GET","path":"/p","x":"\q"}"#, // invalid string escape + b"{\"v\":1,\"method\":\"GET\",\"path\":\"/p\",\"x\":\"\x01\"}", // unescaped control char + br#"{"v":1,"method":"GET","path":"/p","x":tru}"#, // truncated literal + br#"{"v":1,"method":"GET","path":"/p","x":nul}"#, // truncated null + br#"{"v":1,"method":"GET","path":"/p","x":1e+}"#, // exponent without digit + br#"{"v":1,"method":"GET","path":"/p","x":1.}"#, // fraction without digit + br#"{"v":1,"method":"GET","path":"/p","x":01}"#, // leading zero + br#"{"v":1,"method":"GET","path":"/p","x":[}"#, // mismatched container open + br#"{"v":1,"method":"GET","path":"/p","x":[1,2}"#, // array closed by '}' + br#"{"v":1,"method":"GET","path":"/p","x":{"a":1,}}"#, // trailing comma in object + br#"{"v":01,"method":"GET","path":"/p"}"#, // leading zero in `v` + ]; + for case in bad { + assert!( + parse_wire_header(case).is_err(), + "hand parser must reject {}", + String::from_utf8_lossy(case) + ); + assert!( + parse_wire_header_serde(case).is_err(), + "serde parser must reject {}", + String::from_utf8_lossy(case) + ); + } +} + +/// A very deeply nested unknown-field value must be walked by the +/// ITERATIVE skip (no native recursion) so it can never overflow the +/// stack and crash the host JVM across the JNI boundary — and it must +/// stay accept/reject-identical to `serde_json`, whose `ignore_value` is +/// likewise iterative and imposes NO recursion cap on ignored values +/// (so a well-formed deep value is *accepted*, not rejected). The test +/// completing at all proves neither path blew the stack. +#[test] +fn hand_parse_handles_deep_unknown_nesting_without_overflow() { + // Depth far beyond any native recursion limit (a recursive skip would + // overflow the stack here). + let depth = 50_000usize; + + // Well-formed deep nesting under an unknown key: both ACCEPT (serde's + // iterative ignore imposes no cap), value-identical (no fields stored). + let mut ok = br#"{"v":1,"method":"GET","path":"/p","z":"#.to_vec(); + ok.extend(std::iter::repeat_n(b'[', depth)); + ok.extend(std::iter::repeat_n(b']', depth)); + ok.push(b'}'); + assert_eq!( + parse_wire_header(&ok).is_ok(), + parse_wire_header_serde(&ok).is_ok(), + "hand vs serde accept/reject must match on deep well-formed nesting" + ); + assert!( + parse_wire_header(&ok).is_ok(), + "well-formed deep unknown nesting must be accepted (matches serde)" + ); + + // Deep UNCLOSED nesting: both REJECT (grammar error), still no overflow. + let mut bad = br#"{"v":1,"method":"GET","path":"/p","z":"#.to_vec(); + bad.extend(std::iter::repeat_n(b'[', depth)); // never closed + assert!(parse_wire_header(&bad).is_err()); + assert!(parse_wire_header_serde(&bad).is_err()); +} + +/// A shallow unknown-field value (well within the depth cap) carrying +/// escaped strings, a `\uXXXX` BMP escape, a UTF-16 surrogate pair, and a +/// nested array must still PARSE via the non-allocating skip path, with +/// the known fields intact and value-identical to serde — locking the +/// `skip_string` / `validate_*` twins against the decoding `read_string`. +#[test] +fn hand_parse_accepts_shallow_unknown_with_escapes() { + let json = br#"{"v":1,"method":"GET","path":"/p","x-meta":{"trace":"a\"b\nc\td","u":"\u00e9\uD83D\uDE00"},"flags":[true,null,42,-3.14e2]}"#; + let hand = parse_wire_header(json).expect("hand accepts forward-compat unknown fields"); + let serde = parse_wire_header_serde(json).expect("serde accepts the same input"); + assert_eq!( + owned(&hand), + owned(&serde), + "value drift on unknown-skip path" + ); + assert_eq!(hand.method.as_ref(), "GET"); + assert_eq!(hand.path.as_ref(), "/p"); +} + +/// Fresh `validation_errors` table exercising the full escape set +/// (quote, backslash, newline, a `\u0001` control, tab, non-ASCII) +/// plus the skip-if-none `code`/`message` fields. +fn validation_items() -> Vec { + vec![ + ValidationErrorItem { + path: "user\"name".to_owned(), + code: Some("E\\01".to_owned()), + message: Some("bad\nvalue\u{1}\tré".to_owned()), + }, + ValidationErrorItem { + path: "tags".to_owned(), + code: None, + message: None, + }, + ] +} + +/// The hand-rolled response serializer must produce BYTE-IDENTICAL +/// output to `serde_json` across statuses, the optional +/// `validation_errors` array, sorted single/multi headers, non-UTF-8 +/// values (rendered `""`), and the full string escape set — proven by +/// both the `Vec` path and the `&mut [u8]` slice path. +#[test] +fn hand_serialize_matches_serde_serialize() { + use http::{HeaderMap, HeaderName, HeaderValue}; + + let mut headers = HeaderMap::new(); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers.insert("content-length", HeaderValue::from_static("42")); + headers.insert("x-quote", HeaderValue::from_bytes(b"a\"b").unwrap()); + headers.insert("x-backslash", HeaderValue::from_bytes(b"a\\b").unwrap()); + // Valid UTF-8 obs-text passes through verbatim (no `/` escaping). + headers.insert( + "x-utf8", + HeaderValue::from_bytes("ré sumé/path".as_bytes()).unwrap(), + ); + // Invalid UTF-8 value -> rendered as "" by both paths. + headers.insert("x-binary", HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap()); + let cookie = HeaderName::from_static("set-cookie"); + headers.append(cookie.clone(), HeaderValue::from_static("a=1")); + headers.append(cookie.clone(), HeaderValue::from_static("b=2; Path=/")); + headers.append(cookie, HeaderValue::from_bytes(b"c=\"q\"").unwrap()); + + let metadata = ResponseMetadata::current(); + + for status in [200u16, 404, 422] { + for with_ve in [false, true] { + let hand_items = with_ve.then(validation_items); + let mut hand = Vec::new(); + assert!( + write_wire_header_into( + &mut hand, + status, + &headers, + &metadata, + hand_items.as_deref(), + ), + "header fits u32 (status={status}, with_ve={with_ve})" + ); + + let serde_view = WireResponseHeader { + v: WIRE_VERSION, + status, + headers: &WireHeaders(&headers), + metadata: &metadata, + validation_errors: with_ve.then(validation_items), + }; + let serde_bytes = serde_json::to_vec(&serde_view).expect("serde serialize"); + + assert_eq!( + &hand[4..], + serde_bytes.as_slice(), + "Vec-path byte drift (status={status}, with_ve={with_ve})" + ); + // Length prefix must equal the JSON byte length. + assert_eq!( + u32::from_be_bytes(hand[..4].try_into().unwrap()) as usize, + serde_bytes.len() + ); + } + + // Slice path (always None validation_errors): hand vs serde. + let mut hand_slice = vec![0u8; 4096]; + let n_hand = write_wire_header_into_slice(&mut hand_slice, status, &headers, &metadata); + let mut serde_slice = vec![0u8; 4096]; + let n_serde = + write_wire_header_into_slice_serde(&mut serde_slice, status, &headers, &metadata); + assert_eq!(n_hand, n_serde, "slice length drift (status={status})"); + assert_eq!( + &hand_slice[..n_hand], + &serde_slice[..n_serde], + "slice-path byte drift (status={status})" + ); + } +} + +/// INP-01 regression: the 422 validation-error hoist is genuinely +/// best-effort — a single error object with a wrong-typed field +/// (`"code": 123`, `"message": {...}`, `"code": [..]`) must NOT abort the +/// hoist of the other valid errors. Locks the lenient-field behaviour +/// restored after the typed-deserialize rewrite (matches the prior +/// `serde_json::Value` extract path which used `Value::as_str`). +#[test] +fn hoist_422_is_best_effort_for_wrong_typed_fields() { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + // `b`/`c` carry numeric / object / array `code` & `message` — all wrong + // types; every entry still has a usable string `path`, so the whole array + // must hoist (wrong-typed scalars degrade to `None`, never error). + let body = bytes::Bytes::from_static( + br#"{"errors":[ + {"path":"a","code":"too_short","message":"min 3"}, + {"path":"b","code":123,"message":{"nested":true}}, + {"path":"c","code":[1,2],"message":null} + ]}"#, + ); + let items = super::hoist::try_hoist_validation_errors(&headers, &body) + .expect("a wrong-typed field must not abort the best-effort hoist"); + assert_eq!(items.len(), 3, "every error with a path must be hoisted"); + assert_eq!(items[0].path, "a"); + assert_eq!(items[0].code.as_deref(), Some("too_short")); + assert_eq!(items[0].message.as_deref(), Some("min 3")); + assert_eq!(items[1].path, "b"); + assert_eq!(items[1].code, None); + assert_eq!(items[1].message, None); + assert_eq!(items[2].path, "c"); + assert_eq!(items[2].code, None); + assert_eq!(items[2].message, None); +} + +/// Regression: a NON-OBJECT array element (`null`, a bare string, a number) +/// must be SKIPPED, not abort the whole hoist. Before the lenient fallback +/// switched to a `Value` walk, the typed `Vec` retry failed to +/// deserialize the non-object element and dropped EVERY valid error with it. +#[test] +fn hoist_422_skips_non_object_array_elements() { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + // A valid object, then a `null`, a bare string, and a number — the three + // non-object elements must be skipped while the valid one still hoists. + let body = bytes::Bytes::from_static( + br#"{"errors":[ + {"path":"email","message":"not a valid email"}, + null, + "oops", + 42 + ]}"#, + ); + let items = super::hoist::try_hoist_validation_errors(&headers, &body) + .expect("a non-object element must not discard the valid errors"); + assert_eq!( + items.len(), + 1, + "only the one well-formed error object should hoist" + ); + assert_eq!(items[0].path, "email"); + assert_eq!(items[0].message.as_deref(), Some("not a valid email")); +} + +/// Byte-identity for the TINY-header response fast paths in `write_headers` +/// (0 headers → `{}`; exactly 1 distinct name → no stack-array init / sort; +/// header NAME written without the escape-table scan). The multi-header +/// `hand_serialize_matches_serde_serialize` test never reaches these +/// branches, so this locks 0 / 1-single-value / 1-repeated-value maps against +/// `serde_json` on BOTH the `Vec` and slice paths. +#[test] +fn hand_serialize_matches_serde_for_tiny_header_maps() { + use http::{HeaderMap, HeaderName, HeaderValue}; + + let empty = HeaderMap::new(); + + let mut one = HeaderMap::new(); + one.insert("content-type", HeaderValue::from_static("application/json")); + + let mut one_repeated = HeaderMap::new(); + let cookie = HeaderName::from_static("set-cookie"); + one_repeated.append(cookie.clone(), HeaderValue::from_static("a=1")); + one_repeated.append(cookie, HeaderValue::from_static("b=2; Path=/")); + + let metadata = ResponseMetadata::current(); + + for (label, headers) in [ + ("0-header", &empty), + ("1-header-single", &one), + ("1-header-repeated", &one_repeated), + ] { + for status in [200u16, 204, 404] { + let mut hand = Vec::new(); + assert!( + write_wire_header_into(&mut hand, status, headers, &metadata, None), + "header fits ({label}, status={status})" + ); + let serde_view = WireResponseHeader { + v: WIRE_VERSION, + status, + headers: &WireHeaders(headers), + metadata: &metadata, + validation_errors: None::>, + }; + let serde_bytes = serde_json::to_vec(&serde_view).expect("serde serialize"); + assert_eq!( + &hand[4..], + serde_bytes.as_slice(), + "Vec-path byte drift ({label}, status={status})" + ); + + let mut hand_slice = vec![0u8; 1024]; + let n_hand = write_wire_header_into_slice(&mut hand_slice, status, headers, &metadata); + let mut serde_slice = vec![0u8; 1024]; + let n_serde = + write_wire_header_into_slice_serde(&mut serde_slice, status, headers, &metadata); + assert_eq!( + n_hand, n_serde, + "slice length drift ({label}, status={status})" + ); + assert_eq!( + &hand_slice[..n_hand], + &serde_slice[..n_serde], + "slice-path byte drift ({label}, status={status})" + ); + } + } +} diff --git a/crates/vespera_inprocess/tests/alloc_budget.rs b/crates/vespera_inprocess/tests/alloc_budget.rs new file mode 100644 index 00000000..f08783d2 --- /dev/null +++ b/crates/vespera_inprocess/tests/alloc_budget.rs @@ -0,0 +1,329 @@ +//! Deterministic **allocation-budget gate** for the in-process dispatch +//! hot paths. +//! +//! The criterion timing benches drift ±8–10 % on shared CI runners, so +//! the `bench.yml` gate can only fire at a loose ±10 % threshold — a +//! genuine sub-10 % regression slips through. The *number of heap +//! allocations per dispatch*, by contrast, is **deterministic**: identical +//! inputs allocate identically on every run, every machine. A global +//! counting allocator records `alloc` / `realloc` calls so these tests +//! assert an exact per-op allocation budget — catching an accidental new +//! allocation (or a `Vec` that starts reallocating because a capacity +//! estimate regressed) at **zero noise**. This is the Rust-side analogue +//! of the Java `PerfAllocBench`'s `getThreadAllocatedBytes` approach. +//! +//! Budgets are **upper bounds**: a change that REMOVES allocations passes +//! (and should then tighten the budget); a change that ADDS one fails. +//! +//! All measurements run inside ONE `#[test]` so they execute +//! single-threaded — libtest runs test fns concurrently by default and the +//! allocator counter is process-global, so separate test fns would race. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::Once; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use axum::Router; +use axum::routing::{get, post}; +use bytes::Bytes; +use serde_json::json; +use tokio::runtime::{Builder, Runtime}; +use vespera_inprocess::{ + dispatch_from_bytes, dispatch_into, dispatch_into_async_borrowed, register_app, +}; + +// ── Counting global allocator ──────────────────────────────────────── + +static ALLOCS: AtomicUsize = AtomicUsize::new(0); +static REALLOCS: AtomicUsize = AtomicUsize::new(0); +static BYTES: AtomicUsize = AtomicUsize::new(0); + +struct Counting; + +// SAFETY: every method delegates to the `System` allocator with the exact +// same arguments; we only bump relaxed counters first, which cannot affect +// allocation correctness. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOCS.fetch_add(1, Ordering::Relaxed); + BYTES.fetch_add(layout.size(), Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOCS.fetch_add(1, Ordering::Relaxed); + BYTES.fetch_add(layout.size(), Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + REALLOCS.fetch_add(1, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static GLOBAL: Counting = Counting; + +// ── Fixtures ───────────────────────────────────────────────────────── + +async fn ping() -> &'static str { + "pong" +} + +async fn echo(body: Bytes) -> Bytes { + body +} + +/// Returns a `422` with a JSON `{"errors":[...]}` body so the wire path +/// exercises the `to_wire_bytes` 422 `validation_errors` hoist plus the +/// header-capacity estimate. Two realistic errors push the hoisted header +/// JSON past the `WIRE_HEADER_RESERVE` floor, so a build whose capacity +/// estimate ignores the hoisted errors reallocates once mid-serialize; the +/// validation-errors capacity estimate removes that realloc. +async fn validate_fail() -> axum::response::Response { + use axum::response::IntoResponse; + ( + axum::http::StatusCode::UNPROCESSABLE_ENTITY, + [( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + )], + r#"{"errors":[{"path":"username","message":"length is lower than 3"},{"path":"email","message":"not a valid email"}]}"#, + ) + .into_response() +} + +fn install() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + register_app(|| { + Router::new() + .route("/ping", get(ping)) + .route("/echo", post(echo)) + .route("/validate", post(validate_fail)) + }); + }); +} + +fn runtime() -> Runtime { + Builder::new_current_thread().enable_all().build().unwrap() +} + +/// Assemble `[u32 BE header_len | header JSON | body]` wire bytes with an +/// arbitrary request-header set. +fn encode(method: &str, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec { + let header_map: serde_json::Map = headers + .iter() + .map(|(k, v)| ((*k).to_owned(), serde_json::Value::String((*v).to_owned()))) + .collect(); + let header = json!({ "v": 1, "method": method, "path": path, "headers": header_map }); + let header_bytes = serde_json::to_vec(&header).unwrap(); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&u32::try_from(header_bytes.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + wire +} + +/// One measured allocation sample: per-op `alloc` calls, `realloc` calls, +/// and bytes requested, averaged over `iters` after `warmup` settling ops. +struct Sample { + allocs: usize, + reallocs: usize, + bytes: usize, +} + +/// Run `op` `warmup` times to settle one-time lazy initialisation +/// (`OnceLock` routers / config), then measure `iters` ops against the +/// global counters. Allocation counts are deterministic, so integer +/// division yields the exact per-op figure. +fn measure(warmup: usize, iters: usize, mut op: impl FnMut()) -> Sample { + for _ in 0..warmup { + op(); + } + let a0 = ALLOCS.load(Ordering::Relaxed); + let r0 = REALLOCS.load(Ordering::Relaxed); + let b0 = BYTES.load(Ordering::Relaxed); + for _ in 0..iters { + op(); + } + Sample { + allocs: (ALLOCS.load(Ordering::Relaxed) - a0) / iters, + reallocs: (REALLOCS.load(Ordering::Relaxed) - r0) / iters, + bytes: (BYTES.load(Ordering::Relaxed) - b0) / iters, + } +} + +const HEADERS_16: &[(&str, &str)] = &[ + ("host", "api.example.com"), + ("user-agent", "Mozilla/5.0 (bench) Gecko/20100101"), + ("accept", "application/json, text/plain, */*"), + ("accept-encoding", "gzip, deflate, br"), + ("accept-language", "en-US,en;q=0.9"), + ("content-type", "application/json"), + ( + "authorization", + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + ), + ("x-request-id", "01HV2N3M4P5Q6R7S8T9V0W1X2Y"), + ("x-forwarded-for", "203.0.113.7"), + ("x-forwarded-proto", "https"), + ("referer", "https://app.example.com/dashboard"), + ("cookie", "session=abc123; theme=dark; lang=en"), + ("origin", "https://app.example.com"), + ("cache-control", "no-cache"), + ("connection", "keep-alive"), + ("dnt", "1"), +]; + +#[test] +fn allocation_budgets() { + install(); + let rt = runtime(); + let mut out = vec![0u8; 64 * 1024]; + + // ── Case A: bodyless GET via the borrowed direct-write path. No input + // clone (borrows `wire`), no output `Vec` (writes into `out`), no body + // copy — isolates the pure per-dispatch allocation floor. + let wire_get = encode("GET", "/ping", &[], &[]); + let bodyless = measure(200, 2000, || { + let _ = rt.block_on(dispatch_into_async_borrowed(&wire_get, &mut out)); + }); + + // ── Case B: small POST echo (borrowed). Adds the one body-copy + // allocation (`Bytes::copy_from_slice`) over Case A. + let wire_post = encode( + "POST", + "/echo", + &[("content-type", "application/json")], + br#"{"k":1}"#, + ); + let small_post = measure(200, 2000, || { + let _ = rt.block_on(dispatch_into_async_borrowed(&wire_post, &mut out)); + }); + + // ── Case C: 16-header POST (borrowed). Locks the request-header + // handling allocation count — guards the content-type-scan fusion and + // any future header-path allocation regression. The request-header pair + // `Vec` is now pre-reserved at `TYPICAL_HEADER_CAP` (16), so a 16-header + // request fills WITHOUT the realloc the previous capacity-8 reserve paid + // (40 alloc + 0 realloc; was 40 alloc + 1 realloc). + let wire_hdrs = encode("POST", "/echo", HEADERS_16, br#"{"k":1}"#); + let headers_post = measure(200, 2000, || { + let _ = rt.block_on(dispatch_into_async_borrowed(&wire_hdrs, &mut out)); + }); + + // ── Case D: bodyless GET via the buffered materialise path + // (`dispatch_from_bytes`). Includes the input `wire.clone()` and the + // response `Vec` allocation the direct-write path avoids — guards the + // primary FFI entry point. + let materialise = measure(200, 2000, || { + let _ = dispatch_from_bytes(wire_get.clone(), &rt); + }); + + // ── Case E: bodyless GET via `dispatch_into` (owned input clone, reused + // out buffer) — the JNI `dispatchDirect` sync path shape. + let direct_into = measure(200, 2000, || { + let _ = dispatch_into(wire_get.clone(), &mut out, &rt); + }); + + // ── Case F: 422 JSON response via the buffered materialise path. The + // `to_wire_bytes` 422 path hoists `{"errors":[...]}` into the wire + // header's `validation_errors`. With the hoisted-errors length folded + // into the capacity estimate the response `Vec` is sized to serialise the + // header in one shot — the realloc a hoist-blind estimate paid is gone. + let wire_validate = encode( + "POST", + "/validate", + &[("content-type", "application/json")], + br#"{"x":1}"#, + ); + let validate_422 = measure(200, 2000, || { + let _ = dispatch_from_bytes(wire_validate.clone(), &rt); + }); + + // (label, sample, budget). The gate metric is total per-op allocation + // OPS (`alloc` + `realloc` calls) — the deterministic, noise-free + // figure; bytes/op is informational only. + let cases = [ + ( + "A bodyless-GET borrowed", + &bodyless, + BUDGET_BODYLESS_BORROWED, + ), + ("B small-POST borrowed", &small_post, BUDGET_SMALL_POST), + ( + "C 16-header-POST borrowed", + &headers_post, + BUDGET_HEADERS_POST, + ), + ( + "D bodyless-GET materialise", + &materialise, + BUDGET_MATERIALISE, + ), + ( + "E bodyless-GET dispatch_into", + &direct_into, + BUDGET_DISPATCH_INTO, + ), + ( + "F 422-validate materialise", + &validate_422, + BUDGET_VALIDATE_422, + ), + ]; + + // Print every case first so a regression failure still shows the full + // picture (the asserts below would otherwise stop at the first miss). + for &(name, sample, budget) in &cases { + eprintln!( + "VESPERA_ALLOC {name}: allocs/op={} reallocs/op={} bytes/op={} ops={} (budget {budget})", + sample.allocs, + sample.reallocs, + sample.bytes, + sample.allocs + sample.reallocs + ); + } + for &(name, sample, budget) in &cases { + let ops = sample.allocs + sample.reallocs; + assert!( + ops <= budget, + "{name} allocation regressed: {ops} alloc-ops/op \ + (allocs={}, reallocs={}) exceeds budget {budget}", + sample.allocs, + sample.reallocs + ); + } +} + +// Budgets — total per-op allocation OPS (`alloc` + `realloc` calls), +// measured 2026-06 and verified identical across repeated runs (allocation +// counts are deterministic). UPPER BOUNDS: a change that ADDS an allocation +// trips the matching assert; one that REMOVES allocations passes and SHOULD +// then tighten the constant. Most of each count is axum `router.oneshot` + +// tokio `block_on` (framework), not vespera wire code — the gate guards +// against ADDING to the per-dispatch floor. +// +// BUDGET_HEADERS_POST is now realloc-free: the request-header `Vec` is +// pre-reserved at `TYPICAL_HEADER_CAP` (16), so the 16-header set fills +// without the capacity-8 growth realloc it previously paid. An under-reserve +// regression (a re-introduced realloc, or extra allocs) trips this budget. +const BUDGET_BODYLESS_BORROWED: usize = 14; // borrowed: no clone / no output Vec / no body copy +const BUDGET_SMALL_POST: usize = 22; // borrowed: +1 body copy over bodyless +const BUDGET_HEADERS_POST: usize = 40; // borrowed: 40 alloc + 0 realloc (header Vec pre-reserved at 16) +// MATERIALISE / DISPATCH_INTO dropped by 2 each (was 18 / 17) when the OWNED +// wire path stopped copying the request path into a fresh `Bytes`: a bodyless +// GET's borrowed path now SHARES the request's owning header `Bytes` to build +// the `Uri` (`Uri::from_maybe_shared` via `slice_from_owner`), removing the +// `Uri::try_from(&str)` allocation+copy. A regression that re-introduces the +// path copy (or any other owned-path allocation) trips these tightened budgets. +const BUDGET_MATERIALISE: usize = 16; // dispatch_from_bytes: +input clone +response Vec, URI shared +const BUDGET_DISPATCH_INTO: usize = 15; // dispatch_into: +input clone, reused out, URI shared +// 422 materialise path: the hoisted `validation_errors` JSON is now folded +// into the response-`Vec` capacity estimate, so the wire header serialises +// without the mid-write realloc a hoist-blind estimate paid (26 alloc + 0 +// realloc; was 26 alloc + 1 realloc). A re-introduced realloc trips this. +const BUDGET_VALIDATE_422: usize = 26; // realloc-free 422 hoist (was 27 w/ realloc) diff --git a/crates/vespera_inprocess/tests/binary_wire.rs b/crates/vespera_inprocess/tests/binary_wire.rs index 5a40cc9a..7bfb3a6d 100644 --- a/crates/vespera_inprocess/tests/binary_wire.rs +++ b/crates/vespera_inprocess/tests/binary_wire.rs @@ -11,6 +11,7 @@ //! ``` use std::collections::HashMap; +use std::ops::ControlFlow; use std::sync::Once; use axum::Router; @@ -24,7 +25,7 @@ use serde::Deserialize; use serde_json::Value; use std::sync::Mutex; use tokio::runtime::Builder; -use vespera_inprocess::{dispatch_from_bytes, register_app}; +use vespera_inprocess::{RequestChunk, dispatch_from_bytes, register_app}; // ── Test app ───────────────────────────────────────────────────────── @@ -273,6 +274,7 @@ async fn dispatch_streaming_async_chunks_text_body() { let mut chunks: Vec> = Vec::new(); let header_bytes = vespera_inprocess::dispatch_streaming_async(wire, |chunk| { chunks.push(chunk.to_vec()); + ControlFlow::Continue(()) }) .await; let (header, body) = decode_wire(&header_bytes); @@ -301,6 +303,7 @@ async fn dispatch_streaming_async_large_binary_body() { let mut received: Vec = Vec::with_capacity(big_payload.len()); let header_bytes = vespera_inprocess::dispatch_streaming_async(wire, |chunk| { received.extend_from_slice(chunk); + ControlFlow::Continue(()) }) .await; let (header, _body) = decode_wire(&header_bytes); @@ -311,6 +314,37 @@ async fn dispatch_streaming_async_large_binary_body() { ); } +#[test] +fn wire_response_bytes_are_deterministic_across_dispatches() { + // Response headers serialise from a BTreeMap — identical requests + // MUST produce byte-identical wire responses (golden-file / + // SHA-comparison safety). This pins the V2-C determinism + // guarantee; with the previous HashMap the JSON key order varied + // per response. + install_router(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + // /echo/bytes responds with content-type + content-length — + // multiple headers, which is what exposed the ordering issue. + let wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + b"determinism-probe", + ); + let first = dispatch_from_bytes(wire.clone(), &runtime); + for run in 0..4 { + let again = dispatch_from_bytes(wire.clone(), &runtime); + assert_eq!( + first, again, + "wire response bytes must be identical on repeat dispatch (run {run})" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn dispatch_bidirectional_streaming_roundtrips_small_body() { install_router(); @@ -327,13 +361,20 @@ async fn dispatch_bidirectional_streaming_roundtrips_small_body() { // Request body chunks to push. let chunks: Vec> = vec![b"hello ".to_vec(), b"world".to_vec(), b"!".to_vec()]; let chunks_iter = Mutex::new(chunks.into_iter()); - let pull_chunk = move || -> Option> { chunks_iter.lock().unwrap().next() }; + let pull_chunk = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; // Response body sink. let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); let received_clone = std::sync::Arc::clone(&received); let on_chunk = move |chunk: &[u8]| { received_clone.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) }; let header_bytes = @@ -352,6 +393,147 @@ async fn dispatch_bidirectional_streaming_roundtrips_small_body() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dispatch_bidirectional_streaming_endless_empty_pull_aborts_not_hangs() { + install_router(); + + let header_only_wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + // A hostile producer that ALWAYS reports an empty chunk (mirrors a + // non-conformant InputStream.read() returning 0 forever). Without + // the consecutive-empty cap this busy-spins the blocking-pool thread + // forever; with it, the producer aborts the body so the dispatch + // terminates. A timeout guards against regression to a hang. + let pull_chunk = || -> RequestChunk { RequestChunk::Data(Vec::new()) }; + let on_chunk = |_: &[u8]| ControlFlow::Continue(()); + + let dispatched = tokio::time::timeout( + std::time::Duration::from_secs(10), + vespera_inprocess::dispatch_bidirectional_streaming(header_only_wire, pull_chunk, on_chunk), + ) + .await; + + let header_bytes = dispatched.expect("dispatch must terminate, not busy-spin forever"); + let (header, _body) = decode_wire(&header_bytes); + assert_eq!( + header["status"].as_u64(), + Some(400), + "endless empty reads must abort the upload (400), not hang" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dispatch_bidirectional_streaming_pull_error_aborts_upload() { + install_router(); + + let header_only_wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + // First pull yields a chunk, the second reports a producer error + // (e.g. the source `InputStream` threw mid-upload). The body must + // abort so the handler's `Bytes` extractor fails — NOT be accepted + // as a clean EOF carrying the partial "hello ". + let counter = Mutex::new(0u32); + let pull_chunk = move || -> RequestChunk { + let mut g = counter.lock().unwrap(); + *g += 1; + match *g { + 1 => RequestChunk::Data(b"hello ".to_vec()), + _ => RequestChunk::Error, + } + }; + + let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); + let received_clone = std::sync::Arc::clone(&received); + let on_chunk = move |chunk: &[u8]| { + received_clone.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }; + + let header_bytes = + vespera_inprocess::dispatch_bidirectional_streaming(header_only_wire, pull_chunk, on_chunk) + .await; + + let (header, _body) = decode_wire(&header_bytes); + // axum's `Bytes` extractor rejects a body that errors mid-stream + // (400), instead of the 200 echo of the partial "hello " that the + // old silent-EOF behaviour would have produced. + assert_eq!( + header["status"].as_u64(), + Some(400), + "a producer error must reject the upload, not silently complete it" + ); + // Whatever streams back is axum's 400 rejection body — never the + // partial "hello " echoed as a successful upload. + let echoed = received.lock().unwrap().clone(); + assert_ne!( + echoed.as_slice(), + b"hello ", + "the aborted upload must not be echoed back as a completed body" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dispatch_bidirectional_streaming_empty_chunk_is_retry_not_eof() { + // Pins the pull contract relied on by the JNI bridge: + // `Some(vec![])` means "no data right now, keep pulling" (mirrors + // Java `InputStream.read(byte[]) == 0`), NOT end-of-stream. Data + // arriving AFTER an empty chunk must still reach the handler. + install_router(); + + let header_only_wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + let chunks: Vec> = vec![ + b"before".to_vec(), + Vec::new(), // empty read — must be skipped, not treated as EOF + b" after".to_vec(), + ]; + let chunks_iter = Mutex::new(chunks.into_iter()); + let pull_chunk = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; + + let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); + let received_clone = std::sync::Arc::clone(&received); + let on_chunk = move |chunk: &[u8]| { + received_clone.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }; + + let header_bytes = + vespera_inprocess::dispatch_bidirectional_streaming(header_only_wire, pull_chunk, on_chunk) + .await; + + let (header, _body) = decode_wire(&header_bytes); + assert_eq!(header["status"].as_u64(), Some(200)); + assert_eq!( + String::from_utf8_lossy(&received.lock().unwrap()), + "before after", + "data after an empty pull chunk must still reach the handler" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn dispatch_bidirectional_streaming_large_request_body() { install_router(); @@ -378,12 +560,19 @@ async fn dispatch_bidirectional_streaming_large_request_body() { .collect(); let expected: Vec = request_chunks.iter().flatten().copied().collect(); let chunks_iter = Mutex::new(request_chunks.into_iter()); - let pull_chunk = move || -> Option> { chunks_iter.lock().unwrap().next() }; + let pull_chunk = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); let received_clone = std::sync::Arc::clone(&received); let on_chunk = move |chunk: &[u8]| { received_clone.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) }; let header_bytes = @@ -401,12 +590,69 @@ async fn dispatch_bidirectional_streaming_large_request_body() { ); } +/// A single host `pull()` chunk LARGER than the configured per-frame cap +/// (`streaming_chunk_bytes`, default 256 KiB) must be split into bounded +/// pieces on the wire into the mpsc channel — otherwise one oversized chunk +/// occupies a slot at its full size, defeating the `slots * chunk_bytes` +/// memory bound. This pins that the split preserves the body **byte-for-byte +/// and in order** (a broken split would corrupt or reorder the echo). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dispatch_bidirectional_streaming_oversized_chunk_splits_and_roundtrips() { + install_router(); + + let header_only_wire = encode_wire( + "POST", + "/echo/bytes", + None, + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + // ONE chunk of 1 MiB — 4x the 256 KiB default cap, so the producer must + // emit it as several bounded pieces. A position-dependent pattern makes + // any reorder/truncation in the split path fail the byte-for-byte assert. + let total_size = 1024 * 1024; + let oversized: Vec = (0..total_size) + .map(|i| u8::try_from(i % 256).expect("mod 256")) + .collect(); + let expected = oversized.clone(); + let chunk = Mutex::new(Some(oversized)); + let pull_chunk = move || -> RequestChunk { + chunk + .lock() + .unwrap() + .take() + .map_or(RequestChunk::End, RequestChunk::Data) + }; + + let received: std::sync::Arc>> = std::sync::Arc::new(Mutex::new(Vec::new())); + let received_clone = std::sync::Arc::clone(&received); + let on_chunk = move |chunk: &[u8]| { + received_clone.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }; + + let header_bytes = + vespera_inprocess::dispatch_bidirectional_streaming(header_only_wire, pull_chunk, on_chunk) + .await; + + let (header, _) = decode_wire(&header_bytes); + assert_eq!(header["status"].as_u64(), Some(200)); + + let final_body = received.lock().unwrap().clone(); + assert_eq!(final_body.len(), expected.len(), "size match after split"); + assert_eq!( + final_body, expected, + "1 MiB oversized chunk must split and round-trip byte-for-byte" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn dispatch_bidirectional_streaming_emits_error_wire_on_malformed_header() { install_router(); let bad_header: Vec = vec![0u8, 0, 0, 99]; // overflow - let pull = || -> Option> { None }; - let on = |_: &[u8]| {}; + let pull = || -> RequestChunk { RequestChunk::End }; + let on = |_: &[u8]| ControlFlow::Continue(()); let header_bytes = vespera_inprocess::dispatch_bidirectional_streaming(bad_header, pull, on).await; @@ -422,6 +668,7 @@ async fn dispatch_streaming_async_emits_error_wire_on_malformed_input() { let mut chunks: Vec> = Vec::new(); let header_bytes = vespera_inprocess::dispatch_streaming_async(bad_wire, |chunk| { chunks.push(chunk.to_vec()); + ControlFlow::Continue(()) }) .await; // On error the streaming variant emits a normal error_wire — header + body diff --git a/crates/vespera_inprocess/tests/dispatch_into.rs b/crates/vespera_inprocess/tests/dispatch_into.rs new file mode 100644 index 00000000..3fca4a35 --- /dev/null +++ b/crates/vespera_inprocess/tests/dispatch_into.rs @@ -0,0 +1,266 @@ +//! Integration tests for the direct-write dispatch API +//! ([`vespera_inprocess::dispatch_into_async`]) — the +//! zero-materialisation path used by the JNI direct-buffer symbol. + +use std::sync::Once; + +use axum::Json; +use axum::Router; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use bytes::Bytes; +use serde_json::{Value, json}; +use tokio::runtime::Builder; +use vespera_inprocess::{ + DirectWriteResult, dispatch_from_bytes, dispatch_into, dispatch_into_async_borrowed, + register_app, +}; + +async fn ping() -> &'static str { + "pong" +} + +async fn echo(body: Bytes) -> Bytes { + body +} + +/// Mimics the `Validated` 422 contract: JSON body with an `errors` +/// array — the wire layer must hoist it into the response header. +async fn reject() -> (StatusCode, Json) { + ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"errors": [{"path": "name", "message": "too short"}]})), + ) +} + +fn install() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + register_app(|| { + Router::new() + .route("/ping", get(ping)) + .route("/echo", post(echo)) + .route("/reject", post(reject)) + }); + }); +} + +fn encode(method: &str, path: &str, body: &[u8]) -> Vec { + let header = json!({ + "v": 1, + "method": method, + "path": path, + "headers": {"content-type": "application/octet-stream"}, + }); + let header_bytes = serde_json::to_vec(&header).unwrap(); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&u32::try_from(header_bytes.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + wire +} + +fn decode(wire: &[u8]) -> (Value, Vec) { + let header_len = u32::from_be_bytes(wire[..4].try_into().unwrap()) as usize; + let header: Value = serde_json::from_slice(&wire[4..4 + header_len]).unwrap(); + (header, wire[4 + header_len..].to_vec()) +} + +fn runtime() -> tokio::runtime::Runtime { + Builder::new_current_thread().enable_all().build().unwrap() +} + +#[test] +fn complete_matches_dispatch_from_bytes_exactly() { + install(); + let rt = runtime(); + let body = vec![0xCDu8; 32 * 1024]; + let wire = encode("POST", "/echo", &body); + + let reference = dispatch_from_bytes(wire.clone(), &rt); + let mut out = vec![0u8; reference.len() + 64]; + let result = dispatch_into(wire, &mut out, &rt); + + // V2-C determinism makes byte-equality a valid assertion. + assert_eq!(result, DirectWriteResult::Complete(reference.len())); + assert_eq!(&out[..reference.len()], &reference[..]); +} + +#[test] +fn exact_fit_boundary() { + install(); + let rt = runtime(); + let wire = encode("GET", "/ping", &[]); + let reference = dispatch_from_bytes(wire.clone(), &rt); + + let mut out = vec![0u8; reference.len()]; + let result = dispatch_into(wire, &mut out, &rt); + assert_eq!(result, DirectWriteResult::Complete(reference.len())); + assert_eq!(out, reference); +} + +#[test] +fn overflow_reports_exact_required_size() { + install(); + let rt = runtime(); + let body = vec![0xABu8; 100 * 1024]; + let wire = encode("POST", "/echo", &body); + let reference_len = dispatch_from_bytes(wire.clone(), &rt).len(); + + // Out buffer big enough for the header but not the body. + let mut out = vec![0u8; 256]; + let result = dispatch_into(wire.clone(), &mut out, &rt); + assert_eq!(result, DirectWriteResult::Overflow(reference_len)); + + // Header smaller than even the wire header → still exact. + let mut tiny = vec![0u8; 4]; + let result = dispatch_into(wire, &mut tiny, &rt); + assert_eq!(result, DirectWriteResult::Overflow(reference_len)); +} + +#[test] +fn status_422_preserves_validation_error_hoisting() { + install(); + let rt = runtime(); + let wire = encode("POST", "/reject", b"{}"); + + let reference = dispatch_from_bytes(wire.clone(), &rt); + let (ref_header, _) = decode(&reference); + assert!( + ref_header["validation_errors"].is_array(), + "precondition: byte path hoists validation_errors" + ); + + let mut out = vec![0u8; reference.len() + 64]; + let DirectWriteResult::Complete(n) = dispatch_into(wire, &mut out, &rt) else { + panic!("422 must fit"); + }; + assert_eq!( + &out[..n], + &reference[..], + "422 direct path must be byte-identical to dispatch_from_bytes \ + (hoisting + body verbatim)" + ); + let (header, body) = decode(&out[..n]); + assert_eq!(header["status"].as_u64(), Some(422)); + assert!( + header["validation_errors"].is_array(), + "hoisted validation_errors present" + ); + assert!(!body.is_empty(), "original 422 body preserved verbatim"); +} + +#[test] +fn pre_dispatch_errors_write_error_wire_into_out() { + install(); + let rt = runtime(); + + // Unknown app → 404 wire response written into out. + let header = json!({"v": 1, "method": "GET", "path": "/ping", "app": "ghost"}); + let header_bytes = serde_json::to_vec(&header).unwrap(); + let mut wire = u32::try_from(header_bytes.len()) + .unwrap() + .to_be_bytes() + .to_vec(); + wire.extend_from_slice(&header_bytes); + + let mut out = vec![0u8; 4096]; + let DirectWriteResult::Complete(n) = dispatch_into(wire, &mut out, &rt) else { + panic!("error wire must fit in 4096 bytes"); + }; + let (resp_header, body) = decode(&out[..n]); + assert_eq!(resp_header["status"].as_u64(), Some(404)); + assert!(String::from_utf8_lossy(&body).contains("ghost")); + + // Bad wire version → 400. + let bad = encode("GET", "/ping", &[]); + let mut bad = bad; + // Patch "v":1 → "v":9 inside the JSON header. + let pos = bad + .windows(4) + .position(|w| w == b"\"v\":") + .expect("v field present"); + bad[pos + 4] = b'9'; + let DirectWriteResult::Complete(n) = dispatch_into(bad, &mut out, &rt) else { + panic!("400 wire must fit"); + }; + let (resp_header, _) = decode(&out[..n]); + assert_eq!(resp_header["status"].as_u64(), Some(400)); +} + +#[test] +fn overflow_then_retry_with_exact_size_succeeds() { + install(); + let rt = runtime(); + let body = vec![0x42u8; 8 * 1024]; + let wire = encode("POST", "/echo", &body); + + let mut small = vec![0u8; 16]; + let DirectWriteResult::Overflow(required) = dispatch_into(wire.clone(), &mut small, &rt) else { + panic!("expected overflow"); + }; + + let mut exact = vec![0u8; required]; + let result = dispatch_into(wire.clone(), &mut exact, &rt); + assert_eq!(result, DirectWriteResult::Complete(required)); + assert_eq!(exact, dispatch_from_bytes(wire, &rt)); +} + +#[test] +fn body_without_content_type_matches_byte_path() { + // Regression for the Content-Type defaulting drift: dispatch_parts + // injects `content-type: application/json` for non-empty bodies + // without one; the direct-write path must do the same or JSON + // extractors behave differently across dispatch modes. + install(); + let rt = runtime(); + let header = json!({"v": 1, "method": "POST", "path": "/echo"}); // no headers at all + let header_bytes = serde_json::to_vec(&header).unwrap(); + let body = b"{\"k\":1}"; + let mut wire = u32::try_from(header_bytes.len()) + .unwrap() + .to_be_bytes() + .to_vec(); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + + let reference = dispatch_from_bytes(wire.clone(), &rt); + let mut out = vec![0u8; reference.len() + 64]; + let result = dispatch_into(wire, &mut out, &rt); + assert_eq!(result, DirectWriteResult::Complete(reference.len())); + assert_eq!( + &out[..reference.len()], + &reference[..], + "direct path must apply the same content-type defaulting as the byte path" + ); +} + +#[test] +fn borrowed_matches_byte_path_bodyless_with_body_and_422() { + // The borrowed direct-write path (the JNI dispatchDirect0 entry) must be + // byte-identical to the owned byte path across: a bodyless GET (zero input + // copy), a POST with a body (body-only copy), and a 422 (validation_errors + // hoisting through the shared finish_direct_write tail). + install(); + let rt = runtime(); + for (method, path, body) in [ + ("GET", "/ping", Vec::new()), + ("POST", "/echo", vec![0x5Au8; 4096]), + ("POST", "/reject", b"{}".to_vec()), + ] { + let wire = encode(method, path, &body); + let reference = dispatch_from_bytes(wire.clone(), &rt); + let mut out = vec![0u8; reference.len() + 64]; + let result = rt.block_on(dispatch_into_async_borrowed(&wire, &mut out)); + assert_eq!( + result, + DirectWriteResult::Complete(reference.len()), + "{method} {path}: borrowed must complete with the byte-path length" + ); + assert_eq!( + &out[..reference.len()], + &reference[..], + "{method} {path}: borrowed direct-write must be byte-identical to the byte path" + ); + } +} diff --git a/crates/vespera_inprocess/tests/misc_coverage.rs b/crates/vespera_inprocess/tests/misc_coverage.rs index 752cebcc..94135efc 100644 --- a/crates/vespera_inprocess/tests/misc_coverage.rs +++ b/crates/vespera_inprocess/tests/misc_coverage.rs @@ -12,6 +12,7 @@ //! `dispatch_response_streaming` use std::collections::HashMap; +use std::ops::ControlFlow; use std::sync::{Arc, Mutex, Once}; use axum::Router; @@ -220,8 +221,11 @@ async fn streaming_async_version_mismatch_returns_400_in_returned_bytes() { let wire = encode_wire(99, "GET", "/ping", HashMap::new(), &[], Some(APP)); let chunks_buf: Arc>>> = Arc::new(Mutex::new(Vec::new())); let c = Arc::clone(&chunks_buf); - let header_bytes = - dispatch_streaming_async(wire, move |chunk| c.lock().unwrap().push(chunk.to_vec())).await; + let header_bytes = dispatch_streaming_async(wire, move |chunk| { + c.lock().unwrap().push(chunk.to_vec()); + ControlFlow::Continue(()) + }) + .await; let (header, body) = decode_wire(&header_bytes); assert_eq!(header["status"].as_u64(), Some(400)); let msg = String::from_utf8_lossy(&body); @@ -242,8 +246,11 @@ async fn streaming_async_unknown_app_returns_404() { ); let chunks_buf: Arc>>> = Arc::new(Mutex::new(Vec::new())); let c = Arc::clone(&chunks_buf); - let header_bytes = - dispatch_streaming_async(wire, move |chunk| c.lock().unwrap().push(chunk.to_vec())).await; + let header_bytes = dispatch_streaming_async(wire, move |chunk| { + c.lock().unwrap().push(chunk.to_vec()); + ControlFlow::Continue(()) + }) + .await; let (header, _) = decode_wire(&header_bytes); assert_eq!(header["status"].as_u64(), Some(404)); assert!(chunks_buf.lock().unwrap().is_empty()); @@ -255,8 +262,11 @@ async fn streaming_async_invalid_method_returns_405() { let wire = encode_wire(1, "BAD METHOD", "/ping", HashMap::new(), &[], Some(APP)); let chunks_buf: Arc>>> = Arc::new(Mutex::new(Vec::new())); let c = Arc::clone(&chunks_buf); - let header_bytes = - dispatch_streaming_async(wire, move |chunk| c.lock().unwrap().push(chunk.to_vec())).await; + let header_bytes = dispatch_streaming_async(wire, move |chunk| { + c.lock().unwrap().push(chunk.to_vec()); + ControlFlow::Continue(()) + }) + .await; let (header, body) = decode_wire(&header_bytes); assert_eq!(header["status"].as_u64(), Some(405)); assert!(String::from_utf8_lossy(&body).contains("Method Not Allowed")); @@ -269,8 +279,11 @@ async fn streaming_async_triple_header_exercises_multi_growth() { let wire = encode_wire(1, "GET", "/triple", HashMap::new(), &[], Some(APP)); let chunks_buf: Arc>>> = Arc::new(Mutex::new(Vec::new())); let c = Arc::clone(&chunks_buf); - let header_bytes = - dispatch_streaming_async(wire, move |chunk| c.lock().unwrap().push(chunk.to_vec())).await; + let header_bytes = dispatch_streaming_async(wire, move |chunk| { + c.lock().unwrap().push(chunk.to_vec()); + ControlFlow::Continue(()) + }) + .await; let (header, _) = decode_wire(&header_bytes); assert_eq!(header["status"].as_u64(), Some(200)); let trace = &header["headers"]["x-trace-id"]; @@ -343,6 +356,7 @@ async fn streaming_async_forwards_non_empty_query_string() { let b = Arc::clone(&buf); let header_bytes = dispatch_streaming_async(wire, move |chunk| { b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) }) .await; let (header_json, _) = decode_wire(&header_bytes); @@ -362,6 +376,7 @@ async fn streaming_async_post_body_without_content_type_defaults_to_json() { let b = Arc::clone(&buf); let header_bytes = dispatch_streaming_async(wire, move |chunk| { b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) }) .await; let (header_json, _) = decode_wire(&header_bytes); diff --git a/crates/vespera_inprocess/tests/register_app_named_race.rs b/crates/vespera_inprocess/tests/register_app_named_race.rs new file mode 100644 index 00000000..37736395 --- /dev/null +++ b/crates/vespera_inprocess/tests/register_app_named_race.rs @@ -0,0 +1,93 @@ +//! Regression test for the `register_app_named` first-wins contract under +//! **concurrent** same-name registration. +//! +//! Before the registration write-lock, two (or more) threads racing to +//! register the same name could all pass the `contains_key` pre-check and +//! each invoke their `factory` — the loser's router was then silently +//! discarded by the first-wins insert. That breaks the documented +//! "factory is NOT invoked for a duplicate name" contract and is observable +//! whenever a factory has side effects or is expensive. +//! +//! The fix serializes the *registration write path* with a lock (dispatch +//! reads stay lock-free), so the factory for a given name runs **at most +//! once**. This test maximizes the race with a [`Barrier`] so every thread +//! hits `register_app_named` simultaneously, then asserts exactly one factory +//! invocation and that the first-wins router is dispatchable. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier}; + +use axum::Router; +use axum::routing::get; +use serde_json::Value; +use vespera_inprocess::{dispatch_from_bytes, register_app_named}; + +/// Encode a wire request carrying an explicit `"app"` name (no body). +fn encode_wire_for_app(method: &str, path: &str, app: &str) -> Vec { + let header = serde_json::json!({ "v": 1, "method": method, "path": path, "app": app }); + let header_bytes = serde_json::to_vec(&header).expect("header serialise"); + let header_len = u32::try_from(header_bytes.len()).expect("header fits in u32"); + let mut wire = Vec::with_capacity(4 + header_bytes.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire +} + +/// Decode the wire response status from its length-prefixed JSON header. +fn decode_status(resp: &[u8]) -> u64 { + assert!(resp.len() >= 4, "wire response too short ({})", resp.len()); + let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); + let header_len = u32::from_be_bytes(len_bytes) as usize; + assert!( + 4 + header_len <= resp.len(), + "wire header_len overflows response" + ); + let header: Value = + serde_json::from_slice(&resp[4..4 + header_len]).expect("response header is valid JSON"); + header["status"].as_u64().expect("status is an integer") +} + +#[test] +fn concurrent_same_name_register_invokes_factory_once() { + const THREADS: usize = 16; + let invocations = Arc::new(AtomicUsize::new(0)); + let barrier = Arc::new(Barrier::new(THREADS)); + + let handles: Vec<_> = (0..THREADS) + .map(|_| { + let inv = Arc::clone(&invocations); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + // Release every thread at once to maximize the registration race. + barrier.wait(); + register_app_named("race-app", move || { + inv.fetch_add(1, Ordering::SeqCst); + Router::new().route("/race", get(|| async { "ok" })) + }); + }) + }) + .collect(); + for h in handles { + h.join().expect("registration thread panicked"); + } + + assert_eq!( + invocations.load(Ordering::SeqCst), + 1, + "concurrent same-name register_app_named must invoke the factory exactly \ + once (first-wins); a count > 1 means racing registrations both ran their \ + factory before either inserted" + ); + + // The first-wins router must be dispatchable under its app name. + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + let resp = dispatch_from_bytes(encode_wire_for_app("GET", "/race", "race-app"), &runtime); + assert_eq!( + decode_status(&resp), + 200, + "the first-wins race-app router must be reachable after concurrent registration" + ); +} diff --git a/crates/vespera_inprocess/tests/register_app_reentrant.rs b/crates/vespera_inprocess/tests/register_app_reentrant.rs new file mode 100644 index 00000000..b95b1e49 --- /dev/null +++ b/crates/vespera_inprocess/tests/register_app_reentrant.rs @@ -0,0 +1,56 @@ +//! A router factory that re-enters `register_app*` on the SAME thread must +//! NOT deadlock the non-reentrant registration write lock — it is rejected +//! with an error, and the re-entrancy flag is always cleared (even on a +//! factory panic) so later registrations on that thread still work. + +use vespera_inprocess::{Router, try_register_app_named}; + +/// A factory that re-enters `try_register_app_named` on the same thread is +/// rejected with `Err` instead of deadlocking on the held registration lock. +/// (If the guard regressed, this test would HANG rather than fail.) +#[test] +fn reentrant_registration_returns_err_not_deadlock() { + let outcome = try_register_app_named("reentrant_outer", || { + let inner = try_register_app_named("reentrant_inner", Router::new); + assert!( + inner.is_err(), + "re-entrant registration must return Err, got {inner:?}" + ); + Router::new() + }); + assert_eq!(outcome, Ok(true), "outer registration should succeed"); + + // The inner name was rejected *before* its factory ran, so registering it + // normally afterwards still succeeds (proves the rejection left no state). + assert_eq!( + try_register_app_named("reentrant_inner", Router::new), + Ok(true) + ); +} + +/// A factory panic must clear the re-entrancy flag (via the RAII guard) so the +/// same thread can register again afterwards — it must not be wedged into a +/// permanent "re-entrant" state where every future registration falsely fails. +#[test] +fn factory_panic_clears_reentrancy_flag() { + // Silence the default panic hook for the intentional panic below. + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let panicked = std::panic::catch_unwind(|| { + let _ = try_register_app_named("panic_app", || -> Router { + panic!("intentional factory panic"); + }); + }); + std::panic::set_hook(prev); + assert!( + panicked.is_err(), + "factory panic should propagate to the caller" + ); + + // The flag must have been cleared by the RAII guard during unwind, so a + // subsequent registration on this same thread is NOT falsely rejected. + assert_eq!( + try_register_app_named("after_panic_app", Router::new), + Ok(true) + ); +} diff --git a/crates/vespera_inprocess/tests/request_size_cap.rs b/crates/vespera_inprocess/tests/request_size_cap.rs new file mode 100644 index 00000000..92138c6a --- /dev/null +++ b/crates/vespera_inprocess/tests/request_size_cap.rs @@ -0,0 +1,152 @@ +//! Ingress request-size cap ([`vespera_inprocess::max_request_bytes`]). +//! +//! Runs in its own test binary so the process-global `OnceLock` cap is +//! isolated from the other integration tests (which assume the default +//! unlimited behaviour). Both tests pin the same cap so they are +//! order-independent under the parallel test runner. + +use std::cell::{Cell, RefCell}; +use std::ops::ControlFlow; + +use serde_json::Value; +use tokio::runtime::Builder; +use vespera_inprocess::{ + dispatch_from_bytes, dispatch_streaming_async, dispatch_streaming_with_header_async, + set_max_request_bytes, +}; + +/// Small enough that a tiny valid header passes but a padded request +/// trips the cap. +const CAP: usize = 100; + +fn ensure_cap() { + // First-wins `OnceLock`; every test sets the same value so whichever + // runs first, the effective cap is identical. + let _ = set_max_request_bytes(CAP); +} + +/// Parse the JSON header out of a `[u32 BE len | header JSON | body]` +/// wire response. +fn parse_header_json(resp: &[u8]) -> Value { + assert!(resp.len() >= 4, "wire response too short"); + let header_len = u32::from_be_bytes(resp[..4].try_into().unwrap()) as usize; + serde_json::from_slice(&resp[4..4 + header_len]).expect("response header JSON") +} + +fn dispatch(wire: Vec) -> Value { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("build runtime"); + parse_header_json(&dispatch_from_bytes(wire, &runtime)) +} + +fn block_on(fut: F) -> F::Output { + Builder::new_current_thread() + .enable_all() + .build() + .expect("build runtime") + .block_on(fut) +} + +fn wire_with_body(body_len: usize) -> Vec { + let header = br#"{"v":1,"method":"GET","path":"/ping"}"#; + let mut wire = Vec::new(); + wire.extend_from_slice(&u32::try_from(header.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(header); + wire.extend(std::iter::repeat_n(b'x', body_len)); + wire +} + +#[test] +fn oversized_request_returns_413() { + ensure_cap(); + let wire = wire_with_body(200); // total well over CAP + assert!(wire.len() > CAP); + let header = dispatch(wire); + assert_eq!( + header["status"].as_u64(), + Some(413), + "a request over the cap must be rejected with 413 before allocation" + ); +} + +#[test] +fn within_limit_request_is_not_capped() { + ensure_cap(); + let wire = wire_with_body(0); // small header-only request, under CAP + assert!(wire.len() <= CAP); + let header = dispatch(wire); + // No app is registered in this test binary, so a within-limit request + // falls through to the normal 404 (unknown app) — crucially NOT 413. + assert_ne!( + header["status"].as_u64(), + Some(413), + "a request within the cap must not be rejected as oversized" + ); +} + +// ── Streaming-path ingress cap (INP-01) ────────────────────────────── +// +// Response streaming still buffers the full *request* in memory, so it +// must enforce the same cap as the buffered entry points — unlike +// bidirectional streaming, which pulls the request chunk-by-chunk and +// is intentionally exempt. + +#[test] +fn oversized_streaming_request_returns_413() { + ensure_cap(); + let wire = wire_with_body(200); + assert!(wire.len() > CAP); + + let chunks = Cell::new(0usize); + let header_bytes = block_on(dispatch_streaming_async(wire, |_chunk: &[u8]| { + chunks.set(chunks.get() + 1); + ControlFlow::Continue(()) + })); + + let header = parse_header_json(&header_bytes); + assert_eq!( + header["status"].as_u64(), + Some(413), + "response streaming buffers the full request, so an over-cap request must be 413" + ); + assert_eq!( + chunks.get(), + 0, + "a capped request must never stream body chunks" + ); +} + +#[test] +fn oversized_streaming_with_header_request_returns_413() { + ensure_cap(); + let wire = wire_with_body(200); + assert!(wire.len() > CAP); + + let header_seen: RefCell>> = RefCell::new(None); + let chunks = Cell::new(0usize); + block_on(dispatch_streaming_with_header_async( + wire, + |header: &[u8]| *header_seen.borrow_mut() = Some(header.to_vec()), + |_chunk: &[u8]| { + chunks.set(chunks.get() + 1); + ControlFlow::Continue(()) + }, + )); + + let header_bytes = header_seen + .into_inner() + .expect("the header callback must fire exactly once, even on the 413 cap path"); + let header = parse_header_json(&header_bytes); + assert_eq!( + header["status"].as_u64(), + Some(413), + "the 413 must be delivered through the header callback" + ); + assert_eq!( + chunks.get(), + 0, + "a capped request must never stream body chunks" + ); +} diff --git a/crates/vespera_inprocess/tests/response_body_error.rs b/crates/vespera_inprocess/tests/response_body_error.rs new file mode 100644 index 00000000..252663ac --- /dev/null +++ b/crates/vespera_inprocess/tests/response_body_error.rs @@ -0,0 +1,52 @@ +//! Regression test for INP-04: a response body that errors mid-stream +//! must surface as a `500` wire response — never the original status +//! with a silently-truncated (empty) body. +//! +//! Runs in its own test binary because [`register_app`] is a +//! process-global first-wins registration; isolating it keeps this +//! erroring app from leaking into other integration tests. + +use axum::body::Body; +use axum::response::Response; +use axum::routing::get; +use futures_util::stream; +use tokio::runtime::Builder; +use vespera_inprocess::{Router, dispatch_from_bytes, register_app}; + +/// A `200 OK` whose body's only frame is an error — collecting it fails +/// partway, which the buffered dispatch path must report as a `500`. +async fn erroring_body() -> Response { + let s = + stream::once(async { Err::(std::io::Error::other("boom")) }); + Response::new(Body::from_stream(s)) +} + +fn assemble_wire(method: &str, path: &str) -> Vec { + let header = format!(r#"{{"v":1,"method":"{method}","path":"{path}"}}"#); + let mut wire = Vec::new(); + wire.extend_from_slice(&u32::try_from(header.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(header.as_bytes()); + wire +} + +#[test] +fn response_body_stream_error_becomes_500() { + register_app(|| Router::new().route("/boom", get(erroring_body))); + + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("build runtime"); + let resp = dispatch_from_bytes(assemble_wire("GET", "/boom"), &runtime); + + assert!(resp.len() >= 4, "wire response too short"); + let header_len = u32::from_be_bytes(resp[..4].try_into().unwrap()) as usize; + let header: serde_json::Value = + serde_json::from_slice(&resp[4..4 + header_len]).expect("response header JSON"); + assert_eq!( + header["status"].as_u64(), + Some(500), + "a mid-stream response body error must become a 500, not a silent empty success \ + (the handler's 200 status must NOT be reported with a truncated body)" + ); +} diff --git a/crates/vespera_inprocess/tests/streaming_422_hoist.rs b/crates/vespera_inprocess/tests/streaming_422_hoist.rs new file mode 100644 index 00000000..b27ba925 --- /dev/null +++ b/crates/vespera_inprocess/tests/streaming_422_hoist.rs @@ -0,0 +1,164 @@ +//! The header-first streaming variants hoist 422 `validation_errors` into the +//! wire header (parity with the buffered / direct dispatch paths), while every +//! non-422 streaming response keeps its hoist-free header + streamed body. + +use std::ops::ControlFlow; + +use axum::Router; +use axum::response::IntoResponse; +use axum::routing::post; +use bytes::Bytes; +use serde_json::json; +use vespera_inprocess::{ + RequestChunk, StreamOutcome, dispatch_bidirectional_streaming_with_header, + dispatch_streaming_with_header_async, register_app, +}; + +const VALIDATION_BODY: &str = r#"{"errors":[{"path":"username","message":"length is lower than 3"},{"path":"email","message":"not a valid email"}]}"#; + +async fn validate_fail() -> axum::response::Response { + ( + axum::http::StatusCode::UNPROCESSABLE_ENTITY, + [( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + )], + VALIDATION_BODY, + ) + .into_response() +} + +async fn echo(body: Bytes) -> Bytes { + body +} + +fn install() { + register_app(|| { + Router::new() + .route("/validate", post(validate_fail)) + .route("/echo", post(echo)) + }); +} + +/// Assemble `[u32 BE header_len | header JSON | body]` wire bytes. +fn encode(method: &str, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec { + let header_map: serde_json::Map = headers + .iter() + .map(|(k, v)| ((*k).to_owned(), serde_json::Value::String((*v).to_owned()))) + .collect(); + let header = json!({ "v": 1, "method": method, "path": path, "headers": header_map }); + let header_bytes = serde_json::to_vec(&header).unwrap(); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&u32::try_from(header_bytes.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + wire +} + +/// Decode captured `[u32 BE header_len | JSON]` header bytes into the JSON text. +fn header_json(header_bytes: &[u8]) -> String { + let len = u32::from_be_bytes(header_bytes[0..4].try_into().unwrap()) as usize; + String::from_utf8(header_bytes[4..4 + len].to_vec()).unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_streaming_with_header_hoists_422() { + install(); + let wire = encode( + "POST", + "/validate", + &[("content-type", "application/json")], + br#"{"x":1}"#, + ); + let mut header = Vec::new(); + let mut body = Vec::new(); + let outcome = dispatch_streaming_with_header_async( + wire, + |h: &[u8]| header.extend_from_slice(h), + |c: &[u8]| { + body.extend_from_slice(c); + ControlFlow::Continue(()) + }, + ) + .await; + assert_eq!(outcome, StreamOutcome::Complete); + + let json = header_json(&header); + assert!( + json.contains("\"validation_errors\""), + "422 streaming header must hoist validation_errors: {json}" + ); + assert!( + json.contains("username") && json.contains("email"), + "hoisted paths must appear in the header: {json}" + ); + // The original body is still delivered verbatim through on_chunk. + assert_eq!(String::from_utf8(body).unwrap(), VALIDATION_BODY); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_streaming_with_header_non_422_has_no_hoist() { + install(); + let wire = encode( + "POST", + "/echo", + &[("content-type", "application/json")], + br#"{"hello":"world"}"#, + ); + let mut header = Vec::new(); + let mut body = Vec::new(); + let outcome = dispatch_streaming_with_header_async( + wire, + |h: &[u8]| header.extend_from_slice(h), + |c: &[u8]| { + body.extend_from_slice(c); + ControlFlow::Continue(()) + }, + ) + .await; + assert_eq!(outcome, StreamOutcome::Complete); + + let json = header_json(&header); + assert!( + !json.contains("validation_errors"), + "non-422 header must NOT hoist validation_errors: {json}" + ); + assert!( + json.contains("\"status\":200"), + "expected 200 status: {json}" + ); + assert_eq!(String::from_utf8(body).unwrap(), r#"{"hello":"world"}"#); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bidirectional_with_header_hoists_422() { + install(); + // Bidirectional input is header-only; the body arrives via pull_chunk. The + // /validate handler returns 422 without reading the body, so End suffices. + let wire = encode( + "POST", + "/validate", + &[("content-type", "application/json")], + b"", + ); + let mut header = Vec::new(); + let mut body = Vec::new(); + let outcome = dispatch_bidirectional_streaming_with_header( + wire, + || RequestChunk::End, + |c: &[u8]| { + body.extend_from_slice(c); + ControlFlow::Continue(()) + }, + |h: &[u8]| header.extend_from_slice(h), + ) + .await; + assert_eq!(outcome, StreamOutcome::Complete); + + let json = header_json(&header); + assert!( + json.contains("\"validation_errors\""), + "bidirectional 422 header must hoist validation_errors: {json}" + ); + assert_eq!(String::from_utf8(body).unwrap(), VALIDATION_BODY); +} diff --git a/crates/vespera_inprocess/tests/streaming_with_header.rs b/crates/vespera_inprocess/tests/streaming_with_header.rs index 98598beb..ff0b6887 100644 --- a/crates/vespera_inprocess/tests/streaming_with_header.rs +++ b/crates/vespera_inprocess/tests/streaming_with_header.rs @@ -10,7 +10,11 @@ //! exactly once on every code path (success or error). use std::collections::HashMap; +use std::ops::ControlFlow; +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, Once}; +use std::task::{Context, Poll}; use axum::Router; use axum::http::HeaderMap; @@ -18,10 +22,13 @@ use axum::http::header; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use bytes::Bytes; +use http_body::{Body as HttpBody, Frame}; use serde_json::Value; use vespera_inprocess::{ - dispatch_bidirectional_streaming_with_header, dispatch_streaming_with_header_async, - register_app_named, + DirectWriteResult, RequestChunk, StreamOutcome, dispatch_bidirectional_streaming_closing, + dispatch_bidirectional_streaming_with_header, + dispatch_bidirectional_streaming_with_header_closing, dispatch_into_async, + dispatch_streaming_async, dispatch_streaming_with_header_async, register_app_named, }; // ── Test app ───────────────────────────────────────────────────────── @@ -63,6 +70,78 @@ async fn discard_body() -> &'static str { "ok" } +/// Panics before producing any status/headers — exercises the +/// "handler panic before the header callback fires" path that the JNI +/// layer's `header_sent` fallback depends on. +async fn panic_before_header() -> Response { + panic!("intentional handler panic for test"); +} + +/// Reads the full request body — which lazily starts the bidirectional +/// producer — and THEN panics, so the panic unwinds past the explicit +/// request-source close. Used to verify the RAII close guard still fires +/// `request_close` on a panic unwind (the panic-path sibling of M3). +async fn read_then_panic(_body: Bytes) -> Response { + panic!("intentional panic after reading request body"); +} + +/// Response body that yields one data frame and then errors — simulates a +/// handler streaming from a source (file / DB / upstream) that fails +/// mid-stream. Used to verify a body error is never reported as a clean +/// (truncated) success. +struct ErroringBody { + sent_first: bool, +} + +impl HttpBody for ErroringBody { + type Data = Bytes; + type Error = Box; + + fn poll_frame( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + if self.sent_first { + Poll::Ready(Some(Err("simulated mid-stream body failure".into()))) + } else { + self.sent_first = true; + Poll::Ready(Some(Ok(Frame::data(Bytes::from_static(b"partial"))))) + } + } +} + +async fn erroring_body_handler() -> Response { + Response::new(axum::body::Body::new(ErroringBody { sent_first: false })) +} + +struct MultiChunkBody { + index: usize, +} + +impl HttpBody for MultiChunkBody { + type Data = Bytes; + type Error = std::convert::Infallible; + + fn poll_frame( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let chunk = [ + b"first".as_slice(), + b"second".as_slice(), + b"third".as_slice(), + ] + .get(self.index) + .copied(); + self.index += 1; + Poll::Ready(chunk.map(|bytes| Ok(Frame::data(Bytes::copy_from_slice(bytes))))) + } +} + +async fn multi_chunk_body() -> Response { + Response::new(axum::body::Body::new(MultiChunkBody { index: 0 })) +} + fn make_router() -> Router { Router::new() .route("/ping", get(ping)) @@ -70,6 +149,10 @@ fn make_router() -> Router { .route("/triple", get(triple_header)) .route("/q", get(echo_query)) .route("/discard", post(discard_body)) + .route("/panic", get(panic_before_header)) + .route("/read-panic", post(read_then_panic)) + .route("/err-body", get(erroring_body_handler)) + .route("/multi-chunk", get(multi_chunk_body)) } fn install_router() { @@ -161,7 +244,10 @@ async fn streaming_with_header_emits_header_before_chunks() { dispatch_streaming_with_header_async( wire, move |bytes| h.lock().unwrap().extend_from_slice(bytes), - move |chunk| c.lock().unwrap().push(chunk.to_vec()), + move |chunk| { + c.lock().unwrap().push(chunk.to_vec()); + ControlFlow::Continue(()) + }, ) .await; @@ -187,7 +273,10 @@ async fn streaming_with_header_error_on_short_input_skips_chunk_callback() { dispatch_streaming_with_header_async( bad_wire, move |bytes| h.lock().unwrap().extend_from_slice(bytes), - move |chunk| c.lock().unwrap().push(chunk.to_vec()), + move |chunk| { + c.lock().unwrap().push(chunk.to_vec()); + ControlFlow::Continue(()) + }, ) .await; @@ -213,7 +302,10 @@ async fn streaming_with_header_error_on_version_mismatch() { dispatch_streaming_with_header_async( bad, move |bytes| h.lock().unwrap().extend_from_slice(bytes), - move |chunk| c.lock().unwrap().push(chunk.to_vec()), + move |chunk| { + c.lock().unwrap().push(chunk.to_vec()); + ControlFlow::Continue(()) + }, ) .await; @@ -234,7 +326,10 @@ async fn streaming_with_header_error_on_unknown_app() { dispatch_streaming_with_header_async( bad, move |bytes| h.lock().unwrap().extend_from_slice(bytes), - move |chunk| c.lock().unwrap().push(chunk.to_vec()), + move |chunk| { + c.lock().unwrap().push(chunk.to_vec()); + ControlFlow::Continue(()) + }, ) .await; @@ -255,7 +350,10 @@ async fn streaming_with_header_invalid_method_returns_405_via_header_callback() dispatch_streaming_with_header_async( wire, move |bytes| h.lock().unwrap().extend_from_slice(bytes), - move |chunk| c.lock().unwrap().push(chunk.to_vec()), + move |chunk| { + c.lock().unwrap().push(chunk.to_vec()); + ControlFlow::Continue(()) + }, ) .await; @@ -289,7 +387,10 @@ async fn streaming_with_header_forwards_query_string_via_dispatch_and_split() { dispatch_streaming_with_header_async( wire, move |bytes| h.lock().unwrap().extend_from_slice(bytes), - move |chunk| c.lock().unwrap().extend_from_slice(chunk), + move |chunk| { + c.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, ) .await; @@ -313,7 +414,10 @@ async fn streaming_with_header_triple_header_collapses_into_multi() { dispatch_streaming_with_header_async( wire, move |bytes| h.lock().unwrap().extend_from_slice(bytes), - move |chunk| c.lock().unwrap().push(chunk.to_vec()), + move |chunk| { + c.lock().unwrap().push(chunk.to_vec()); + ControlFlow::Continue(()) + }, ) .await; @@ -345,7 +449,13 @@ async fn bidirectional_with_header_roundtrips_body() { let chunks = vec![b"foo".to_vec(), b"bar".to_vec()]; let chunks_iter = Mutex::new(chunks.into_iter()); - let pull = move || -> Option> { chunks_iter.lock().unwrap().next() }; + let pull = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -355,7 +465,10 @@ async fn bidirectional_with_header_roundtrips_body() { dispatch_bidirectional_streaming_with_header( wire, pull, - move |chunk| b.lock().unwrap().extend_from_slice(chunk), + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, move |hdr| h.lock().unwrap().extend_from_slice(hdr), ) .await; @@ -368,7 +481,7 @@ async fn bidirectional_with_header_roundtrips_body() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn bidirectional_with_header_error_on_short_input() { let bad: Vec = vec![0u8, 0, 0]; // < 4 bytes - let pull = || -> Option> { None }; + let pull = || -> RequestChunk { RequestChunk::End }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let h = Arc::clone(&header_buf); @@ -377,7 +490,10 @@ async fn bidirectional_with_header_error_on_short_input() { dispatch_bidirectional_streaming_with_header( bad, pull, - move |chunk| b.lock().unwrap().extend_from_slice(chunk), + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, move |hdr| h.lock().unwrap().extend_from_slice(hdr), ) .await; @@ -392,7 +508,7 @@ async fn bidirectional_with_header_error_on_short_input() { async fn bidirectional_with_header_error_on_version_mismatch() { install_router(); let bad = encode_bad_version("POST", "/echo"); - let pull = || -> Option> { None }; + let pull = || -> RequestChunk { RequestChunk::End }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let h = Arc::clone(&header_buf); @@ -401,7 +517,10 @@ async fn bidirectional_with_header_error_on_version_mismatch() { dispatch_bidirectional_streaming_with_header( bad, pull, - move |chunk| b.lock().unwrap().extend_from_slice(chunk), + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, move |hdr| h.lock().unwrap().extend_from_slice(hdr), ) .await; @@ -415,7 +534,7 @@ async fn bidirectional_with_header_error_on_version_mismatch() { async fn bidirectional_with_header_error_on_unknown_app() { install_router(); let bad = encode_unknown_app("POST", "/echo"); - let pull = || -> Option> { None }; + let pull = || -> RequestChunk { RequestChunk::End }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let h = Arc::clone(&header_buf); @@ -424,7 +543,10 @@ async fn bidirectional_with_header_error_on_unknown_app() { dispatch_bidirectional_streaming_with_header( bad, pull, - move |chunk| b.lock().unwrap().extend_from_slice(chunk), + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, move |hdr| h.lock().unwrap().extend_from_slice(hdr), ) .await; @@ -438,7 +560,7 @@ async fn bidirectional_with_header_error_on_unknown_app() { async fn bidirectional_with_header_invalid_method_returns_405() { install_router(); let wire = encode_wire("BAD METHOD", "/echo", HashMap::new(), &[]); - let pull = || -> Option> { None }; + let pull = || -> RequestChunk { RequestChunk::End }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let h = Arc::clone(&header_buf); @@ -447,7 +569,10 @@ async fn bidirectional_with_header_invalid_method_returns_405() { dispatch_bidirectional_streaming_with_header( wire, pull, - move |chunk| b.lock().unwrap().extend_from_slice(chunk), + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, move |hdr| h.lock().unwrap().extend_from_slice(hdr), ) .await; @@ -475,15 +600,15 @@ async fn bidirectional_with_header_break_when_receiver_dropped_mid_stream() { let counter = Arc::new(Mutex::new(0u32)); let counter_clone = Arc::clone(&counter); - let pull = move || -> Option> { + let pull = move || -> RequestChunk { let mut g = counter_clone.lock().unwrap(); if *g >= 1000 { - return None; + return RequestChunk::End; } *g += 1; // 4 KiB chunks — large enough that 16 slots ≈ 64 KiB worth // pile up before the handler decides to return. - Some(vec![0u8; 4096]) + RequestChunk::Data(vec![0u8; 4096]) }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -494,7 +619,10 @@ async fn bidirectional_with_header_break_when_receiver_dropped_mid_stream() { dispatch_bidirectional_streaming_with_header( wire, pull, - move |chunk| b.lock().unwrap().extend_from_slice(chunk), + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, move |hdr| h.lock().unwrap().extend_from_slice(hdr), ) .await; @@ -522,15 +650,15 @@ async fn bidirectional_with_header_slow_producer_yields_poll_pending() { let counter = Arc::new(Mutex::new(0u32)); let counter_clone = Arc::clone(&counter); - let pull = move || -> Option> { + let pull = move || -> RequestChunk { let mut g = counter_clone.lock().unwrap(); if *g >= 3 { - return None; + return RequestChunk::End; } *g += 1; // Sleep so the consumer drains the channel and hits Pending. std::thread::sleep(std::time::Duration::from_millis(25)); - Some(b"chunk".to_vec()) + RequestChunk::Data(b"chunk".to_vec()) }; let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -541,7 +669,10 @@ async fn bidirectional_with_header_slow_producer_yields_poll_pending() { dispatch_bidirectional_streaming_with_header( wire, pull, - move |chunk| b.lock().unwrap().extend_from_slice(chunk), + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, move |hdr| h.lock().unwrap().extend_from_slice(hdr), ) .await; @@ -565,13 +696,13 @@ async fn bidirectional_with_header_empty_pull_chunks_are_skipped() { // Second call returns the real body, third returns None (EOF). let counter = Arc::new(Mutex::new(0u32)); let counter_clone = Arc::clone(&counter); - let pull = move || -> Option> { + let pull = move || -> RequestChunk { let mut g = counter_clone.lock().unwrap(); *g += 1; match *g { - 1 => Some(Vec::new()), // empty chunk — must be skipped - 2 => Some(b"X".to_vec()), - _ => None, + 1 => RequestChunk::Data(Vec::new()), // empty chunk — must be skipped + 2 => RequestChunk::Data(b"X".to_vec()), + _ => RequestChunk::End, } }; @@ -583,7 +714,10 @@ async fn bidirectional_with_header_empty_pull_chunks_are_skipped() { dispatch_bidirectional_streaming_with_header( wire, pull, - move |chunk| b.lock().unwrap().extend_from_slice(chunk), + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, move |hdr| h.lock().unwrap().extend_from_slice(hdr), ) .await; @@ -592,3 +726,393 @@ async fn bidirectional_with_header_empty_pull_chunks_are_skipped() { assert_eq!(header_json["status"].as_u64(), Some(200)); assert_eq!(body_buf.lock().unwrap().as_slice(), b"X"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn streaming_with_header_handler_panic_does_not_emit_header() { + // Precondition lock for the JNI layer's `header_sent` fallback: when + // an axum handler panics BEFORE producing status/headers, the panic + // propagates through dispatch_streaming_with_header_async (the + // inprocess layer does NOT catch it) and `on_header` is never called. + // The JNI symbol relies on exactly this — its catch_unwind sees the + // panic with `header_sent == false` and emits a 500 header itself. + install_router(); + let wire = encode_wire("GET", "/panic", HashMap::new(), &[]); + + let header_seen = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let hs = Arc::clone(&header_seen); + + // Drive it on a spawned task so the handler panic surfaces as a + // JoinError instead of unwinding the test thread. + let join = tokio::spawn(async move { + dispatch_streaming_with_header_async( + wire, + move |_header: &[u8]| { + hs.store(true, std::sync::atomic::Ordering::SeqCst); + }, + |_chunk: &[u8]| ControlFlow::Continue(()), + ) + .await; + }) + .await; + + assert!( + join.is_err(), + "a handler panic must propagate (inprocess does not catch it)" + ); + assert!( + !header_seen.load(std::sync::atomic::Ordering::SeqCst), + "on_header must NOT fire when the handler panics before producing a header" + ); +} + +// ── M3: request-source close hook ──────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn bidirectional_closing_invokes_close_after_full_read() { + // M3 regression: when the handler reads the request body (which + // lazily starts the producer), the `request_close` hook fires + // exactly once after the response is drained. This is what lets the + // JNI layer close a Java `InputStream` so a producer parked in a + // blocking read can't hang the dispatch on a stuck upload. + install_router(); + let wire = encode_wire( + "POST", + "/echo", + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + let chunks = vec![b"foo".to_vec(), b"bar".to_vec()]; + let chunks_iter = Mutex::new(chunks.into_iter()); + let pull = move || -> RequestChunk { + chunks_iter + .lock() + .unwrap() + .next() + .map_or(RequestChunk::End, RequestChunk::Data) + }; + + let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let b = Arc::clone(&body_buf); + let close_calls = Arc::new(AtomicUsize::new(0)); + let cc = Arc::clone(&close_calls); + + let header = dispatch_bidirectional_streaming_closing( + wire, + pull, + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, + move || { + cc.fetch_add(1, Ordering::SeqCst); + }, + ) + .await; + + let (header_json, _) = decode_wire(&header); + assert_eq!(header_json["status"].as_u64(), Some(200)); + assert_eq!(body_buf.lock().unwrap().as_slice(), b"foobar"); + assert_eq!( + close_calls.load(Ordering::SeqCst), + 1, + "request_close must fire exactly once after a full-read dispatch" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn bidirectional_with_header_closing_invokes_close_after_full_read() { + install_router(); + let wire = encode_wire( + "POST", + "/echo", + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + let payload = Mutex::new(Some(b"payload".to_vec())); + let pull = move || -> RequestChunk { + payload + .lock() + .unwrap() + .take() + .map_or(RequestChunk::End, RequestChunk::Data) + }; + + let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let h = Arc::clone(&header_buf); + let b = Arc::clone(&body_buf); + let close_calls = Arc::new(AtomicUsize::new(0)); + let cc = Arc::clone(&close_calls); + + dispatch_bidirectional_streaming_with_header_closing( + wire, + pull, + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, + move |hdr| h.lock().unwrap().extend_from_slice(hdr), + move || { + cc.fetch_add(1, Ordering::SeqCst); + }, + ) + .await; + + let (header_json, _) = decode_wire(&header_buf.lock().unwrap()); + assert_eq!(header_json["status"].as_u64(), Some(200)); + assert_eq!(body_buf.lock().unwrap().as_slice(), b"payload"); + assert_eq!( + close_calls.load(Ordering::SeqCst), + 1, + "request_close must fire exactly once after a full-read dispatch" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn bidirectional_with_header_closing_skips_close_when_body_ignored() { + // When the handler never reads the request body, the producer is + // never started, so there is nothing to close — `request_close` must + // NOT fire. A GET handler with no body extractor never polls the + // request body. + install_router(); + let wire = encode_wire("GET", "/ping", HashMap::new(), &[]); + + let pull = || -> RequestChunk { RequestChunk::End }; + let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let h = Arc::clone(&header_buf); + let b = Arc::clone(&body_buf); + let close_calls = Arc::new(AtomicUsize::new(0)); + let cc = Arc::clone(&close_calls); + + dispatch_bidirectional_streaming_with_header_closing( + wire, + pull, + move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }, + move |hdr| h.lock().unwrap().extend_from_slice(hdr), + move || { + cc.fetch_add(1, Ordering::SeqCst); + }, + ) + .await; + + let (header_json, _) = decode_wire(&header_buf.lock().unwrap()); + assert_eq!(header_json["status"].as_u64(), Some(200)); + assert_eq!( + close_calls.load(Ordering::SeqCst), + 0, + "request_close must NOT fire when the handler ignores the body (producer never started)" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn bidirectional_closing_invokes_close_on_handler_panic() { + // Panic-path sibling of M3: the handler reads the full body (starting the + // producer) and then panics, so the unwind skips the explicit close. The + // RAII guard in bidirectional_streaming_inner must STILL fire request_close + // so a producer parked in a blocking source read can be unblocked instead + // of leaking forever. + install_router(); + let wire = encode_wire( + "POST", + "/read-panic", + HashMap::from([("content-type", "application/octet-stream")]), + &[], + ); + + let payload = Mutex::new(Some(b"body".to_vec())); + let pull = move || -> RequestChunk { + payload + .lock() + .unwrap() + .take() + .map_or(RequestChunk::End, RequestChunk::Data) + }; + + let close_calls = Arc::new(AtomicUsize::new(0)); + let cc = Arc::clone(&close_calls); + + // Run on a spawned task so the handler panic surfaces as a JoinError + // instead of unwinding the test thread. + let join = tokio::spawn(async move { + dispatch_bidirectional_streaming_with_header_closing( + wire, + pull, + |_chunk: &[u8]| ControlFlow::Continue(()), + |_hdr: &[u8]| {}, + move || { + cc.fetch_add(1, Ordering::SeqCst); + }, + ) + .await; + }) + .await; + + assert!( + join.is_err(), + "handler panic must propagate (inprocess does not catch it)" + ); + assert_eq!( + close_calls.load(Ordering::SeqCst), + 1, + "request_close must fire via the drop guard even when the handler panics after starting the producer" + ); +} + +// ── Response body stream errors must not be reported as success ─────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn response_streaming_body_error_yields_500_not_truncated_success() { + // A handler whose response body errors mid-stream must surface a 500 + // through the returned wire header, not the original 200 with a silently + // truncated body (dispatch_response_streaming path). + install_router(); + let wire = encode_wire("GET", "/err-body", HashMap::new(), &[]); + let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let b = Arc::clone(&body_buf); + + let header = dispatch_streaming_async(wire, move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Continue(()) + }) + .await; + + let (header_json, err_body) = decode_wire(&header); + assert_eq!( + header_json["status"].as_u64(), + Some(500), + "a response body that errors mid-stream must yield 500, not a truncated 200" + ); + assert!( + !err_body.is_empty(), + "the 500 wire must carry an error body" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn streaming_with_header_body_error_returns_body_error_outcome() { + // Header-first path: the 200 header is committed via `on_header` BEFORE + // the body drains, so a mid-stream body error can no longer change the + // status. The dispatch must report `StreamOutcome::BodyError` so the host + // (JNI bridge) can abort the transport instead of finishing cleanly over a + // truncated body. Regression guard for the silently-swallowed `Err(_)`. + install_router(); + let wire = encode_wire("GET", "/err-body", HashMap::new(), &[]); + let header_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let h = Arc::clone(&header_buf); + + let outcome = dispatch_streaming_with_header_async( + wire, + move |header| h.lock().unwrap().extend_from_slice(header), + |_chunk| ControlFlow::Continue(()), + ) + .await; + + assert_eq!( + outcome, + StreamOutcome::BodyError, + "a response body that errors after the header is committed must report BodyError" + ); + // The header committed as 200 — the error only surfaced afterwards. + let (header_json, _) = decode_wire(&header_buf.lock().unwrap()); + assert_eq!(header_json["status"].as_u64(), Some(200)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn streaming_with_header_chunk_break_returns_sink_stopped_outcome() { + // When the chunk sink returns `Break` (host output sink failed), the + // header-first path must report `StreamOutcome::SinkStopped` rather than a + // clean completion, so the JNI bridge can surface the truncation. + install_router(); + let wire = encode_wire("GET", "/multi-chunk", HashMap::new(), &[]); + let outcome = + dispatch_streaming_with_header_async(wire, |_header| {}, |_chunk| ControlFlow::Break(())) + .await; + assert_eq!( + outcome, + StreamOutcome::SinkStopped, + "a chunk sink that breaks must report SinkStopped" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn response_streaming_chunk_break_returns_500_not_silent_success() { + // When the chunk sink returns `Break` (the host output sink failed + // mid-stream), the non-header `dispatch_streaming_async` must surface a + // 500 — NOT the original success header — so a TRUNCATED response is never + // reported as a clean success. (Mirrors the header-first + // `...sink_stopped_outcome` and direct-write + // `...body_error_yields_500_not_truncated_success` contracts.) + install_router(); + let wire = encode_wire("GET", "/multi-chunk", HashMap::new(), &[]); + let body_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let b = Arc::clone(&body_buf); + + let header = dispatch_streaming_async(wire, move |chunk| { + b.lock().unwrap().extend_from_slice(chunk); + ControlFlow::Break(()) + }) + .await; + + let (header_json, _header_body) = decode_wire(&header); + assert_eq!( + header_json["status"].as_u64(), + Some(500), + "a chunk-sink break must yield 500, not a truncated 200 success" + ); + // The first chunk was already delivered to the sink before the break fired. + assert_eq!(body_buf.lock().unwrap().as_slice(), b"first"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn bidirectional_chunk_break_returns_500_not_silent_success() { + // The non-header BIDIRECTIONAL path must also surface a 500 when the chunk + // sink breaks mid-response, instead of returning the captured success + // header (which would report a truncated bidirectional response as a clean + // success). Mirrors `response_streaming_chunk_break_returns_500...`. + install_router(); + let wire = encode_wire("GET", "/multi-chunk", HashMap::new(), &[]); + let header = dispatch_bidirectional_streaming_closing( + wire, + || RequestChunk::End, // no request body + |_chunk| ControlFlow::Break(()), // sink fails on the first chunk + || {}, // no-op request-source close + ) + .await; + let (header_json, _) = decode_wire(&header); + assert_eq!( + header_json["status"].as_u64(), + Some(500), + "a bidirectional chunk-sink break must yield 500, not truncated success" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn direct_write_body_error_yields_500_not_truncated_success() { + // Direct-write path: the response is buffered into the caller's slice and + // only returned at the end, so a body error must rewrite the buffer to a + // 500 error wire rather than returning the partially-written 200 bytes. + install_router(); + let wire = encode_wire("GET", "/err-body", HashMap::new(), &[]); + let mut out = vec![0u8; 4096]; + + let result = dispatch_into_async(wire, &mut out).await; + let n = match result { + DirectWriteResult::Complete(n) => n, + DirectWriteResult::Overflow(required) => { + panic!("expected Complete (500 fits in 4096), got Overflow({required})") + } + }; + + let (header_json, _) = decode_wire(&out[..n]); + assert_eq!( + header_json["status"].as_u64(), + Some(500), + "direct-write must emit 500 on a body error, not truncated bytes" + ); +} diff --git a/crates/vespera_inprocess/tests/wire_contract.rs b/crates/vespera_inprocess/tests/wire_contract.rs new file mode 100644 index 00000000..446a7af6 --- /dev/null +++ b/crates/vespera_inprocess/tests/wire_contract.rs @@ -0,0 +1,254 @@ +//! **Wire-format contract locks** — byte-exact goldens for the +//! response wire header. +//! +//! These tests pin the serialized JSON *bytes* (field order, header +//! key order, `HeaderValue` untagged shape, metadata layout) so any +//! refactor of `collect_header_map` / wire serialization that changes +//! the observable wire format fails loudly. Do NOT update the +//! expected strings without an explicit wire-format review — Java +//! decoders and HMAC-style byte comparisons depend on this layout. + +use std::collections::HashMap; +use std::sync::Once; + +use axum::Router; +use axum::http::{HeaderMap, HeaderName}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use serde_json::Value; +use tokio::runtime::Builder; +use vespera_inprocess::{dispatch_from_bytes, error_wire, register_app}; + +async fn contract_headers() -> Response { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-single"), + "value-1".parse().unwrap(), + ); + let cookie = HeaderName::from_static("set-cookie"); + headers.append(cookie.clone(), "a=1".parse().unwrap()); + headers.append(cookie, "b=2".parse().unwrap()); + (headers, "ok").into_response() +} + +/// Echo the raw request body back — used by the cross-language golden +/// test so a matched `POST /users` proves the header/body split + routing +/// on the exact bytes the Java encoder produces. +async fn echo_body(body: axum::body::Bytes) -> axum::body::Bytes { + body +} + +fn install_router() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + register_app(|| { + Router::new() + .route("/contract", get(contract_headers)) + .route("/users", post(echo_body)) + }); + }); +} + +fn encode_wire(method: &str, path: &str, headers: HashMap<&str, &str>, body: &[u8]) -> Vec { + let mut header = serde_json::Map::new(); + header.insert("v".to_owned(), Value::from(1u8)); + header.insert("method".to_owned(), Value::String(method.to_owned())); + header.insert("path".to_owned(), Value::String(path.to_owned())); + if !headers.is_empty() { + let headers_json: serde_json::Map = headers + .into_iter() + .map(|(k, v)| (k.to_owned(), Value::String(v.to_owned()))) + .collect(); + header.insert("headers".to_owned(), Value::Object(headers_json)); + } + let header_bytes = serde_json::to_vec(&Value::Object(header)).expect("header serialise"); + let header_len = u32::try_from(header_bytes.len()).expect("header fits u32"); + let mut wire = Vec::with_capacity(4 + header_bytes.len() + body.len()); + wire.extend_from_slice(&header_len.to_be_bytes()); + wire.extend_from_slice(&header_bytes); + wire.extend_from_slice(body); + wire +} + +fn split_wire(resp: &[u8]) -> (String, Vec) { + assert!(resp.len() >= 4, "wire response too short"); + let len_bytes: [u8; 4] = resp[..4].try_into().expect("4 bytes"); + let header_len = u32::from_be_bytes(len_bytes) as usize; + assert!( + 4 + header_len <= resp.len(), + "header_len overflows response" + ); + let header = String::from_utf8(resp[4..4 + header_len].to_vec()).expect("UTF-8 header"); + let body = resp[4 + header_len..].to_vec(); + (header, body) +} + +/// Golden: response wire header bytes for a multi-value-header +/// response. Locks: +/// - struct field order: `v`, `status`, `headers`, `metadata` +/// - BTreeMap alphabetical header key order +/// - `HeaderValue` untagged shape (string vs array) +/// - compact JSON (no whitespace) +#[test] +fn response_wire_header_bytes_are_locked() { + install_router(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + let resp = dispatch_from_bytes( + encode_wire("GET", "/contract", HashMap::new(), &[]), + &runtime, + ); + let (header, body) = split_wire(&resp); + assert_eq!(body, b"ok"); + + // wire-order locked — see module docs before changing. + let expected = format!( + concat!( + r#"{{"v":1,"status":200,"headers":{{"#, + r#""content-length":"2","#, + r#""content-type":"text/plain; charset=utf-8","#, + r#""set-cookie":["a=1","b=2"],"#, + r#""x-single":"value-1""#, + r#"}},"metadata":{{"version":"{version}"}}}}"# + ), + version = env!("CARGO_PKG_VERSION"), + ); + assert_eq!( + header, expected, + "wire response header bytes drifted — this is a WIRE FORMAT BREAK" + ); +} + +/// Golden: `error_wire` bytes. Locks the error path's exact shape — +/// content-type single value + plain-text body. +#[test] +fn error_wire_bytes_are_locked() { + let wire = error_wire(418, "teapot says no"); + let (header, body) = split_wire(&wire); + assert_eq!(body, b"teapot says no"); + + // wire-order locked — see module docs before changing. + let expected = format!( + concat!( + r#"{{"v":1,"status":418,"headers":{{"#, + r#""content-type":"text/plain; charset=utf-8""#, + r#"}},"metadata":{{"version":"{version}"}}}}"# + ), + version = env!("CARGO_PKG_VERSION"), + ); + assert_eq!( + header, expected, + "error_wire header bytes drifted — this is a WIRE FORMAT BREAK" + ); +} + +/// **Cross-language golden (request direction)** — dispatches the +/// byte-identical wire frame the Java encoder produces and asserts the +/// Rust parser accepts it and routes correctly. +/// +/// The header JSON + body below are byte-identical to the Java side's +/// shared golden (`VesperaWireTest.CANONICAL_REQUEST_HEADER_JSON` / +/// `CANONICAL_REQUEST_BODY`). Java asserts its encoder emits exactly +/// these bytes; this test asserts Rust parses exactly these bytes and +/// routes `POST /users` with the body intact. Together they lock the two +/// independent hand-rolled wire implementations against silent drift: a +/// change to either side's field order / structure / framing breaks its +/// own golden assertion. +#[test] +fn cross_language_request_golden_routes() { + install_router(); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + // Byte-identical to the Java cross-language golden — do NOT edit one + // side without the other (see VesperaWireTest). + // + // The query string travels EMBEDDED in `path` (`/users?page=1`), not as a + // separate `query` field: the Java encoder writes the full request target + // into `path` so the Rust dispatch side borrows it directly (no `path + + // '?' + query` String join — ~4% per query-GET, see the `query_path` + // bench). Routing still matches `POST /users` (axum routes on the path + // component) with `page=1` available as the URI query. + let header_json = + br#"{"v":1,"method":"POST","path":"/users?page=1","headers":{"content-type":"application/json"}}"#; + let body = br#"{"x":1}"#; + let mut wire = Vec::with_capacity(4 + header_json.len() + body.len()); + wire.extend_from_slice(&u32::try_from(header_json.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(header_json); + wire.extend_from_slice(body); + + let resp = dispatch_from_bytes(wire, &runtime); + let (header, resp_body) = split_wire(&resp); + + // Body round-trip proves the parser split header/body at the exact + // offset and routed the echo handler; 200 proves `POST /users` matched. + assert_eq!( + resp_body, body, + "cross-language request golden: echo body must round-trip (header/body split + routing)" + ); + assert!( + header.contains(r#""status":200"#), + "cross-language request golden: POST /users must route 200 — got: {header}" + ); +} + +/// Golden: 422 hoisting shape — `validation_errors` appears as the +/// LAST field, after `metadata`, with `path`/`message` entry order. +#[test] +fn validation_hoist_wire_bytes_are_locked() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + vespera_inprocess::register_app_named("contract-422", || { + Router::new().route( + "/reject", + get(|| async { + ( + axum::http::StatusCode::UNPROCESSABLE_ENTITY, + [("content-type", "application/json")], + r#"{"errors":[{"path":"email","message":"not a valid email"}]}"#, + ) + }), + ) + }); + }); + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + let mut req_header = serde_json::Map::new(); + req_header.insert("v".to_owned(), Value::from(1u8)); + req_header.insert("method".to_owned(), Value::String("GET".to_owned())); + req_header.insert("path".to_owned(), Value::String("/reject".to_owned())); + req_header.insert("app".to_owned(), Value::String("contract-422".to_owned())); + let header_bytes = serde_json::to_vec(&Value::Object(req_header)).expect("serialise"); + let mut wire = Vec::with_capacity(4 + header_bytes.len()); + wire.extend_from_slice(&u32::try_from(header_bytes.len()).unwrap().to_be_bytes()); + wire.extend_from_slice(&header_bytes); + + let resp = dispatch_from_bytes(wire, &runtime); + let (header, body) = split_wire(&resp); + assert_eq!( + body, br#"{"errors":[{"path":"email","message":"not a valid email"}]}"#, + "original 422 body must be preserved verbatim" + ); + + // wire-order locked — see module docs before changing. + let expected = format!( + concat!( + r#"{{"v":1,"status":422,"headers":{{"#, + r#""content-length":"59","#, + r#""content-type":"application/json""#, + r#"}},"metadata":{{"version":"{version}"}},"#, + r#""validation_errors":[{{"path":"email","message":"not a valid email"}}]}}"# + ), + version = env!("CARGO_PKG_VERSION"), + ); + assert_eq!( + header, expected, + "422 hoisting wire bytes drifted — this is a WIRE FORMAT BREAK" + ); +} diff --git a/crates/vespera_inprocess/tests/wire_robustness.rs b/crates/vespera_inprocess/tests/wire_robustness.rs new file mode 100644 index 00000000..3d89e655 --- /dev/null +++ b/crates/vespera_inprocess/tests/wire_robustness.rs @@ -0,0 +1,160 @@ +//! Fuzz-style robustness harness for the wire trust boundary. +//! +//! Throws thousands of random, adversarial, and mutated byte sequences +//! at [`vespera_inprocess::dispatch_from_bytes`] and asserts the wire +//! contract on every one: +//! +//! * it **never panics** (no `unwrap`/index/slice/overflow reachable +//! from hostile input), and +//! * it **always returns a well-formed length-prefixed wire response** +//! (`[u32 BE header_len | JSON header]`) whose header is valid JSON +//! carrying a numeric `status`. +//! +//! This is a deterministic (seeded) `cargo test` complement to the +//! coverage-guided `cargo fuzz` target under `fuzz/` (which needs +//! nightly + libFuzzer and runs in CI/Linux). Any panic prints the +//! offending input prefix for replay. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use tokio::runtime::{Builder, Runtime}; +use vespera_inprocess::dispatch_from_bytes; + +/// Tiny deterministic xorshift PRNG — no dependency, exact replay. +struct XorShift(u64); + +impl XorShift { + fn next_u64(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + + fn byte(&mut self) -> u8 { + (self.next_u64() & 0xff) as u8 + } + + /// Uniform in `[0, n)`; returns 0 when `n == 0`. + fn range(&mut self, n: usize) -> usize { + if n == 0 { + return 0; + } + // `v < n` (a `usize`), so it always fits back into `usize`. + usize::try_from(self.next_u64() % n as u64).unwrap_or(0) + } +} + +/// Dispatch `wire`, asserting no panic and a well-formed wire response. +fn assert_robust(rt: &Runtime, wire: &[u8]) { + let owned = wire.to_vec(); + let result = catch_unwind(AssertUnwindSafe(|| dispatch_from_bytes(owned, rt))); + + let Ok(resp) = result else { + let prefix = &wire[..wire.len().min(64)]; + panic!( + "dispatch_from_bytes PANICKED on input (len={}): {prefix:02x?}", + wire.len() + ); + }; + + assert!( + resp.len() >= 4, + "response shorter than the 4-byte length prefix ({} bytes)", + resp.len() + ); + let header_len = u32::from_be_bytes(resp[..4].try_into().unwrap()) as usize; + assert!( + 4 + header_len <= resp.len(), + "response header_len {header_len} overflows response ({} bytes)", + resp.len() + ); + let header: serde_json::Value = serde_json::from_slice(&resp[4..4 + header_len]) + .expect("response header must be valid JSON"); + assert!( + header + .get("status") + .and_then(serde_json::Value::as_u64) + .is_some(), + "response header must carry a numeric status: {header}" + ); +} + +fn runtime() -> Runtime { + Builder::new_current_thread() + .enable_all() + .build() + .expect("build current-thread runtime") +} + +#[test] +fn random_bytes_never_panic() { + let rt = runtime(); + let mut rng = XorShift(0x9E37_79B9_7F4A_7C15); + for _ in 0..5000 { + let len = rng.range(512); + let wire: Vec = (0..len).map(|_| rng.byte()).collect(); + assert_robust(&rt, &wire); + } +} + +#[test] +fn adversarial_header_len_never_panic() { + let rt = runtime(); + // 4-byte length prefixes claiming huge / edge `header_len` values with + // varying tails — exercises the bounds checks in `split_wire_request`. + for header_len in [ + 0u32, + 1, + 3, + 4, + 100, + 0x7fff_ffff, + 0x8000_0000, + 0xffff_fffe, + u32::MAX, + ] { + for tail in [0usize, 1, 4, 16, 64] { + let mut wire = header_len.to_be_bytes().to_vec(); + wire.extend(std::iter::repeat_n(b'{', tail)); + assert_robust(&rt, &wire); + } + } +} + +#[test] +fn structured_mutation_never_panic() { + let rt = runtime(); + // Start from a valid wire request and apply random byte mutations / + // truncations — keeps inputs near the parseable manifold so the + // deeper header-JSON / body-split paths are exercised, not just the + // early length-prefix rejects. + let base = { + let header = br#"{"v":1,"method":"POST","path":"/x","query":"a=1","headers":{"content-type":"application/json"},"app":"_default"}"#; + let mut wire = u32::try_from(header.len()).unwrap().to_be_bytes().to_vec(); + wire.extend_from_slice(header); + wire.extend_from_slice(b"{\"k\":\"v\"}"); + wire + }; + + let mut rng = XorShift(0xDEAD_BEEF_CAFE_BABE); + for _ in 0..3000 { + let mut wire = base.clone(); + let mutations = 1 + rng.range(4); + for _ in 0..mutations { + if wire.is_empty() { + break; + } + let idx = rng.range(wire.len()); + wire[idx] = rng.byte(); + } + // Occasionally truncate to exercise short/partial inputs. + if rng.range(3) == 0 && !wire.is_empty() { + let keep = rng.range(wire.len()); + wire.truncate(keep); + } + assert_robust(&rt, &wire); + } +} diff --git a/crates/vespera_jni/Cargo.toml b/crates/vespera_jni/Cargo.toml index 5190341c..8d5df72b 100644 --- a/crates/vespera_jni/Cargo.toml +++ b/crates/vespera_jni/Cargo.toml @@ -10,6 +10,22 @@ repository.workspace = true vespera_inprocess = { workspace = true } jni = "0.22" tokio = { version = "1", features = ["rt-multi-thread"] } +# `FutureExt::catch_unwind` for the async dispatch panic-isolation path +# (replaces a redundant second `tokio::spawn`). Already in the workspace +# dependency tree via tokio/axum/tower, so this adds no new crate to the +# build — only `std` is needed for the `catch_unwind` combinator. +futures-util = { version = "0.3", default-features = false, features = ["std"] } +# Optional high-performance global allocator for the final cdylib. +# Opt-in because #[global_allocator] is process-wide and must be the +# embedding crate's decision. +mimalloc = { version = "0.1", optional = true } + +[features] +# Use mimalloc as the global allocator inside the JNI cdylib. The +# default OS allocator (Windows HeapAlloc in particular) is measurably +# slower on the allocation-heavy dispatch paths (input Vec, response +# collect, wire response, streaming chunks). +mimalloc = ["dep:mimalloc"] [lints] workspace = true diff --git a/crates/vespera_jni/src/daemon_env.rs b/crates/vespera_jni/src/daemon_env.rs new file mode 100644 index 00000000..7389d97a --- /dev/null +++ b/crates/vespera_jni/src/daemon_env.rs @@ -0,0 +1,258 @@ +//! Thread-local cached daemon attachment to the JVM. +//! +//! Every JNI callback into the JVM needs a [`jni::Env`] valid for the +//! calling OS thread. Non-JVM threads (Tokio workers, `spawn_blocking` +//! pool threads) are not attached, so each callback would otherwise +//! `AttachCurrentThread` + detach — paying that cost **per call**. On +//! the streaming hot path that is once per body chunk (≈ 4096 times for +//! a 1 GiB / 256 KiB stream), and for async completion once per +//! dispatch. +//! +//! [`with_cached_daemon_env`] resolves the current thread's `JNIEnv` +//! **once** and caches it in thread-local storage; every subsequent +//! call on the same thread reuses it: +//! +//! * If the thread is **already attached** (e.g. a JVM-owned servlet +//! request thread driving `Runtime::block_on`), its env is *borrowed* +//! — never detached, because the JVM owns that attachment. +//! * Otherwise the thread is attached as a **daemon** +//! (`AttachCurrentThreadAsDaemon`, so it never blocks JVM shutdown) +//! and the attachment is *owned*: it is released with +//! `DetachCurrentThread` from the thread-local destructor when the OS +//! thread exits (e.g. a `spawn_blocking` worker reaped after its idle +//! timeout). Threads that outlive the process — the leaked static +//! runtime's workers — simply never run the destructor, which is +//! harmless at process teardown. +//! +//! # Safety invariant +//! +//! The cached `*mut jni::sys::JNIEnv` is valid **only for the exact +//! `JavaVM` and OS thread that produced it**. This is upheld structurally: +//! +//! * the pointer lives in a `thread_local!` cell, so it is never +//! observable from another thread; +//! * the raw `JavaVM` pointer is stored beside it and compared on every +//! lookup, so an embedding that invokes this bridge with another VM on +//! the same native thread ejects the stale cache before reuse; +//! * it is produced by `GetEnv` / `AttachCurrentThreadAsDaemon` *for +//! the current thread* and only ever dereferenced inside the same +//! [`with_cached_daemon_env`] call that read it back from TLS; +//! * `jni::Env` is `!Send`/`!Sync`, and the borrow handed to the +//! callback never escapes the closure; +//! * the owning [`CachedEnv`] stays in TLS for the thread's lifetime, +//! so the env stays attached for as long as the cached pointer is +//! reachable. +//! +//! A future polled across `.await` points may resume on a different +//! worker thread; that thread simply finds an empty TLS cell and +//! resolves its own env, so correctness does not depend on thread +//! affinity — only the amortised attach count does. + +use std::cell::RefCell; +use std::ffi::c_void; +use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; +use std::ptr; + +use jni::errors::jni_error_code_to_result; + +/// One thread's cached JVM attachment. Dropped from the thread-local +/// destructor on thread exit; detaches the JVM only for attachments +/// this module created (`owned`). +struct CachedEnv { + env_ptr: *mut jni::sys::JNIEnv, + vm_ptr: *mut jni::sys::JavaVM, + jvm: jni::JavaVM, + owned: bool, +} + +impl Drop for CachedEnv { + fn drop(&mut self) { + if !self.owned { + // Borrowed a JVM-owned thread's env — the JVM owns the + // attachment lifecycle, we must not detach it. + return; + } + let raw_vm = self.jvm.get_raw(); + // SAFETY: `raw_vm` is a valid JavaVM pointer for this process. + // `DetachCurrentThread` runs on the exact OS thread whose daemon + // attachment we created in `resolve_current_env`, releasing the + // JVM's per-thread state as that thread exits. + unsafe { + ((*(*raw_vm)).v1_1.DetachCurrentThread)(raw_vm); + } + } +} + +thread_local! { + /// Cached attachment for the current OS thread (empty until the + /// first [`with_cached_daemon_env`] call resolves it). + static DAEMON_ENV: RefCell> = const { RefCell::new(None) }; +} + +/// Attach the current OS thread to the JVM as a daemon and return its +/// `JNIEnv`. +fn attach_daemon_thread(jvm: &jni::JavaVM) -> jni::errors::Result<*mut jni::sys::JNIEnv> { + let raw_vm = jvm.get_raw(); + let mut env_ptr = ptr::null_mut::(); + let mut args = jni::sys::JavaVMAttachArgs { + version: jni::JNIVersion::V1_4.into(), + name: ptr::null_mut(), + group: ptr::null_mut(), + }; + + // SAFETY: `raw_vm` comes from `Env::get_java_vm()` and is therefore a + // valid JavaVM pointer for this process. JNI 1.4 provides + // `AttachCurrentThreadAsDaemon`; the returned `JNIEnv` is valid only + // on the current OS thread and is cached in thread-local storage by + // the sole caller below. + let res = unsafe { + ((*(*raw_vm)).v1_4.AttachCurrentThreadAsDaemon)( + raw_vm, + &raw mut env_ptr, + (&raw mut args).cast::(), + ) + }; + jni_error_code_to_result(res)?; + if env_ptr.is_null() { + return Err(jni::errors::Error::NullPtr("AttachCurrentThreadAsDaemon")); + } + + Ok(env_ptr.cast()) +} + +/// Resolve the current thread's `JNIEnv`, returning `(env, owned)`. +/// +/// `owned == false` when the thread was **already** attached (the JVM +/// owns it — do not detach); `owned == true` when this call attached it +/// as a daemon (we detach on thread exit). +fn resolve_current_env(jvm: &jni::JavaVM) -> jni::errors::Result<(*mut jni::sys::JNIEnv, bool)> { + let raw_vm = jvm.get_raw(); + let mut env_ptr = ptr::null_mut::(); + let version: jni::sys::jint = jni::JNIVersion::V1_4.into(); + + // SAFETY: `raw_vm` is a valid JavaVM pointer. `GetEnv` reports + // whether the current thread is already attached without creating a + // new attachment. + let res = unsafe { ((*(*raw_vm)).v1_2.GetEnv)(raw_vm, &raw mut env_ptr, version) }; + if res == jni::sys::JNI_OK && !env_ptr.is_null() { + // Already attached (e.g. a JVM-owned request thread) — borrow it. + return Ok((env_ptr.cast(), false)); + } + + // Not attached (Tokio worker / spawn_blocking thread): attach as a + // daemon and take ownership of the attachment lifecycle. + let env_ptr = attach_daemon_thread(jvm)?; + Ok((env_ptr, true)) +} + +/// Run `callback` with a [`jni::Env`] for the current thread, resolving +/// (and caching) the attachment on first use and reusing it thereafter. +/// +/// The callback runs inside a fresh local-reference frame (so JNI local +/// refs created per call do not accumulate on the long-lived thread), +/// and any pending JVM exception is cleared afterwards — replacing the +/// scoped-detach cleanup that jni-rs runs for transient attachments but +/// cached attachments intentionally skip. +/// +/// Panics from `callback` are caught, the exception state is scrubbed, +/// and the panic is resumed so unwinding still cannot cross the FFI +/// boundary uncaught at the JNI entry point. +pub fn with_cached_daemon_env(jvm: &jni::JavaVM, callback: F) -> std::result::Result +where + F: FnOnce(&mut jni::Env<'_>) -> std::result::Result, + E: From, +{ + with_cached_daemon_env_impl(jvm, true, callback) +} + +/// Like [`with_cached_daemon_env`] but **without** wrapping `callback` in +/// a JNI local-reference frame. +/// +/// For the streaming chunk callbacks (`make_pull_closure` / +/// `make_push_closure`) whose hot path uses cached-`JMethodID` +/// `call_method_unchecked` + `get_region`/`set_region` and therefore +/// creates **no** JNI local references per chunk — so the per-chunk +/// `PushLocalFrame`/`PopLocalFrame` of [`with_cached_daemon_env`] is pure +/// overhead (≈ 4096 frame pairs for a 1 GiB / 256 KiB stream). The +/// pending-exception scrub and panic handling are preserved identically; +/// only the local frame is dropped. +/// +/// Callbacks that DO create local refs (e.g. `byte_array_from_slice` in +/// `complete_future` / `call_header_consumer`) MUST keep using +/// [`with_cached_daemon_env`] so those refs are reclaimed per call. +pub fn with_cached_daemon_env_no_frame( + jvm: &jni::JavaVM, + callback: F, +) -> std::result::Result +where + F: FnOnce(&mut jni::Env<'_>) -> std::result::Result, + E: From, +{ + with_cached_daemon_env_impl(jvm, false, callback) +} + +/// Shared implementation of [`with_cached_daemon_env`] (frame) and +/// [`with_cached_daemon_env_no_frame`] (no frame). +fn with_cached_daemon_env_impl( + jvm: &jni::JavaVM, + use_local_frame: bool, + callback: F, +) -> std::result::Result +where + F: FnOnce(&mut jni::Env<'_>) -> std::result::Result, + E: From, +{ + DAEMON_ENV.with(|cell| { + // Resolve + cache under a short-lived borrow, then release it + // before running the callback so a nested call on the same thread + // cannot double-borrow the cell. + let env_ptr = { + let mut slot = cell.borrow_mut(); + let requested_vm = jvm.get_raw(); + if slot + .as_ref() + .is_some_and(|cached| cached.vm_ptr != requested_vm) + { + *slot = None; + } + if slot.is_none() { + let (env_ptr, owned) = resolve_current_env(jvm)?; + *slot = Some(CachedEnv { + env_ptr, + vm_ptr: requested_vm, + jvm: jvm.clone(), + owned, + }); + } + slot.as_ref() + .map(|cached| cached.env_ptr) + .expect("cache populated above") + }; + + // SAFETY: `env_ptr` was resolved for this exact OS thread (see + // the module-level safety invariant) and is confined to this + // thread's TLS cell; it is never shared across threads. The + // owning `CachedEnv` remains in TLS, so the attachment outlives + // this borrow. When `use_local_frame` is true a per-call local + // frame prevents local-ref accumulation on the long-lived thread; + // the no-frame path is reserved for callbacks that create none. + let mut guard = unsafe { jni::AttachGuard::from_unowned(env_ptr) }; + let env = guard.borrow_env_mut(); + let result = catch_unwind(AssertUnwindSafe(|| { + if use_local_frame { + env.with_local_frame(jni::DEFAULT_LOCAL_FRAME_CAPACITY, callback) + } else { + callback(env) + } + })); + + if env.exception_check() { + env.exception_clear(); + } + + match result { + Ok(callback_result) => callback_result, + Err(payload) => resume_unwind(payload), + } + }) +} diff --git a/crates/vespera_jni/src/jni_buf.rs b/crates/vespera_jni/src/jni_buf.rs new file mode 100644 index 00000000..7e49703a --- /dev/null +++ b/crates/vespera_jni/src/jni_buf.rs @@ -0,0 +1,95 @@ +//! Sound, zero-fill-free reads of a Java `byte[]` region into an owned +//! `Vec`. +//! +//! `JByteArray::get_region` (and `Env::convert_byte_array`) require an +//! already-initialised `&mut [i8]` destination, which forces a +//! `vec![0u8; len]` whose every byte is then immediately overwritten by +//! the JNI copy — wasted work that, on the streaming request path, runs +//! once per body chunk (≈ 4096 times for a 1 GiB / 256 KiB upload). +//! +//! This helper instead hands the raw `GetByteArrayRegion` JNI entry a +//! pointer into the `Vec`'s **uninitialised** spare capacity — exactly +//! how jni's own `convert_byte_array` calls `GetByteArrayRegion` +//! internally — and only `set_len`s after the copy succeeds. No +//! `&mut [i8]` reference over uninitialised memory is ever created, so +//! there is no `slice::from_raw_parts_mut`-over-uninit UB (the precise +//! reason the previous code zero-filled first). + +use jni::objects::JByteArray; +use jni::sys::{jarray, jbyte, jsize}; + +/// Read `arr[0..len]` into a fresh `Vec` of length `len`, skipping +/// the zero-fill that `get_region` / `convert_byte_array` pay. +/// +/// On any pending JNI exception (e.g. the array was concurrently shrunk +/// so the region is out of bounds) the exception is cleared and an +/// `Err` is returned with the `Vec` left **empty** — uninitialised bytes +/// are never observable. +pub fn read_byte_array_region( + env: &mut jni::Env<'_>, + arr: &JByteArray<'_>, + len: usize, +) -> jni::errors::Result> { + let mut vec: Vec = Vec::new(); + if len == 0 { + return Ok(vec); + } + // Fallible reservation BEFORE any JNI call: a very large `len` (a multi-GB + // request that passed an unlimited / loose ingress cap, or genuine memory + // pressure) must NOT reach Rust's infallible-allocation OOM handler, which + // ABORTS the process — and an abort takes down the host JVM, uncatchable by + // the `catch_unwind` guards at the JNI entry points. `try_reserve_exact` + // surfaces the failure as a recoverable `NoMemory` error the caller maps to + // a wire `413`, so the documented "degrades to a wire response, never a + // thrown/aborting failure" contract for the ingress read actually holds. + vec.try_reserve_exact(len) + .map_err(|_| jni::errors::Error::JniCall(jni::errors::JniError::NoMemory))?; + // `GetByteArrayRegion` takes a `jsize` (i32) length. `len` never + // exceeds a Java array length (itself `jsize`-bounded), so this only + // fails on a caller bug; surface it as an error rather than truncate. + let region_len = jsize::try_from(len) + .map_err(|_| jni::errors::Error::JniCall(jni::errors::JniError::InvalidArguments))?; + + let env_ptr = env.get_raw(); + let array = arr.as_raw(); + // SAFETY: + // * `env_ptr` is the current thread's valid `JNIEnv`, returned by + // `Env::get_raw()`. Dereferencing it to reach the JNI function + // table and invoking `GetByteArrayRegion` mirrors jni's own + // `convert_byte_array` (and `daemon_env`'s raw VM calls): the + // function-table entries are non-null `extern "system"` pointers. + // * `array` is a live `byte[]` local/global reference; `[0, len)` is + // in bounds because callers pass either the array length (buffered + // path) or the exact positive `InputStream.read(byte[])` count after + // checking it does not exceed the fixed streaming buffer length. + // * The destination is `vec`'s reserved-but-uninitialised capacity + // (`try_reserve_exact(len)` reserved exactly `len` bytes). Only a raw + // `*mut jbyte` is passed to JNI — no `&mut [i8]` over uninitialised + // memory is created. `u8` and `jbyte` (`i8`) share size/alignment. + unsafe { + let interface = *env_ptr; + ((*interface).v1_1.GetByteArrayRegion)( + env_ptr, + array as jarray, + 0, + region_len, + vec.as_mut_ptr().cast::(), + ); + } + + // `GetByteArrayRegion` only throws `ArrayIndexOutOfBoundsException` + // for an out-of-range region; `[0, len)` is in range here, but check + // defensively. Returning before `set_len` keeps the `Vec` empty so + // no uninitialised byte is ever exposed. + if env.exception_check() { + env.exception_clear(); + return Err(jni::errors::Error::JavaException); + } + + // SAFETY: `GetByteArrayRegion` returned with no pending exception, so + // it initialised all `len` destination bytes. + unsafe { + vec.set_len(len); + } + Ok(vec) +} diff --git a/crates/vespera_jni/src/jni_impl.rs b/crates/vespera_jni/src/jni_impl.rs new file mode 100644 index 00000000..81d3225c --- /dev/null +++ b/crates/vespera_jni/src/jni_impl.rs @@ -0,0 +1,1070 @@ +use std::{ + future::Future, + sync::{ + Arc, LazyLock, + atomic::{AtomicBool, Ordering}, + }, +}; + +use futures_util::FutureExt; +use jni::EnvUnowned; +use jni::errors::ThrowRuntimeExAndDefault; +use jni::objects::{JByteArray, JClass, JObject}; +use jni::sys::jbyteArray; + +use crate::daemon_env::with_cached_daemon_env; +use crate::streaming_closures::{ + call_header_consumer, call_header_consumer_local, close_input_stream, complete_future, + complete_future_local, make_pull_closure, make_push_closure, +}; + +// Per-thread reusable Java chunk buffers for the streaming paths live in +// a sidecar module to keep this file within the 1000-line source cap. +#[path = "jni_impl_streaming_buffer.rs"] +mod streaming_buffer; +use streaming_buffer::{PullPushBuffers, mark_streaming_buffer_reusable}; + +// Runtime / streaming configuration JNI hooks (seeded from +// `VesperaBridge.init()` before the first dispatch) live in a sidecar +// module so this file stays focused on the per-request dispatch symbols. +#[path = "jni_impl_config.rs"] +mod config; +pub use config::{runtime_worker_threads, streaming_chunk_size}; + +/// Multi-threaded Tokio runtime shared across all JNI calls. +/// +/// Worker thread count defaults to Tokio's heuristic (number of +/// logical CPUs) and can be capped for embeddings where the JVM's +/// own thread pools (e.g. Tomcat) compete for the same cores — +/// see [`runtime_worker_threads`]. +pub static RUNTIME: LazyLock = LazyLock::new(|| { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + if let Some(workers) = runtime_worker_threads() { + builder.worker_threads(workers); + } + builder + .enable_all() + .build() + .expect("failed to create Tokio runtime") +}); + +/// Cap on each per-thread sync runtime's blocking pool. +/// +/// [`block_on_sync_runtime`] builds ONE current-thread runtime per calling +/// OS thread. A JVM host with a large servlet pool (e.g. 200 Tomcat threads) +/// would otherwise get 200 runtimes each able to spawn Tokio's default 512 +/// blocking threads — a worst case approaching 100k threads if handlers use +/// `spawn_blocking` (the multipart extractor's temp-file I/O does). Capping +/// the per-runtime blocking pool bounds that multiplication. Sync dispatch is +/// for small requests; a handler that exceeds the cap simply runs its +/// blocking tasks in batches — no deadlock, because `block_on` keeps driving +/// the runtime. Detached `tokio::spawn` is still unsupported on this path +/// (see [`block_on_sync_runtime`]). +const SYNC_RUNTIME_MAX_BLOCKING_THREADS: usize = 4; + +thread_local! { + static SYNC_RUNTIME: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .max_blocking_threads(SYNC_RUNTIME_MAX_BLOCKING_THREADS) + .build() + .expect("failed to create per-thread Tokio runtime"); +} + +/// Drive a synchronous JNI dispatch on the calling OS thread's +/// current-thread Tokio runtime. +/// +/// The request future is driven to completion inside this `block_on`, +/// avoiding shared-runtime enter/scheduler contention on tiny +/// `dispatchBytes` / `dispatchDirect` calls. Handlers that await their +/// spawned tasks still complete normally, and `spawn_blocking` uses this +/// runtime's blocking pool. Detached `tokio::spawn` tasks are fragile on +/// this path: a current-thread runtime has no worker threads, so detached +/// tasks only make progress while a later `block_on` runs on the same +/// Java caller thread. The TLS runtime is dropped when that OS thread +/// exits, cleanly shutting down its per-runtime state. +fn block_on_sync_runtime(future: F) -> F::Output +where + F: Future, +{ + SYNC_RUNTIME.with(|runtime| runtime.block_on(future)) +} + +/// Build a `413` wire response when `len` exceeds the configured +/// request-size cap ([`vespera_inprocess::max_request_bytes`]); `None` +/// when within the limit (the default — unlimited). Lets the buffered +/// JNI entry points reject an oversized request **before** allocating +/// the Rust-side body copy that would otherwise double the Java +/// `byte[]` already resident. +fn oversized_request_wire(len: usize) -> Option> { + if vespera_inprocess::request_exceeds_limit(len) { + Some(vespera_inprocess::error_wire( + 413, + &format!( + "request size {len} bytes exceeds configured maximum of {} bytes", + vespera_inprocess::max_request_bytes() + ), + )) + } else { + None + } +} + +/// Clear a pending Java exception (if any) so subsequent JNI calls in +/// the same `with_env` scope are not issued with an exception in flight. +/// +/// A failed `GetArrayLength` / region read / `convert_byte_array` (e.g. +/// a `null` array) can leave a pending exception that would poison the +/// follow-up calls (`byte_array_from_slice`, `complete_future_local`, +/// `call_header_consumer`) the dispatch family uses to deliver the wire +/// error response. Clearing it keeps those calls well-defined. +fn clear_pending_exception(env: &mut jni::Env<'_>) { + if env.exception_check() { + env.exception_clear(); + } +} + +/// Read a request `byte[]` into an owned buffer, centralizing the +/// ingress contract for every buffered JNI dispatch symbol: +/// +/// * `Ok(bytes)` — request body read successfully. +/// * `Err(wire)` — a ready-to-deliver wire response the caller forwards +/// to Java: `413` when the length exceeds the configured cap, `400` +/// when the JNI length query / region read fails. +/// +/// On any JNI failure the pending Java exception is cleared first, so +/// the caller can safely make further JNI calls to deliver `Err`. +fn read_request_byte_array( + env: &mut jni::Env<'_>, + request_bytes: &JByteArray<'_>, +) -> Result, Vec> { + if request_bytes.is_null() { + return Err(vespera_inprocess::error_wire( + 400, + "invalid input byte array (null)", + )); + } + let Ok(len) = request_bytes.len(env) else { + clear_pending_exception(env); + return Err(vespera_inprocess::error_wire( + 400, + "invalid input byte array (length query failed)", + )); + }; + // Ingress cap: reject an oversized request with 413 BEFORE allocating + // the Rust-side body copy (the amplification the Java `byte[]` would + // otherwise double). + if let Some(err) = oversized_request_wire(len) { + return Err(err); + } + // Read straight into uninitialised capacity — no zero-fill that + // `get_region` would immediately overwrite. The reservation inside + // `read_byte_array_region` is fallible, so an oversized request that + // slipped past a loose / unlimited ingress cap reports `NoMemory` and + // degrades to a wire `413` instead of aborting the host JVM. + match crate::jni_buf::read_byte_array_region(env, request_bytes, len) { + Ok(buf) => Ok(buf), + Err(jni::errors::Error::JniCall(jni::errors::JniError::NoMemory)) => { + // try_reserve failed before any JNI call, so there is no pending + // exception to scrub here. + Err(vespera_inprocess::error_wire( + 413, + &format!("request body of {len} bytes could not be allocated"), + )) + } + Err(_) => { + clear_pending_exception(env); + Err(vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + )) + } + } +} + +/// Run a **void** JNI symbol's body under `catch_unwind` so a panic +/// anywhere in it — including the setup that runs *before* the inner +/// dispatch `catch_unwind` (byte-array ingress, global-ref promotion, +/// VM promotion, streaming-buffer checkout, future/header setup) — +/// can never unwind across the `extern "system"` boundary into the JVM. +/// +/// A caught panic is swallowed: the inner dispatch guard already does +/// best-effort future/header completion for the common (handler) panic; +/// this outer guard only covers the rare setup-path panic, where no +/// `Env` is available to complete anything anyway. Matches the +/// whole-body guard already used by `configureRuntime0` / +/// `configureStreaming0`. +fn guard_void_symbol(body: impl FnOnce()) -> bool { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).is_err() +} + +fn panic_wire() -> Vec { + vespera_inprocess::error_wire(500, "panic in Rust engine") +} + +#[path = "jni_impl_support.rs"] +mod support; +use support::{ + FullStreamHeaderSetup, PanicHeaderAction, panic_post_header_action, push_unless_header_failed, + setup_full_stream, setup_full_stream_with_header, setup_stream, setup_stream_with_header, + throw_streaming_abort, +}; + +fn handle_header_dispatch_panic( + env: &mut jni::Env<'_>, + header_consumer: &JObject<'_>, + header_sent: &AtomicBool, + header_failed: &AtomicBool, + header_notified: &AtomicBool, +) { + match panic_post_header_action( + header_sent.load(Ordering::Relaxed), + header_failed.load(Ordering::Acquire), + ) { + PanicHeaderAction::FireFallbackHeader => { + let err = panic_wire(); + let _ = call_header_consumer_local(env, header_consumer, &err); + header_notified.store(true, Ordering::Release); + } + PanicHeaderAction::ThrowAbort => { + throw_streaming_abort(env, header_failed.load(Ordering::Acquire)); + } + } +} + +fn reject_null_header_consumer( + env: &mut jni::Env<'_>, + header_consumer: &JObject<'_>, + header_notified: &AtomicBool, +) -> bool { + if !header_consumer.is_null() { + return false; + } + let _ = env.throw_new( + jni::jni_str!("java/lang/IllegalArgumentException"), + jni::jni_str!("headerConsumer must not be null"), + ); + header_notified.store(true, Ordering::Release); + true +} + +fn notify_local_header( + env: &mut jni::Env<'_>, + header_consumer: &JObject<'_>, + header_bytes: &[u8], + header_notified: &AtomicBool, +) { + let _ = call_header_consumer_local(env, header_consumer, header_bytes); + header_notified.store(true, Ordering::Release); +} + +fn record_header_callback_result( + delivered: bool, + header_sent: &AtomicBool, + header_failed: &AtomicBool, + header_notified: &AtomicBool, +) { + header_notified.store(true, Ordering::Release); + if delivered { + header_sent.store(true, Ordering::Relaxed); + } else { + header_failed.store(true, Ordering::Release); + } +} + +fn read_header_or_notify( + env: &mut jni::Env<'_>, + header_bytes: &JByteArray<'_>, + header_consumer: &JObject<'_>, + header_notified: &AtomicBool, +) -> Option> { + match read_request_byte_array(env, header_bytes) { + Ok(buf) => Some(buf), + Err(err) => { + notify_local_header(env, header_consumer, &err, header_notified); + None + } + } +} + +fn setup_full_header_or_notify( + env: &mut jni::Env<'_>, + header_consumer: &JObject<'_>, + input_stream: &JObject<'_>, + output_stream: &JObject<'_>, + header_notified: &AtomicBool, +) -> Option { + setup_full_stream_with_header(env, header_consumer, input_stream, output_stream).map_or_else( + |_| { + notify_local_header(env, header_consumer, &panic_wire(), header_notified); + None + }, + Some, + ) +} + +struct FullHeaderArgs<'a, 'local> { + header_bytes: &'a JByteArray<'local>, + header_consumer: &'a JObject<'local>, + input_stream: &'a JObject<'local>, + output_stream: &'a JObject<'local>, + header_notified: &'a Arc, +} + +fn dispatch_full_streaming_with_header_body(env: &mut jni::Env<'_>, args: &FullHeaderArgs<'_, '_>) { + if reject_null_header_consumer(env, args.header_consumer, args.header_notified) { + return; + } + let Some(header_input) = read_header_or_notify( + env, + args.header_bytes, + args.header_consumer, + args.header_notified, + ) else { + return; + }; + let Some((header_global, input_global, output_global, jvm, buffers)) = + setup_full_header_or_notify( + env, + args.header_consumer, + args.input_stream, + args.output_stream, + args.header_notified, + ) + else { + return; + }; + let PullPushBuffers { + pull_buf, + pull_buf_lease, + push_buf, + push_buf_lease, + } = buffers; + + let pull_jvm = jvm.clone(); + let pull_global = Arc::clone(&input_global); + let push_jvm = jvm.clone(); + let push_global = output_global; + let close_jvm = jvm.clone(); + let input_for_close = input_global; + let header_jvm = jvm; + let header_for_cb = header_global; + + let header_sent = Arc::new(AtomicBool::new(false)); + let header_failed = Arc::new(AtomicBool::new(false)); + let header_sent_cb = Arc::clone(&header_sent); + let header_failed_cb = Arc::clone(&header_failed); + let header_notified_cb = Arc::clone(args.header_notified); + let header_failed_push = Arc::clone(&header_failed); + let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut push = make_push_closure(push_jvm, push_global, push_buf); + RUNTIME.block_on( + vespera_inprocess::dispatch_bidirectional_streaming_with_header_closing( + header_input, + make_pull_closure(pull_jvm, pull_global, pull_buf), + move |chunk: &[u8]| { + push_unless_header_failed(&header_failed_push, &mut push, chunk) + }, + |header_bytes: &[u8]| { + let delivered = with_cached_daemon_env( + &header_jvm, + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + call_header_consumer(env, &header_for_cb, header_bytes) + }, + ) + .is_ok(); + record_header_callback_result( + delivered, + &header_sent_cb, + &header_failed_cb, + &header_notified_cb, + ); + }, + move || { + let _ = with_cached_daemon_env(&close_jvm, |env| { + close_input_stream(env, &input_for_close) + }); + }, + ), + ) + })); + match panic_result { + Ok(outcome) => { + mark_streaming_buffer_reusable(pull_buf_lease); + mark_streaming_buffer_reusable(push_buf_lease); + let failed_header = header_failed.load(Ordering::Acquire); + if failed_header + || matches!( + outcome, + vespera_inprocess::StreamOutcome::BodyError + | vespera_inprocess::StreamOutcome::SinkStopped + ) + { + throw_streaming_abort(env, failed_header); + } + } + Err(_) => handle_header_dispatch_panic( + env, + args.header_consumer, + &header_sent, + &header_failed, + args.header_notified, + ), + } +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchBytes(byte[]) -> byte[]` +/// +/// **Synchronous** binary wire-format JNI entry point. Blocks the +/// calling thread until the Rust dispatch completes. The request-array +/// read AND the dispatch run inside a single `catch_unwind`, so a panic +/// anywhere in that work (including an allocation failure in the ingress +/// read) degrades to a valid wire-format `500` response rather than +/// surfacing as a thrown Java exception. The only step outside the guard +/// is the final `byte_array_from_slice` that hands the bytes back, itself +/// covered by the `with_env`/`resolve` FFI boundary — so a panic can never +/// unwind across the `extern "system"` boundary into the JVM. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchBytes<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + request_bytes: JByteArray<'local>, +) -> jbyteArray { + let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + unowned_env + .with_env(|env| -> jni::errors::Result> { + // Read + dispatch under ONE guard: a panic in the ingress read + // (e.g. allocation failure for an unbounded request) now also + // degrades to a wire `500` instead of a thrown Java exception. + let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + match read_request_byte_array(env, &request_bytes) { + Ok(input) => block_on_sync_runtime( + vespera_inprocess::dispatch_from_bytes_async(input), + ), + Err(err_wire) => err_wire, + } + })) + .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); + + Ok(env.byte_array_from_slice(&response)?.into()) + }) + .resolve::() + .into_raw() + })); + guarded.unwrap_or_else(|_| { + unowned_env + .with_env(|env| -> jni::errors::Result> { + Ok(env.byte_array_from_slice(&panic_wire())?.into()) + }) + .resolve::() + .into_raw() + }) +} + +#[path = "jni_impl_direct.rs"] +mod direct; + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchAsync(CompletableFuture, byte[]) -> void` +/// +/// **Asynchronous** binary wire-format JNI entry point. Returns +/// immediately after spawning the dispatch on the shared Tokio +/// runtime. Completes the supplied `CompletableFuture` +/// from a runtime worker thread once the response is ready. +/// +/// Contract (always-complete): +/// - **success** → `future.complete(responseBytes)` +/// - **JNI conversion failure** → `future.complete(error_wire(400, ...))` +/// - **Rust panic / handler crash** → `future.complete(error_wire(500, "panic in Rust engine"))` +/// The future is always completed with a valid wire response — +/// it is never left dangling, even on internal errors. +/// +/// # Threading contract (IMPORTANT) +/// +/// The future is completed **on a Tokio runtime worker thread**, so any +/// *non-async* `CompletableFuture` continuation (`thenApply`, `thenAccept`, +/// `whenComplete`, …) runs **inline on that worker**. Callers MUST therefore: +/// - attach heavy / blocking continuations with the `*Async` variants +/// (`thenApplyAsync`, `whenCompleteAsync`, …) on their own executor, and +/// - never re-enter a blocking vespera dispatch from an inline continuation — +/// that nests a `block_on` inside the runtime and degrades to a caught-panic +/// `500`. This applies to EVERY blocking JNI entry point, not just +/// `dispatchBytes` / `dispatchDirect`: the streaming symbols +/// (`dispatchStreaming`, `dispatchFullStreaming`, and their `*WithHeader` +/// variants) also `RUNTIME.block_on(...)` and are *more* damaging to +/// re-enter because they hold a worker across the entire response/request +/// stream. +/// +/// Completing the future off the worker (via `spawn_blocking`) was measured at +/// ~16x the per-dispatch cost (`vespera_inprocess` `benches/dispatch.rs`, +/// group `async_completion_ab`: ~1.5 µs inline vs ~24.5 µs hand-off), so the +/// worker-thread completion is kept and this contract is documented instead — +/// matching how Netty / async HTTP clients complete futures from their I/O +/// threads. The autoconfigured Spring proxy never selects `ASYNC` (its +/// `SmartDispatchModeResolver` uses DIRECT / SYNC / streaming), so this path is +/// opt-in for callers doing their own `CompletableFuture` composition. +/// +/// Cancellation: Java's `future.cancel(true)` does NOT abort the +/// in-flight Rust task in this iteration (defer to follow-up). +/// Java callers may still observe cancellation via `future.isCancelled()`. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchAsync<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + future_obj: JObject<'local>, + request_bytes: JByteArray<'local>, +) { + let future_notified = Arc::new(AtomicBool::new(false)); + let future_notified_body = Arc::clone(&future_notified); + // The only unrecoverable path is failing to promote the future to a + // GlobalRef (below): without that ref there is nothing to complete, + // and a failure there means the JVM is already in trouble. Every + // path AFTER the ref exists completes the future, so the + // always-complete contract holds even on VM-promotion / scheduling + // failures. + // + // JNI-03: the entire body runs under `guard_void_symbol` so a panic + // in the setup that precedes the inner dispatch guard cannot unwind + // across this `extern "system"` boundary. + let panicked = guard_void_symbol(|| { + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + if future_obj.is_null() { + let _ = env.throw_new( + jni::jni_str!("java/lang/IllegalArgumentException"), + jni::jni_str!("future must not be null"), + ); + future_notified_body.store(true, Ordering::Release); + return Ok(()); + } + // On-thread cold paths (oversized, JNI conversion failure, VM + // promotion / scheduling failure) complete the future via the + // still-valid LOCAL `future_obj` ref, so only the spawned task + // needs a `Global` ref (created just before the spawn below) — + // instead of a second one held solely for these paths. + let input = match read_request_byte_array(env, &request_bytes) { + Ok(buf) => buf, + Err(err) => { + let _ = complete_future_local(env, &future_obj, &err); + future_notified_body.store(true, Ordering::Release); + return Ok(()); + } + }; + + // Promote the VM; on the (near-impossible) failure complete the + // future we already hold so it never dangles. + let jvm = match env.get_java_vm() { + Ok(jvm) => jvm, + Err(e) => { + let _ = complete_future_local( + env, + &future_obj, + &vespera_inprocess::error_wire(500, "JNI VM promotion failed"), + ); + future_notified_body.store(true, Ordering::Release); + return Err(e); + } + }; + + // The single owning global ref, created only now and moved into + // the spawned task (which completes the future from a worker + // thread). Every on-thread path uses the local `future_obj` + // instead, so this is the only `Global` ref allocated per call. + let future_for_task = match env.new_global_ref(&future_obj) { + Ok(g) => g, + Err(e) => { + let _ = complete_future_local( + env, + &future_obj, + &vespera_inprocess::error_wire(500, "JNI global ref failed"), + ); + future_notified_body.store(true, Ordering::Release); + return Err(e); + } + }; + + // A panic in the dispatch future is caught **in place** with + // `FutureExt::catch_unwind` instead of isolating it in a second + // `tokio::spawn` task — same panic → 500 wire fallback (preserving + // always-complete semantics for the Java future), but one fewer + // task allocation + scheduler hop per async dispatch. The inner + // spawn never bought parallelism here (the outer task awaited it + // immediately), so it was pure overhead. `AssertUnwindSafe` is + // sound: a panic drops the half-run dispatch and we return a fresh + // `error_wire`; the registered `Router` is `Arc`-shared and is not + // left observably inconsistent. The outer `catch_unwind` still + // guards `RUNTIME.spawn` itself so a scheduling failure completes + // the future (with a 500) instead of leaving the Java caller + // hanging. + let scheduled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.spawn(async move { + let response = std::panic::AssertUnwindSafe( + vespera_inprocess::dispatch_from_bytes_async(input), + ) + .catch_unwind() + .await + .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); + + // ALWAYS-COMPLETE CONTRACT: the Java CompletableFuture must + // resolve on every path or `dispatchAsync` callers hang + // forever. The cached-daemon completion can fail (daemon + // attach during VM shutdown, or an OOM allocating the + // response byte[]); on failure make a best-effort second + // attempt with a tiny error payload (far less likely to OOM + // than the full response) so the future still resolves with + // an error rather than never. If even that fails the JVM is + // unrecoverable and nothing could complete it. + let completed = + with_cached_daemon_env(&jvm, |env| -> jni::errors::Result<()> { + complete_future(env, &future_for_task, &response) + }); + if completed.is_err() { + let _ = with_cached_daemon_env(&jvm, |env| -> jni::errors::Result<()> { + complete_future( + env, + &future_for_task, + &vespera_inprocess::error_wire(500, "async completion failed"), + ) + }); + } + }); + })); + if scheduled.is_err() { + let _ = complete_future_local( + env, + &future_obj, + &vespera_inprocess::error_wire(500, "failed to schedule Rust dispatch"), + ); + future_notified_body.store(true, Ordering::Release); + } else { + future_notified_body.store(true, Ordering::Release); + } + + Ok(()) + }); + }); + if panicked && !future_notified.load(Ordering::Acquire) && !future_obj.is_null() { + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + complete_future_local(env, &future_obj, &panic_wire()) + }); + } +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchStreaming(byte[], OutputStream) -> byte[]` +/// +/// **Streaming** JNI entry point. Drives the dispatch +/// synchronously like [`Java_...dispatchBytes`], but emits the +/// response body chunk-by-chunk by calling `outputStream.write(byte[])` +/// for each chunk axum produces — no full-body materialisation on +/// either the Rust or JVM side. +/// +/// Returns the wire-format **header only** (`[u32 BE header_len | +/// header JSON]`) — the body is delivered through the +/// `OutputStream` argument while the dispatch is in flight. +/// Callers (e.g. Spring `StreamingResponseBody`) read the header +/// first to commit the HTTP status + response headers, then +/// continue serving the streamed body bytes. +/// +/// Failure modes mirror [`Java_...dispatchBytes`]: a **pre-streaming** +/// failure (malformed wire, version mismatch, no app registered, or a panic +/// before the first body frame) produces a regular `error_wire(...)` response +/// (header + small body) and the `OutputStream` is **not** written to. A +/// failure that occurs **after** the first body frame (the host +/// `OutputStream` erroring mid-drain, or a body-stream error) may leave +/// partial bytes already written to the `OutputStream`; it is still reported +/// as a `500` `error_wire(...)` header return, so the caller must treat a +/// `5xx` header returned after streaming has begun as a truncated response. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreaming<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + request_bytes: JByteArray<'local>, + output_stream: JObject<'local>, +) -> jbyteArray { + let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + unowned_env + .with_env(|env| -> jni::errors::Result> { + let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || -> jni::errors::Result> { + if output_stream.is_null() { + return Ok(env + .byte_array_from_slice(&vespera_inprocess::error_wire( + 400, + "outputStream must not be null", + ))? + .into()); + } + let input = match read_request_byte_array(env, &request_bytes) { + Ok(buf) => buf, + Err(err) => return Ok(env.byte_array_from_slice(&err)?.into()), + }; + + // Promote the OutputStream to a Global (so the streaming + // callback can call .write() from a daemon-attached worker + // thread), grab the VM, and check out the per-thread push + // chunk buffer. On ANY setup failure (rare, OOM-driven) the + // previous bare `?` returned an ignored `Err` from `with_env` + // → `resolve::` threw a Java + // exception + returned `null`, breaking the "every failure is + // a valid wire response" contract. Return a `500` wire + // response instead so the Java decoder is never handed `null`. + let Ok((stream_global, jvm, push_buf, push_buf_lease)) = + setup_stream(env, &output_stream) + else { + clear_pending_exception(env); + return Ok(env + .byte_array_from_slice(&vespera_inprocess::error_wire( + 500, + "JNI streaming setup failed", + ))? + .into()); + }; + + let header_bytes = + RUNTIME.block_on(vespera_inprocess::dispatch_streaming_async( + input, + make_push_closure(jvm, stream_global, push_buf), + )); + mark_streaming_buffer_reusable(push_buf_lease); + + Ok(env.byte_array_from_slice(&header_bytes)?.into()) + }, + )) + .unwrap_or_else(|_| Ok(env.byte_array_from_slice(&panic_wire())?.into()))?; + + Ok(response) + }) + .resolve::() + .into_raw() + })); + guarded.unwrap_or_else(|_| { + unowned_env + .with_env(|env| -> jni::errors::Result> { + Ok(env.byte_array_from_slice(&panic_wire())?.into()) + }) + .resolve::() + .into_raw() + }) +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchFullStreaming(byte[], InputStream, OutputStream) -> byte[]` +/// +/// **Bidirectional streaming** JNI entry point. Reads the request +/// body chunk-by-chunk from `inputStream.read(byte[])` and emits +/// response body chunks via `outputStream.write(byte[])` — neither +/// side ever materialises the full body in memory, so 1 GiB +/// uploads with 1 GiB downloads run in O(chunk_size) RAM. +/// +/// Returns the wire-format **header only** (`[u32 BE header_len | +/// header JSON]`); the response body was delivered through +/// `outputStream`. +/// +/// Wire envelope contract: +/// - `headerBytes` is a wire-format request **without a body** +/// (just the 4-byte length prefix + JSON header). Send the +/// request body via `inputStream`, not embedded in this buffer. +/// - `inputStream.read(byte[])` semantics: returns `-1` on EOF, +/// `0` for an empty read (will be retried), or `>0` for the +/// number of bytes read into the supplied buffer. +/// +/// Failure modes mirror [`Java_...dispatchStreaming`]: malformed +/// wire / unknown version / no app / Rust panic produce a normal +/// `error_wire(...)` response in the returned bytes and neither +/// stream is touched. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreaming< + 'local, +>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + header_bytes: JByteArray<'local>, + input_stream: JObject<'local>, + output_stream: JObject<'local>, +) -> jbyteArray { + let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + unowned_env + .with_env(|env| -> jni::errors::Result> { + let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || -> jni::errors::Result> { + if input_stream.is_null() || output_stream.is_null() { + return Ok(env + .byte_array_from_slice(&vespera_inprocess::error_wire( + 400, + "inputStream and outputStream must not be null", + ))? + .into()); + } + // Read the header byte[] through the shared ingress contract + // (length cap honoured + pending-exception scrub on failure) + // rather than a raw `convert_byte_array`, so an oversized header + // byte[] is rejected before a full Rust-side copy — parity with + // the buffered dispatch symbols. + let header_input = match read_request_byte_array(env, &header_bytes) { + Ok(buf) => buf, + Err(err) => return Ok(env.byte_array_from_slice(&err)?.into()), + }; + + // Promote the input/output refs (+ a second input ref for the + // post-response close, since `Global` is not `Clone`), grab the + // VM, and check out both per-thread chunk buffers. On ANY setup + // failure (rare, OOM-driven) the previous bare `?` surfaced to + // Java as a thrown exception + `null` return; return a `500` wire + // response instead so the decoder is never handed `null`. A + // half-acquired buffer pair cannot leak a lease (see + // `setup_full_stream` / `checkout_pull_push_buffers`). + let Ok((input_global, output_global, jvm, buffers)) = + setup_full_stream(env, &input_stream, &output_stream) + else { + clear_pending_exception(env); + return Ok(env + .byte_array_from_slice(&vespera_inprocess::error_wire( + 500, + "JNI streaming setup failed", + ))? + .into()); + }; + let PullPushBuffers { + pull_buf, + pull_buf_lease, + push_buf, + push_buf_lease, + } = buffers; + + // Closures capture clones of the JavaVM and Globals; + // both types are Send+Sync. + let pull_jvm = jvm.clone(); + let pull_global = Arc::clone(&input_global); + let close_jvm = jvm.clone(); + let input_for_close = input_global; + let push_jvm = jvm; + let push_global = output_global; + + let header_response = RUNTIME.block_on( + vespera_inprocess::dispatch_bidirectional_streaming_closing( + header_input, + // Pull request body chunks from Java InputStream. + // Runs on a tokio blocking thread (spawn_blocking + // inside dispatch_bidirectional_streaming). + make_pull_closure(pull_jvm, pull_global, pull_buf), + // Push response body chunks to Java OutputStream. + // Runs on the tokio worker driving the dispatch. + make_push_closure(push_jvm, push_global, push_buf), + // Close the InputStream once the response is fully + // streamed, so a producer parked in a blocking read is + // unblocked and the dispatch cannot hang on a stuck + // upload that never reaches EOF. + move || { + let _ = with_cached_daemon_env(&close_jvm, |env| { + close_input_stream(env, &input_for_close) + }); + }, + ), + ); + mark_streaming_buffer_reusable(pull_buf_lease); + mark_streaming_buffer_reusable(push_buf_lease); + + Ok(env.byte_array_from_slice(&header_response)?.into()) + }, + )) + .unwrap_or_else(|_| Ok(env.byte_array_from_slice(&panic_wire())?.into()))?; + + Ok(response) + }) + .resolve::() + .into_raw() + })); + guarded.unwrap_or_else(|_| { + unowned_env + .with_env(|env| -> jni::errors::Result> { + Ok(env.byte_array_from_slice(&panic_wire())?.into()) + }) + .resolve::() + .into_raw() + }) +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchStreamingWithHeader(byte[], Consumer, OutputStream) -> void` +/// +/// Same as [`Java_...dispatchStreaming`] but emits the wire-format +/// response header via `headerConsumer.accept(byte[])` **before** +/// the first body byte reaches `outputStream`. This lets +/// Spring-style `HttpServletResponse` controllers commit status +/// and headers while the response is still uncommitted. +/// +/// `headerConsumer` is invoked exactly once on every code path +/// (success or error); the bytes are a normal wire-format header +/// (length-prefixed JSON). On error `outputStream` is not +/// touched. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreamingWithHeader< + 'local, +>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + request_bytes: JByteArray<'local>, + header_consumer: JObject<'local>, + output_stream: JObject<'local>, +) { + let header_notified = Arc::new(AtomicBool::new(false)); + let header_notified_body = Arc::clone(&header_notified); + // JNI-03: whole-body panic guard (see `guard_void_symbol`). + let panicked = guard_void_symbol(|| { + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + if reject_null_header_consumer(env, &header_consumer, &header_notified_body) { + return Ok(()); + } + let Some(input) = + read_header_or_notify(env, &request_bytes, &header_consumer, &header_notified_body) + else { + return Ok(()); + }; + + // Promote refs + check out the chunk buffer. On ANY setup failure + // (global-ref / VM promotion or buffer alloc — all rare, OOM-driven) + // fire the header consumer once with a 500 via the still-valid LOCAL + // ref, so the "header consumer invoked exactly once on every code + // path" contract holds and the Java caller never hangs waiting for a + // header that will never arrive. The previous bare `?` here returned + // an ignored `Err` from `with_env`, exiting silently. + let Ok((header_global, stream_global, jvm, push_buf, push_buf_lease)) = + setup_stream_with_header(env, &header_consumer, &output_stream) + else { + notify_local_header(env, &header_consumer, &panic_wire(), &header_notified_body); + return Ok(()); + }; + + // Panic safety: catch_unwind absorbs Rust panics so the JVM + // never sees an unwinding stack across the FFI boundary. + // `header_sent` records whether the header callback fired; if a + // panic unwinds BEFORE it does (e.g. the axum handler panicked + // inside dispatch, before status/headers are produced), we fire + // the consumer once with a 500 header below so the documented + // "header consumer invoked exactly once on every code path" + // contract holds and the Java caller is not left hanging. A + // panic AFTER the header fired truncates the body past a header the + // host already committed; `panic_post_header_action` then throws + // IOException to abort the response (symmetric with the body-error / + // sink-stop abort on the `Ok` branch) instead of finishing cleanly + // over a short body. + let header_sent = Arc::new(AtomicBool::new(false)); + let header_failed = Arc::new(AtomicBool::new(false)); + let header_sent_cb = Arc::clone(&header_sent); + let header_failed_cb = Arc::clone(&header_failed); + let header_notified_cb = Arc::clone(&header_notified_body); + let header_failed_push = Arc::clone(&header_failed); + let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let header_for_cb = header_global; + let jvm_for_cb = jvm.clone(); + let mut push = make_push_closure(jvm, stream_global, push_buf); + RUNTIME.block_on(vespera_inprocess::dispatch_streaming_with_header_async( + input, + |header_bytes: &[u8]| { + let delivered = with_cached_daemon_env( + &jvm_for_cb, + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + call_header_consumer(env, &header_for_cb, header_bytes) + }, + ) + .is_ok(); + record_header_callback_result( + delivered, + &header_sent_cb, + &header_failed_cb, + &header_notified_cb, + ); + }, + move |chunk: &[u8]| { + push_unless_header_failed(&header_failed_push, &mut push, chunk) + }, + )) + })); + match panic_result { + Ok(outcome) => { + mark_streaming_buffer_reusable(push_buf_lease); + let failed_header = header_failed.load(Ordering::Acquire); + // The header was already committed via the consumer, so a + // failure that aborts the body mid-stream can no longer + // change the status. Surface it as a thrown IOException so + // the servlet container aborts the response instead of + // finishing cleanly over a truncated body — the host + // otherwise cannot tell a short stream from a complete one. + if failed_header + || matches!( + outcome, + vespera_inprocess::StreamOutcome::BodyError + | vespera_inprocess::StreamOutcome::SinkStopped + ) + { + throw_streaming_abort(env, failed_header); + } + } + Err(_) => { + handle_header_dispatch_panic( + env, + &header_consumer, + &header_sent, + &header_failed, + &header_notified_body, + ); + } + } + + Ok(()) + }); + }); + if panicked && !header_notified.load(Ordering::Acquire) && !header_consumer.is_null() { + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + notify_local_header(env, &header_consumer, &panic_wire(), &header_notified); + Ok(()) + }); + } +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream) -> void` +/// +/// Bidirectional streaming with the same header-callback contract +/// as [`Java_...dispatchStreamingWithHeader`]. Request body +/// pulled from `inputStream`, response header emitted via +/// `headerConsumer.accept(byte[])` once axum produces status + +/// headers, then response body chunks streamed to `outputStream`. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreamingWithHeader< + 'local, +>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + header_bytes_in: JByteArray<'local>, + header_consumer: JObject<'local>, + input_stream: JObject<'local>, + output_stream: JObject<'local>, +) { + let header_notified = Arc::new(AtomicBool::new(false)); + let header_notified_body = Arc::clone(&header_notified); + // JNI-03: whole-body panic guard (see `guard_void_symbol`). + let panicked = guard_void_symbol(|| { + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + dispatch_full_streaming_with_header_body( + env, + &FullHeaderArgs { + header_bytes: &header_bytes_in, + header_consumer: &header_consumer, + input_stream: &input_stream, + output_stream: &output_stream, + header_notified: &header_notified_body, + }, + ); + Ok(()) + }); + }); + if panicked && !header_notified.load(Ordering::Acquire) && !header_consumer.is_null() { + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + notify_local_header(env, &header_consumer, &panic_wire(), &header_notified); + Ok(()) + }); + } +} + +#[cfg(test)] +#[path = "jni_impl_runtime_config_tests.rs"] +mod runtime_config_tests; + +#[cfg(test)] +#[path = "jni_impl_streaming_abort_tests.rs"] +mod streaming_abort_tests; diff --git a/crates/vespera_jni/src/jni_impl_config.rs b/crates/vespera_jni/src/jni_impl_config.rs new file mode 100644 index 00000000..761d05dc --- /dev/null +++ b/crates/vespera_jni/src/jni_impl_config.rs @@ -0,0 +1,115 @@ +//! Runtime / streaming configuration JNI hooks. +//! +//! These symbols are seeded from `VesperaBridge.init()` **before the first +//! dispatch** and then fixed for the process lifetime. They are split out of +//! `jni_impl.rs` (which owns the per-request dispatch symbols) so each file +//! keeps a single concern — and stays within the 1000-line source cap. + +use jni::EnvUnowned; +use jni::objects::JClass; +use jni::sys::jint; + +const MIN_RUNTIME_WORKERS: usize = 1; +const MAX_RUNTIME_WORKERS: usize = 1024; + +static RUNTIME_WORKER_THREADS: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Worker thread count for the shared [`RUNTIME`](super::RUNTIME), resolved once +/// (first hit wins, then fixed for the process lifetime): +/// +/// 1. [`set_runtime_worker_threads`] called before the runtime is +/// first used (the `configureRuntime0` JNI hook from +/// `VesperaBridge.init()` lands here) +/// 2. `VESPERA_RUNTIME_WORKERS` environment variable +/// 3. `None` — Tokio's default (number of logical CPUs) +/// +/// Values are clamped to `[1, 1024]`. +#[must_use] +pub fn runtime_worker_threads() -> Option { + *RUNTIME_WORKER_THREADS.get_or_init(|| { + std::env::var("VESPERA_RUNTIME_WORKERS") + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .map(|v| v.clamp(MIN_RUNTIME_WORKERS, MAX_RUNTIME_WORKERS)) + }) +} + +/// Override the shared runtime's worker thread count **before the +/// first dispatch**. Returns `false` when the value was already +/// fixed. Clamped to `[1, 1024]`. +pub fn set_runtime_worker_threads(workers: usize) -> bool { + RUNTIME_WORKER_THREADS + .set(Some( + workers.clamp(MIN_RUNTIME_WORKERS, MAX_RUNTIME_WORKERS), + )) + .is_ok() +} + +/// `com.devfive.vespera.bridge.VesperaBridge.configureRuntime0(int) -> void` +/// +/// Seeds the shared Tokio runtime's worker thread count **before +/// the first dispatch**. Values `<= 0` leave the setting +/// untouched (env var / Tokio default applies). Calls after the +/// configuration is fixed are silently ignored. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_configureRuntime0<'local>( + _unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + worker_threads: jint, +) { + // Defensive `catch_unwind`: this body cannot panic today, but it is + // an `extern "system"` JNI symbol, so guard it for consistency with + // the dispatch symbols — an unwind must never cross the FFI boundary. + let _ = std::panic::catch_unwind(|| { + if let Ok(workers) = usize::try_from(worker_threads) + && workers > 0 + { + let _ = set_runtime_worker_threads(workers); + } + }); +} + +/// Per-chunk buffer size for streaming dispatches. +/// +/// Resolved once per process by +/// [`vespera_inprocess::streaming_chunk_bytes`] (default 256 KiB; +/// override via the `VESPERA_STREAMING_CHUNK_BYTES` env var or the +/// `configureStreaming0` JNI setter called from +/// `VesperaBridge.init()`). Large enough to amortise JNI call +/// overhead, small enough to keep memory bounded for multi-GB +/// streams. Subsequent calls are a single atomic load. +pub fn streaming_chunk_size() -> usize { + vespera_inprocess::streaming_chunk_bytes() +} + +/// `com.devfive.vespera.bridge.VesperaBridge.configureStreaming0(int, int) -> void` +/// +/// Seeds the process-wide streaming configuration **before the +/// first dispatch**. Values `<= 0` leave the corresponding +/// setting untouched (env var / default applies). Calls after +/// the configuration is fixed (first dispatch already ran, or a +/// previous call set it) are silently ignored — the JNI side has +/// no use for the failure signal beyond logging, which Java owns. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_configureStreaming0<'local>( + _unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + chunk_bytes: jint, + channel_capacity: jint, +) { + // Defensive `catch_unwind` — see `configureRuntime0`: keep every JNI + // `extern "system"` symbol panic-safe even though this body cannot + // panic with the current setters. + let _ = std::panic::catch_unwind(|| { + if let Ok(bytes) = usize::try_from(chunk_bytes) + && bytes > 0 + { + let _ = vespera_inprocess::set_streaming_chunk_bytes(bytes); + } + if let Ok(slots) = usize::try_from(channel_capacity) + && slots > 0 + { + let _ = vespera_inprocess::set_streaming_channel_capacity(slots); + } + }); +} diff --git a/crates/vespera_jni/src/jni_impl_direct.rs b/crates/vespera_jni/src/jni_impl_direct.rs new file mode 100644 index 00000000..b7ff1d29 --- /dev/null +++ b/crates/vespera_jni/src/jni_impl_direct.rs @@ -0,0 +1,304 @@ +//! Direct-buffer (zero JNI region copy) synchronous dispatch. +//! +//! The `dispatchDirect0` JNI symbol and its helpers, split out of +//! `jni_impl.rs` to keep that file within the project's 1000-line +//! source cap. Semantics are unchanged; `block_on_sync_runtime` is +//! reused from the parent module. + +use jni::EnvUnowned; +use jni::errors::ThrowRuntimeExAndDefault; +use jni::objects::{JByteBuffer, JClass}; +use jni::sys::jint; + +use super::{block_on_sync_runtime, panic_wire}; + +/// Sentinel for [`Java_..._dispatchDirect`]: the response (or its +/// required size) cannot be represented in the `jint` return value +/// (> `i32::MAX` bytes). +/// +/// `jint::MIN` is the only value the `-(required_size)` protocol can +/// never produce: `required_size <= i32::MAX`, so the most negative +/// legitimate return is `-(i32::MAX) == jint::MIN + 1`. +const DIRECT_UNREPRESENTABLE: jint = jint::MIN; + +// Compile-time proof that the sentinel cannot collide with any +// legitimate `-(required_size)` value. +const _: () = assert!(DIRECT_UNREPRESENTABLE < -i32::MAX); + +/// Copy `response` into the caller's direct out buffer. +/// +/// Returns: +/// * `>= 0` — bytes written (`response` fit entirely) +/// * `< 0` — `-(required_size)`: nothing written, caller must retry +/// with a buffer of at least `required_size` bytes +/// * [`DIRECT_UNREPRESENTABLE`] — response exceeds `i32::MAX` bytes +/// and cannot be expressed in the return-code protocol +/// +/// # Safety contract (upheld by the caller) +/// +/// `out_addr` must point to a writable region of at least `out_cap` +/// bytes that stays valid for the duration of this call (a JNI +/// direct buffer pinned by the live `JByteBuffer` local ref). +/// Whether `[a0, a0+a_len)` and `[b0, b0+b_len)` overlap (addresses as +/// `usize`). Used to reject aliasing `in_buf` / `out_buf` direct-buffer +/// ranges in [`Java_..._dispatchDirect0`] before creating a shared `&[u8]` +/// and an exclusive `&mut [u8]` over them (SEC-1). `saturating_add` +/// keeps the bound arithmetic panic-free for any address. +fn ranges_overlap(a0: usize, a_len: usize, b0: usize, b_len: usize) -> bool { + let a1 = a0.saturating_add(a_len); + let b1 = b0.saturating_add(b_len); + a0 < b1 && b0 < a1 +} + +/// Copy `response` into the caller's direct out buffer, returning the +/// `dispatchDirect0` code (`>= 0` bytes written, `-(required)` on overflow, +/// [`DIRECT_UNREPRESENTABLE`] when the size exceeds `i32::MAX`). +/// +/// # Safety +/// +/// `out_addr` must point to a writable region of at least `out_cap` bytes +/// that stays valid for the whole call (a JNI direct buffer pinned by a +/// live `JByteBuffer` local ref) and must NOT alias `response` (callers +/// pass a Rust-owned wire `Vec`). Encoded as `unsafe fn` so every call +/// site acknowledges the raw-pointer contract instead of it being an +/// unchecked promise on a safe function. +unsafe fn write_response_to_out(out_addr: *mut u8, out_cap: usize, response: &[u8]) -> jint { + if response.len() <= out_cap { + // SAFETY: `response.len() <= out_cap` and the caller's `# Safety` + // contract guarantees `out_addr..out_addr+out_cap` is writable and + // non-aliasing with `response` (a Rust-owned Vec → a Java direct + // buffer). + unsafe { + std::ptr::copy_nonoverlapping(response.as_ptr(), out_addr, response.len()); + } + // Java buffer capacities are jint-bounded, so len <= cap + // always fits i32. + jint::try_from(response.len()).unwrap_or(DIRECT_UNREPRESENTABLE) + } else { + jint::try_from(response.len()).map_or(DIRECT_UNREPRESENTABLE, |required| -required) + } +} + +/// `com.devfive.vespera.bridge.VesperaBridge.dispatchDirect0(ByteBuffer, int, ByteBuffer) -> int` +/// (private native; the public Java wrapper `dispatchDirect` validates +/// buffer directness and writability before crossing JNI) +/// +/// **Direct-buffer** synchronous dispatch — the zero-JNI-region-copy +/// sibling of [`Java_...dispatchBytes`]. +/// +/// Contract (mirrored in the Java wrapper's javadoc): +/// * `in_buf` / `out_buf` MUST be **direct, writable** `ByteBuffer`s. +/// The public Java wrapper is the authoritative guard: it rejects +/// non-direct and read-only buffers before crossing JNI. This private +/// native symbol deliberately does NOT call back into Java (for example, +/// `ByteBuffer.isReadOnly()`) because this ~2 µs direct path is selected +/// specifically to avoid per-request JNI calls beyond raw-address/capacity +/// resolution. Callers that bypass the Java wrapper violate this ABI +/// contract and may hand Rust a read-only page as `&mut [u8]`. +/// * The wire request is read from `in_buf[0..in_len]` — explicit +/// `in_len`, **never** the buffer's position/limit (eliminates +/// the classic "forgot to flip()" corruption). +/// * Return `>= 0`: a complete wire response was written to +/// `out_buf[0..n]`. +/// * Return `< 0`: `-(required_size)` — the response did not fit. +/// `out_buf` contents are **undefined** (a prefix may have been +/// written). `required_size` is exact, but retrying re-runs the +/// dispatch, so the Java side only auto-retries idempotent +/// methods. +/// * `Integer.MIN_VALUE`: response size exceeds `i32::MAX`. +/// +/// Compared with `dispatchBytes`, this path removes BOTH JNI +/// region copies (Java `byte[]` ↔ Rust), the per-call Java heap +/// array allocations, AND — via +/// [`vespera_inprocess::dispatch_into_async_borrowed`] — the +/// intermediate response `Vec` AND the request-side input copy: the +/// wire header is parsed **in place** from the borrowed `in_buf`, and +/// only a non-empty request body is copied into an owned `Bytes` +/// (axum's `Body` requires `'static` ownership), so a bodyless `GET` +/// copies nothing on the request side. On the success path the wire +/// header and each body frame are written straight into `out_buf`. +/// `422` responses are materialised internally to preserve +/// `validation_errors` hoisting. +/// +/// # Safety invariants (comment-locked) +/// +/// 1. `in_buf` / `out_buf` stay rooted as live local refs for the +/// whole call — HotSpot neither moves nor frees the backing +/// memory of a direct buffer while its object is reachable. +/// 2. The raw addresses derived from them are used **only within +/// this function body** — never captured by closures, spawned +/// tasks, or returned structs. +/// 3. The input is read through a **borrowed** slice for the duration +/// of the synchronous `block_on` (no `Vec` copy). Invariant 1 +/// keeps the backing memory valid throughout and the borrow never +/// escapes the `block_on`, so nothing borrowed from the buffer +/// outlives the call. +/// 4. `in_buf` and `out_buf` are proven **non-overlapping** (SEC-1) +/// before the shared `&[u8]` / exclusive `&mut [u8]` are created, so +/// they never alias the same memory. +/// 5. `out_buf` is **writable** and covers at least `out_cap` bytes. This is +/// an explicit ABI precondition of this private symbol, enforced by the +/// public Java wrapper's `isReadOnly()` checks (SEC-2). Re-checking here +/// would add a hot-path JNI call, so the native side documents and trusts +/// that wrapper contract for speed. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchDirect0<'local>( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + in_buf: JByteBuffer<'local>, + in_len: jint, + out_buf: JByteBuffer<'local>, +) -> jint { + let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + unowned_env + .with_env(|env| -> jni::errors::Result { + if in_buf.is_null() || out_buf.is_null() { + let _ = env.throw_new( + jni::jni_str!("java/lang/IllegalArgumentException"), + jni::jni_str!("in and out direct buffers must not be null"), + ); + return Ok(DIRECT_UNREPRESENTABLE); + } + let mut out_region: Option<(*mut u8, usize)> = None; + let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || -> jni::errors::Result { + // Resolve the OUTPUT buffer FIRST and record it, so any + // *later* failure (notably an invalid `in_buf`) can still + // write a decodable wire response into it instead of + // throwing — upholding the dispatch* family contract that + // every failure yields a wire response. An output-resolution + // failure (null ⇒ heap buffer, or JVM trouble) has no buffer + // to write into, so it still propagates via `?` → the + // RuntimeException the resolve below maps it to (defense in + // depth behind the Java-side isDirect()/isReadOnly() guard). + let out_addr = env.get_direct_buffer_address(&out_buf)?; + let out_cap = env.get_direct_buffer_capacity(&out_buf)?; + out_region = Some((out_addr, out_cap)); + debug_assert!( + !out_addr.is_null(), + "JNI direct output buffer address must be non-null" + ); + + // Now resolve the INPUT buffer. A failure here (null ⇒ heap + // buffer, non-direct, or JVM trouble) writes a `400` wire + // response into the already-resolved output buffer instead of + // throwing + returning the default `jint` — so a caller that + // bypasses the Java wrapper with a bad `in_buf` but a valid + // `out_buf` still receives a decodable wire error. + let in_resolved = match env.get_direct_buffer_address(&in_buf) { + Ok(addr) => env.get_direct_buffer_capacity(&in_buf).map(|cap| (addr, cap)), + Err(e) => Err(e), + }; + let Ok((in_addr, in_cap)) = in_resolved else { + // GetDirectBufferAddress returns NULL without raising a + // Java exception, but clear defensively so the wire + // response is delivered with no exception in flight. + if env.exception_check() { + env.exception_clear(); + } + let err = vespera_inprocess::error_wire( + 400, + "invalid in_buf (null, heap, or non-direct ByteBuffer)", + ); + // SAFETY: `out_addr`/`out_cap` came from the live direct + // output buffer above and `err` is a Rust-owned Vec. + return Ok(unsafe { write_response_to_out(out_addr, out_cap, &err) }); + }; + + // Validate in_len against the buffer's real capacity — + // all failures still produce a valid wire response in + // `out_buf`, per the dispatch* family contract. + let in_len = match usize::try_from(in_len) { + Ok(len) if len <= in_cap => len, + _ => { + let err = vespera_inprocess::error_wire( + 400, + "invalid in_len (negative or exceeds buffer capacity)", + ); + // SAFETY: `out_addr`/`out_cap` came from the live direct + // output buffer above and `err` is a Rust-owned Vec. + return Ok(unsafe { write_response_to_out(out_addr, out_cap, &err) }); + } + }; + + // SEC-1: reject overlapping `in_buf` / `out_buf` ranges. + // Below we create a shared `&[u8]` over the input and an + // exclusive `&mut [u8]` over the output; if they alias the + // same direct-buffer memory (the caller passed the same + // buffer, or overlapping `slice()`/`duplicate()` views) that + // is instant UB. The Java wrapper cannot detect this (it has + // no native address), so the check lives here. `out_buf` is + // writable by the wrapper's `isReadOnly()` guard (SEC-2), so + // writing the error response into it is sound. + if ranges_overlap(in_addr as usize, in_len, out_addr as usize, out_cap) { + let err = vespera_inprocess::error_wire( + 400, + "in_buf and out_buf must not overlap (aliasing would be undefined behavior)", + ); + // SAFETY: `out_addr`/`out_cap` came from the live direct + // output buffer above and `err` is a Rust-owned Vec. + return Ok(unsafe { write_response_to_out(out_addr, out_cap, &err) }); + } + + let dispatched = { + // SAFETY: invariants 1–3 above. `in_addr..in_addr+in_len` + // (`in_len <= in_cap`) is a readable region and + // `out_addr..out_addr+out_cap` a writable region, both of + // direct buffers pinned by their live `in_buf` / `out_buf` + // local refs; SEC-1 proved non-overlap and SEC-2 is the ABI + // contract that `out_buf` is writable (enforced by Java's + // public wrapper, not re-checked here to keep the direct hot + // path free of an extra JNI call). The Java caller is blocked + // for the whole call, so both buffers stay valid throughout. + // The borrowed `input` slice is read in place (no `Vec` copy) + // and never escapes this synchronous `block_on`. + let input = unsafe { std::slice::from_raw_parts(in_addr, in_len) }; + let out = unsafe { std::slice::from_raw_parts_mut(out_addr, out_cap) }; + block_on_sync_runtime(vespera_inprocess::dispatch_into_async_borrowed( + input, out, + )) + }; + + let code = match dispatched { + vespera_inprocess::DirectWriteResult::Complete(n) => { + // n <= out_cap, and Java buffer capacities are + // jint-bounded, so this always fits i32. + jint::try_from(n).unwrap_or(DIRECT_UNREPRESENTABLE) + } + vespera_inprocess::DirectWriteResult::Overflow(required) => { + jint::try_from(required).map_or(DIRECT_UNREPRESENTABLE, |r| -r) + } + }; + Ok(code) + }, + )); + + guarded.unwrap_or_else(|_| { + out_region.map_or_else( + || { + let _ = env.throw_new( + jni::jni_str!("java/lang/RuntimeException"), + jni::jni_str!( + "panic in Rust engine before direct output buffer resolution" + ), + ); + Ok(DIRECT_UNREPRESENTABLE) + }, + |(out_addr, out_cap)| { + let err = panic_wire(); + // SAFETY: `out_addr`/`out_cap` were resolved from the live + // direct output buffer before the panic, and `err` is a + // Rust-owned Vec that cannot alias that Java buffer. + Ok(unsafe { write_response_to_out(out_addr, out_cap, &err) }) + }, + ) + }) + }) + .resolve::() + })); + guarded.unwrap_or(DIRECT_UNREPRESENTABLE) +} + +#[cfg(test)] +#[path = "jni_impl_direct_tests.rs"] +mod direct_tests; diff --git a/crates/vespera_jni/src/jni_impl_direct_tests.rs b/crates/vespera_jni/src/jni_impl_direct_tests.rs new file mode 100644 index 00000000..999e8405 --- /dev/null +++ b/crates/vespera_jni/src/jni_impl_direct_tests.rs @@ -0,0 +1,42 @@ +use super::write_response_to_out; + +// SAFETY (all tests below): each `out` is a live, writable `Vec`; the +// `(out.as_mut_ptr(), out.len())` pair describes exactly its allocation, and +// the `response` literal is a distinct Rust-owned slice that cannot alias it — +// satisfying `write_response_to_out`'s `# Safety` contract. + +#[test] +fn response_fits_returns_len_and_writes_bytes() { + let mut out = vec![0u8; 16]; + let response = b"hello wire"; + let n = unsafe { write_response_to_out(out.as_mut_ptr(), out.len(), response) }; + assert_eq!(n, 10); + assert_eq!(&out[..10], response); +} + +#[test] +fn exact_fit_boundary() { + let mut out = vec![0u8; 4]; + let n = unsafe { write_response_to_out(out.as_mut_ptr(), out.len(), b"abcd") }; + assert_eq!(n, 4); + assert_eq!(&out[..], b"abcd"); +} + +#[test] +fn overflow_returns_negative_required_size_and_writes_nothing() { + let mut out = vec![0xAAu8; 4]; + let n = unsafe { write_response_to_out(out.as_mut_ptr(), out.len(), b"too large") }; + assert_eq!(n, -9); + assert_eq!( + &out[..], + &[0xAA; 4], + "overflow must not touch the out buffer" + ); +} + +#[test] +fn zero_capacity_overflow() { + let mut out: Vec = Vec::new(); + let n = unsafe { write_response_to_out(out.as_mut_ptr(), 0, b"x") }; + assert_eq!(n, -1); +} diff --git a/crates/vespera_jni/src/jni_impl_runtime_config_tests.rs b/crates/vespera_jni/src/jni_impl_runtime_config_tests.rs new file mode 100644 index 00000000..44b4c60f --- /dev/null +++ b/crates/vespera_jni/src/jni_impl_runtime_config_tests.rs @@ -0,0 +1,18 @@ +use super::config::{runtime_worker_threads, set_runtime_worker_threads}; + +/// One test owns the process-global `OnceLock`: setter wins, +/// clamping applies, and later writes are rejected. +#[test] +fn setter_fixes_clamped_value_first_wins() { + assert!(set_runtime_worker_threads(99_999), "first set must win"); + assert_eq!( + runtime_worker_threads(), + Some(1024), + "value must clamp to the upper bound" + ); + assert!( + !set_runtime_worker_threads(4), + "second set must be rejected once fixed" + ); + assert_eq!(runtime_worker_threads(), Some(1024)); +} diff --git a/crates/vespera_jni/src/jni_impl_streaming_abort_tests.rs b/crates/vespera_jni/src/jni_impl_streaming_abort_tests.rs new file mode 100644 index 00000000..2b3379ce --- /dev/null +++ b/crates/vespera_jni/src/jni_impl_streaming_abort_tests.rs @@ -0,0 +1,108 @@ +use std::ops::ControlFlow; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::support::{ + PanicHeaderAction, panic_post_header_action, push_unless_header_failed, + should_fire_fallback_header, +}; + +#[test] +fn push_gate_aborts_without_writing_when_header_delivery_failed() { + // Given: the JNI header callback already failed before the first body chunk. + let header_failed = AtomicBool::new(true); + let mut wrote = false; + + // When: the response body pump tries to deliver a chunk. + let outcome = push_unless_header_failed( + &header_failed, + &mut |_| { + wrote = true; + ControlFlow::Continue(()) + }, + b"body", + ); + + // Then: streaming aborts before any body byte reaches the sink. + assert!(outcome.is_break()); + assert!(!wrote); +} + +#[test] +fn push_gate_delegates_when_header_delivery_succeeded() { + // Given: the header callback succeeded and body streaming may proceed. + let header_failed = AtomicBool::new(false); + let mut delivered = Vec::new(); + + // When: the response body pump receives a chunk. + let outcome = push_unless_header_failed( + &header_failed, + &mut |chunk| { + delivered.extend_from_slice(chunk); + ControlFlow::Continue(()) + }, + b"body", + ); + + // Then: the underlying sink receives the bytes unchanged. + assert!(outcome.is_continue()); + assert_eq!(delivered, b"body"); + + header_failed.store(true, Ordering::SeqCst); + let stopped = + push_unless_header_failed(&header_failed, &mut |_| ControlFlow::Continue(()), b"x"); + assert!(stopped.is_break()); +} + +#[test] +fn fallback_header_fires_only_when_consumer_never_invoked() { + // Panic unwound BEFORE the header callback was ever reached: the Java caller + // has no header yet, so the one-shot 500 fallback MUST fire. + assert!(should_fire_fallback_header(false, false)); + + // Header callback already SUCCEEDED: re-firing would deliver the header + // twice — forbidden by the "invoked exactly once on every code path" contract. + assert!(!should_fire_fallback_header(true, false)); + + // Header callback already THREW (it WAS invoked): a later panic must not + // re-enter the (possibly broken / already-committed) consumer a second time. + // This is the edge the prior `!header_sent`-only guard mishandled by + // double-invoking the consumer. + assert!(!should_fire_fallback_header(false, true)); + + // Defensive: both flags set never co-occurs in practice, but must still not + // re-fire. + assert!(!should_fire_fallback_header(true, true)); +} + +#[test] +fn panic_post_header_action_aborts_once_header_is_committed() { + // Panic BEFORE the header was ever delivered: the Java caller has no header, + // so the one-shot 500 fallback must be delivered (never an abort, which + // would leave the caller with neither a header nor a result). + assert_eq!( + panic_post_header_action(false, false), + PanicHeaderAction::FireFallbackHeader + ); + + // Header already SUCCEEDED, then the dispatch future panicked mid-body: the + // body is truncated past a committed header, so the transport must be + // aborted — re-firing the consumer is forbidden (already invoked once). + assert_eq!( + panic_post_header_action(true, false), + PanicHeaderAction::ThrowAbort + ); + + // Header delivery THREW (consumer already invoked, response already broken): + // a later panic must abort rather than re-enter the consumer. + assert_eq!( + panic_post_header_action(false, true), + PanicHeaderAction::ThrowAbort + ); + + // Defensive: both flags set never co-occurs, but must still abort, never + // double-invoke the consumer. + assert_eq!( + panic_post_header_action(true, true), + PanicHeaderAction::ThrowAbort + ); +} diff --git a/crates/vespera_jni/src/jni_impl_streaming_buffer.rs b/crates/vespera_jni/src/jni_impl_streaming_buffer.rs new file mode 100644 index 00000000..7b2621bb --- /dev/null +++ b/crates/vespera_jni/src/jni_impl_streaming_buffer.rs @@ -0,0 +1,199 @@ +//! Per-thread reusable Java byte-array buffers for the streaming JNI +//! dispatch paths. +//! +//! Split out of `jni_impl.rs` to keep that file within the project's +//! 1000-line source cap. Semantics are unchanged: each streaming +//! direction (request pull / response push) keeps one cached +//! `Global` of the configured chunk size, leased for the +//! duration of a dispatch and marked reusable only after the streaming +//! future returns normally (a panic leaves the lease checked out so the +//! next dispatch allocates a fresh buffer instead of aliasing the Java +//! array that may still be in flight). + +use std::{cell::RefCell, sync::Arc}; + +use jni::objects::{Global, JByteArray}; + +use super::streaming_chunk_size; + +thread_local! { + static STREAMING_PULL_BUFFER: RefCell> = const { RefCell::new(None) }; + static STREAMING_PUSH_BUFFER: RefCell> = const { RefCell::new(None) }; +} + +pub type StreamingChunkBuffer = Arc>>; + +#[derive(Clone, Copy)] +pub enum StreamingBufferRole { + Pull, + Push, +} + +impl StreamingBufferRole { + fn with_cache( + self, + callback: impl FnOnce(&RefCell>) -> R, + ) -> R { + match self { + Self::Pull => STREAMING_PULL_BUFFER.with(callback), + Self::Push => STREAMING_PUSH_BUFFER.with(callback), + } + } +} + +struct CachedStreamingChunkBuffer { + size: usize, + array: StreamingChunkBuffer, + checked_out: bool, +} + +// Released after the streaming future returns normally via +// [`Self::mark_reusable`], which flips `checked_out` back to `false`. +// +// If a panic instead unwinds through a dispatch — while the request producer +// may STILL be parked in `InputStream.read` on a `spawn_blocking` thread — the +// lease is dropped WITHOUT `mark_reusable`, and its [`Drop`] DISCARDS the +// cached slot entirely. The prior behaviour left the slot `checked_out` +// forever, which permanently disabled pooling on that OS thread: every later +// stream on a panic-touched (pooled servlet) thread then allocated a throwaway +// Java array. Discarding instead lets pooling recover on the next dispatch. +// +// Discarding is safe against the still-running producer: the in-flight closure +// holds an `Arc` clone of the cached `Global`, so dropping the +// cache's `Arc` cannot delete the JVM global ref out from under the producer, +// and the next dispatch installs a brand-new buffer that can never alias the +// one still in flight. +pub struct StreamingChunkBufferLease { + role: StreamingBufferRole, + released: bool, +} + +impl StreamingChunkBufferLease { + const fn new(role: StreamingBufferRole) -> Self { + Self { + role, + released: false, + } + } + + /// Release the lease after a dispatch that returned normally: the cached + /// buffer is free for reuse by the next dispatch on this thread. + fn mark_reusable(mut self) { + self.role.with_cache(|cache| { + if let Some(cached) = cache.borrow_mut().as_mut() { + cached.checked_out = false; + } + }); + // Mark released so the `Drop` below is a no-op for this clean release. + self.released = true; + } +} + +impl Drop for StreamingChunkBufferLease { + fn drop(&mut self) { + if self.released { + return; + } + // Dropped WITHOUT `mark_reusable` — a panic unwound through the + // streaming dispatch. Discard the cached slot (see the type doc) so + // the next dispatch reinstalls a fresh pooled buffer instead of + // forever allocating throwaways; the in-flight closure's own global + // ref keeps the array alive, so clearing the cache reference here can + // never alias or free a buffer still in flight. + self.role.with_cache(|cache| { + *cache.borrow_mut() = None; + }); + } +} + +fn new_streaming_chunk_buffer( + env: &mut jni::Env<'_>, + size: usize, +) -> jni::errors::Result { + let local = env.new_byte_array(size)?; + Ok(Arc::new(env.new_global_ref(&local)?)) +} + +pub fn checkout_streaming_chunk_buffer( + env: &mut jni::Env<'_>, + role: StreamingBufferRole, +) -> jni::errors::Result<(StreamingChunkBuffer, Option)> { + let size = streaming_chunk_size(); + role.with_cache(|cache| { + let mut slot = cache.borrow_mut(); + // Three outcomes, decided by the cached slot's state: + match slot.as_mut() { + // Still checked out — a concurrent dispatch holds it, or a prior + // dispatch panicked mid-stream and never returned its lease. Hand + // back a throwaway, unpooled buffer and leave the cache untouched + // so we never alias a Java array that may still be in flight. + Some(cached) if cached.checked_out => { + return Ok((new_streaming_chunk_buffer(env, size)?, None)); + } + // Free to reuse — refresh the backing array only if the configured + // chunk size changed, then lease it back to the caller. + Some(cached) => { + if cached.size != size { + cached.array = new_streaming_chunk_buffer(env, size)?; + cached.size = size; + } + let dispatch_array = Arc::clone(&cached.array); + cached.checked_out = true; + return Ok((dispatch_array, Some(StreamingChunkBufferLease::new(role)))); + } + // Empty slot — fall through to install a fresh cached buffer. + None => {} + } + let array = new_streaming_chunk_buffer(env, size)?; + let dispatch_array = Arc::clone(&array); + *slot = Some(CachedStreamingChunkBuffer { + size, + array, + checked_out: true, + }); + Ok((dispatch_array, Some(StreamingChunkBufferLease::new(role)))) + }) +} + +pub fn mark_streaming_buffer_reusable(lease: Option) { + if let Some(lease) = lease { + lease.mark_reusable(); + } +} + +/// The pull + push per-thread chunk buffers (and their leases) acquired +/// together for one bidirectional streaming dispatch. +pub struct PullPushBuffers { + pub pull_buf: StreamingChunkBuffer, + pub pull_buf_lease: Option, + pub push_buf: StreamingChunkBuffer, + pub push_buf_lease: Option, +} + +/// Check out the pull + push chunk buffers for a bidirectional stream in +/// one step. Pull and push run concurrently on different threads, so each +/// direction gets its own per-thread cached buffer. +/// +/// If the push checkout fails after the pull buffer was already leased, the +/// pull lease is released before returning the error so a half-acquired pair +/// never leaks a leased buffer (which would force the next dispatch to +/// allocate a fresh array). Centralising this cleanup keeps the invariant in +/// one place instead of duplicating it across every bidirectional entry point. +pub fn checkout_pull_push_buffers(env: &mut jni::Env<'_>) -> jni::errors::Result { + let (pull_buf, pull_buf_lease) = + checkout_streaming_chunk_buffer(env, StreamingBufferRole::Pull)?; + let (push_buf, push_buf_lease) = + match checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push) { + Ok(checked_out) => checked_out, + Err(err) => { + mark_streaming_buffer_reusable(pull_buf_lease); + return Err(err); + } + }; + Ok(PullPushBuffers { + pull_buf, + pull_buf_lease, + push_buf, + push_buf_lease, + }) +} diff --git a/crates/vespera_jni/src/jni_impl_support.rs b/crates/vespera_jni/src/jni_impl_support.rs new file mode 100644 index 00000000..9f56128b --- /dev/null +++ b/crates/vespera_jni/src/jni_impl_support.rs @@ -0,0 +1,238 @@ +//! Helper functions + setup routines extracted from jni_impl.rs to keep that +//! file within the project's 1000-line source cap. Pure code move — no logic +//! change. All items are pub(super) (used only by the Java_... symbols in +//! [crate::jni_impl]). + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +use jni::objects::{Global, JObject}; + +use super::streaming_buffer::{ + PullPushBuffers, StreamingBufferRole, StreamingChunkBuffer, StreamingChunkBufferLease, + checkout_pull_push_buffers, checkout_streaming_chunk_buffer, +}; + +pub(super) fn throw_streaming_abort(env: &mut jni::Env<'_>, header_failed: bool) { + if header_failed { + let _ = env.throw_new( + jni::jni_str!("java/io/IOException"), + jni::jni_str!("vespera: response header callback failed before body streaming"), + ); + } else { + let _ = env.throw_new( + jni::jni_str!("java/io/IOException"), + jni::jni_str!("vespera: response body stream aborted after the header was committed"), + ); + } +} + +pub(super) fn push_unless_header_failed( + header_failed: &AtomicBool, + push: &mut impl FnMut(&[u8]) -> std::ops::ControlFlow<()>, + chunk: &[u8], +) -> std::ops::ControlFlow<()> { + if header_failed.load(Ordering::Acquire) { + std::ops::ControlFlow::Break(()) + } else { + push(chunk) + } +} + +/// Whether the panic-path fallback header (a one-shot `500`) should be delivered +/// after a Rust panic unwound out of a streaming-with-header dispatch. +/// +/// It fires ONLY when the header consumer was never invoked: `header_sent` +/// records a SUCCESSFUL invocation and `header_failed` records one that THREW — +/// either flag means "already invoked once", so a later panic must NOT re-enter +/// the consumer. Re-entry would break the documented "header consumer invoked +/// exactly once on every code path" contract and re-deliver to a consumer that +/// may already be in a failed / partially-committed state. Only a panic that +/// unwound BEFORE the callback was ever reached (both flags false) earns the +/// fallback, so the Java caller is never left without a header. +/// +/// (The prior inline guard tested `!header_sent` alone, which double-invoked the +/// consumer in the rare "callback threw, then the dispatch future panicked" +/// edge; this predicate closes that gap and is unit-tested in +/// `jni_impl_streaming_abort_tests.rs`.) +pub(super) fn should_fire_fallback_header(header_sent: bool, header_failed: bool) -> bool { + !header_sent && !header_failed +} + +/// What the panic landing-pad of a streaming-with-header dispatch must do after +/// a Rust panic unwound out of the dispatch future, given whether the response +/// header was already delivered. +/// +/// Mirror image of the SUCCESS branch's truncation handling: that branch throws +/// [`throw_streaming_abort`] when the body errors or the sink stops *after* the +/// header was committed (`failed_header || BodyError | SinkStopped`). A panic +/// after a committed header is the SAME failure shape — the body is truncated +/// past a header the host already wrote — so it must abort the transport too, +/// not return cleanly over a short body. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PanicHeaderAction { + /// The header consumer was never invoked (`!header_sent && !header_failed`): + /// deliver the one-shot `500` fallback header so the Java caller is never + /// left without a header. + FireFallbackHeader, + /// The header was already committed (or its delivery threw): a panic now + /// truncates the body past a committed header, so throw `IOException` to + /// abort the response — symmetric with the body-error / sink-stop abort on + /// the success branch. + ThrowAbort, +} + +/// Decide the panic-branch action from the two header flags. Splitting it out +/// (like [`should_fire_fallback_header`], which it reuses) keeps the decision +/// unit-testable without a live JVM — see `jni_impl_streaming_abort_tests.rs`. +pub(super) fn panic_post_header_action( + header_sent: bool, + header_failed: bool, +) -> PanicHeaderAction { + if should_fire_fallback_header(header_sent, header_failed) { + PanicHeaderAction::FireFallbackHeader + } else { + PanicHeaderAction::ThrowAbort + } +} + +/// Promoted refs + a checked-out chunk buffer for a response +/// streaming-with-header dispatch. Aliased so the helper return type stays +/// under clippy's `type_complexity` cap. +pub(super) type StreamHeaderSetup = ( + Global>, + Global>, + jni::JavaVM, + StreamingChunkBuffer, + Option, +); + +/// Promote the header-consumer + output-stream refs and check out the chunk +/// buffer for [`Java_..._dispatchStreamingWithHeader`]. Split out so the +/// dispatcher handles a (rare, OOM-driven) setup failure with a `let ... else` +/// that fires the header consumer exactly once, instead of a silently-ignored +/// `?` that would leave the Java caller hanging. +pub(super) fn setup_stream_with_header( + env: &mut jni::Env<'_>, + header_consumer: &JObject<'_>, + output_stream: &JObject<'_>, +) -> jni::errors::Result { + if header_consumer.is_null() { + return Err(jni::errors::Error::NullPtr("header_consumer")); + } + if output_stream.is_null() { + return Err(jni::errors::Error::NullPtr("output_stream")); + } + let header_global: Global> = env.new_global_ref(header_consumer)?; + let stream_global: Global> = env.new_global_ref(output_stream)?; + let jvm = env.get_java_vm()?; + // One per-thread reusable Java chunk buffer for the whole stream. + let (push_buf, push_buf_lease) = + checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push)?; + Ok((header_global, stream_global, jvm, push_buf, push_buf_lease)) +} + +/// Promoted refs + both chunk buffers for a bidirectional +/// streaming-with-header dispatch. Aliased to stay under `type_complexity`. +pub(super) type FullStreamHeaderSetup = ( + Global>, + Arc>>, + Global>, + jni::JavaVM, + PullPushBuffers, +); + +/// Promote the refs and check out both chunk buffers for +/// [`Java_..._dispatchFullStreamingWithHeader`]. Split out both to keep that +/// dispatcher under the line cap and so a setup failure is handled with a +/// `let ... else` that fires the header consumer exactly once. +pub(super) fn setup_full_stream_with_header( + env: &mut jni::Env<'_>, + header_consumer: &JObject<'_>, + input_stream: &JObject<'_>, + output_stream: &JObject<'_>, +) -> jni::errors::Result { + if header_consumer.is_null() { + return Err(jni::errors::Error::NullPtr("header_consumer")); + } + if input_stream.is_null() { + return Err(jni::errors::Error::NullPtr("input_stream")); + } + if output_stream.is_null() { + return Err(jni::errors::Error::NullPtr("output_stream")); + } + let header_global: Global> = env.new_global_ref(header_consumer)?; + let input_global: Arc>> = Arc::new(env.new_global_ref(input_stream)?); + let output_global: Global> = env.new_global_ref(output_stream)?; + let jvm = env.get_java_vm()?; + // Pull and push run concurrently on different threads (the pull lease is + // released for us if the push checkout fails). + let buffers = checkout_pull_push_buffers(env)?; + Ok((header_global, input_global, output_global, jvm, buffers)) +} + +/// Promoted output-stream ref + a checked-out push chunk buffer for a +/// response-streaming dispatch (no header consumer). Aliased to stay under +/// clippy's `type_complexity` cap. +pub(super) type StreamSetup = ( + Global>, + jni::JavaVM, + StreamingChunkBuffer, + Option, +); + +/// Promote the output-stream ref and check out the push chunk buffer for +/// [`Java_..._dispatchStreaming`]. Split out so the dispatcher can handle a +/// (rare, OOM-driven) setup failure with a `let ... else` that returns a `500` +/// wire response, instead of a silently-ignored `?` that surfaced to Java as a +/// thrown exception + `null` return — breaking the "every failure is a valid +/// wire response" contract the other dispatch symbols uphold. The buffer +/// checkout is last, so an earlier ref/VM failure never leaves a lease held. +pub(super) fn setup_stream( + env: &mut jni::Env<'_>, + output_stream: &JObject<'_>, +) -> jni::errors::Result { + if output_stream.is_null() { + return Err(jni::errors::Error::NullPtr("output_stream")); + } + let stream_global: Global> = env.new_global_ref(output_stream)?; + let jvm = env.get_java_vm()?; + let (push_buf, push_buf_lease) = + checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push)?; + Ok((stream_global, jvm, push_buf, push_buf_lease)) +} + +/// Promoted input/output refs and both chunk buffers for a bidirectional +/// streaming dispatch (no header consumer). The input ref is `Arc`-wrapped so +/// pull and post-response close share one JVM global ref. +pub(super) type FullStreamSetup = ( + Arc>>, + Global>, + jni::JavaVM, + PullPushBuffers, +); + +/// Promote the refs and check out both chunk buffers for +/// [`Java_..._dispatchFullStreaming`]. Split out so a setup failure returns a +/// `500` wire response instead of a silently-ignored `?` (see [`setup_stream`]). +/// `checkout_pull_push_buffers` releases the pull lease for us if the push +/// checkout fails, and no lease is held if an earlier ref/VM promotion fails. +pub(super) fn setup_full_stream( + env: &mut jni::Env<'_>, + input_stream: &JObject<'_>, + output_stream: &JObject<'_>, +) -> jni::errors::Result { + if input_stream.is_null() { + return Err(jni::errors::Error::NullPtr("input_stream")); + } + if output_stream.is_null() { + return Err(jni::errors::Error::NullPtr("output_stream")); + } + let input_global: Arc>> = Arc::new(env.new_global_ref(input_stream)?); + let output_global: Global> = env.new_global_ref(output_stream)?; + let jvm = env.get_java_vm()?; + let buffers = checkout_pull_push_buffers(env)?; + Ok((input_global, output_global, jvm, buffers)) +} diff --git a/crates/vespera_jni/src/lib.rs b/crates/vespera_jni/src/lib.rs index f5f95de2..6a47c29f 100644 --- a/crates/vespera_jni/src/lib.rs +++ b/crates/vespera_jni/src/lib.rs @@ -11,12 +11,23 @@ //! dispatch symbol is exported by this crate, matching the fixed Java //! class `com.devfive.vespera.bridge.VesperaBridge`. -#![allow(unsafe_code)] #![cfg(not(tarpaulin_include))] pub use jni; pub use vespera_inprocess; +/// mimalloc as the process-wide allocator (feature `mimalloc`). +/// +/// The JNI dispatch hot path allocates several times per call (input +/// buffer, request body, response collection, wire response); the OS +/// default allocator — Windows `HeapAlloc` in particular — is +/// measurably slower than mimalloc on this pattern. Opt-in because a +/// `#[global_allocator]` is process-wide and belongs to the final +/// cdylib's build decision. +#[cfg(feature = "mimalloc")] +#[global_allocator] +static GLOBAL_ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; + /// Generate the `JNI_OnLoad` export that registers a single (default) /// app. Backward-compatible sugar for the single-app case; new code /// targeting multiple apps should use [`jni_apps!`] directly. @@ -29,6 +40,10 @@ pub use vespera_inprocess; /// `JNI_OnLoad`. The resulting router is reachable from Java /// without an `X-Vespera-App` header (or with the header set to /// `"_default"`). +// SAFETY SCOPE: this macro intentionally emits `#[unsafe(no_mangle)]` for the +// single required `JNI_OnLoad` export; keep the unsafe allowance local so other +// crate-root code still trips the workspace unsafe lint. +#[allow(unsafe_code)] #[macro_export] macro_rules! jni_app { ($factory:expr) => { @@ -37,8 +52,17 @@ macro_rules! jni_app { _vm: $crate::jni::JavaVM, _: *mut ::std::ffi::c_void, ) -> $crate::jni::sys::jint { - $crate::vespera_inprocess::register_app($factory); - $crate::jni::sys::JNI_VERSION_1_8 + // The user factory runs here (router construction); a panic + // must never unwind across this `extern "system"` boundary + // into the JVM. Catch it and fail library load with + // `JNI_ERR` instead of aborting the host process. + let loaded = ::std::panic::catch_unwind(|| { + $crate::vespera_inprocess::register_app($factory); + }); + match loaded { + ::std::result::Result::Ok(()) => $crate::jni::sys::JNI_VERSION_1_8, + ::std::result::Result::Err(_) => $crate::jni::sys::JNI_ERR, + } } }; } @@ -76,6 +100,10 @@ macro_rules! jni_app { /// once — will produce a duplicate-symbol link error. /// /// [`register_app_named`]: vespera_inprocess::register_app_named +// SAFETY SCOPE: this macro intentionally emits `#[unsafe(no_mangle)]` for the +// single required `JNI_OnLoad` export; keep the unsafe allowance local so other +// crate-root code still trips the workspace unsafe lint. +#[allow(unsafe_code)] #[macro_export] macro_rules! jni_apps { ( $( $name:literal => $factory:expr ),+ $(,)? ) => { @@ -84,580 +112,37 @@ macro_rules! jni_apps { _vm: $crate::jni::JavaVM, _: *mut ::std::ffi::c_void, ) -> $crate::jni::sys::jint { - $( - $crate::vespera_inprocess::register_app_named($name, $factory); - )+ - $crate::jni::sys::JNI_VERSION_1_8 + // Each user factory runs here (router construction); a panic + // must never unwind across this `extern "system"` boundary + // into the JVM. Catch it and fail library load with + // `JNI_ERR` instead of aborting the host process. + let loaded = ::std::panic::catch_unwind(|| { + $( + $crate::vespera_inprocess::register_app_named($name, $factory); + )+ + }); + match loaded { + ::std::result::Result::Ok(()) => $crate::jni::sys::JNI_VERSION_1_8, + ::std::result::Result::Err(_) => $crate::jni::sys::JNI_ERR, + } } }; } // Everything below requires a JVM — excluded from coverage. #[cfg(not(tarpaulin_include))] -mod jni_impl { - use std::sync::LazyLock; - - use jni::EnvUnowned; - use jni::errors::ThrowRuntimeExAndDefault; - use jni::objects::{Global, JByteArray, JClass, JObject, JValue}; - use jni::sys::jbyteArray; - use jni::{jni_sig, jni_str}; - - /// Multi-threaded Tokio runtime shared across all JNI calls. - pub static RUNTIME: LazyLock = LazyLock::new(|| { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("failed to create Tokio runtime") - }); - - /// Per-chunk buffer size for streaming dispatches (16 KiB — large - /// enough to amortise JNI call overhead, small enough to keep - /// memory bounded for multi-GB streams). - const STREAMING_CHUNK_SIZE: usize = 16 * 1024; - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchBytes(byte[]) -> byte[]` - /// - /// **Synchronous** binary wire-format JNI entry point. Blocks the - /// calling thread until the Rust dispatch completes. Wraps the - /// entire pipeline in `catch_unwind` so a panic anywhere produces - /// a valid wire-format `500` response with a plain-text body — - /// JVM never sees an unwinding stack across the FFI boundary. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchBytes<'local>( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - request_bytes: JByteArray<'local>, - ) -> jbyteArray { - unowned_env - .with_env(|env| -> jni::errors::Result> { - let Ok(input) = env.convert_byte_array(&request_bytes) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid input byte array (JNI conversion failed)", - ); - return Ok(env.byte_array_from_slice(&err)?.into()); - }; - - let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - vespera_inprocess::dispatch_from_bytes(input, &RUNTIME) - })) - .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); - - Ok(env.byte_array_from_slice(&response)?.into()) - }) - .resolve::() - .into_raw() - } - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchAsync(CompletableFuture, byte[]) -> void` - /// - /// **Asynchronous** binary wire-format JNI entry point. Returns - /// immediately after spawning the dispatch on the shared Tokio - /// runtime. Completes the supplied `CompletableFuture` - /// from a runtime worker thread once the response is ready. - /// - /// Contract (always-complete): - /// - **success** → `future.complete(responseBytes)` - /// - **JNI conversion failure** → `future.complete(error_wire(400, ...))` - /// - **Rust panic / handler crash** → `future.complete(error_wire(500, "panic in Rust engine"))` - /// The future is always completed with a valid wire response — - /// it is never left dangling, even on internal errors. - /// - /// Cancellation: Java's `future.cancel(true)` does NOT abort the - /// in-flight Rust task in this iteration (defer to follow-up). - /// Java callers may still observe cancellation via `future.isCancelled()`. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchAsync<'local>( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - future_obj: JObject<'local>, - request_bytes: JByteArray<'local>, - ) { - // Best-effort: any error inside with_env aborts the dispatch - // (future will dangle on the Java side — only happens if we - // can't even promote the future to a GlobalRef, which would - // mean the JVM is already in trouble). - let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { - // 1. Promote CompletableFuture to Global so it survives - // across the tokio task boundary. - let future_global: Global> = env.new_global_ref(&future_obj)?; - - // 2. Try to convert the input byte array. On failure, - // complete the future synchronously with the error wire - // and return early — no async work needed. - let Ok(input) = env.convert_byte_array(&request_bytes) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid input byte array (JNI conversion failed)", - ); - let _ = complete_future(env, &future_global, &err); - return Ok(()); - }; - - // 3. Snapshot the JavaVM (Send + Sync) so we can re-attach - // the tokio worker thread once the dispatch completes. - let jvm = env.get_java_vm()?; - - // 4. Fire-and-forget on the runtime. An inner tokio::spawn - // converts any panic in dispatch_from_bytes_async into - // a JoinError, guaranteeing always-complete semantics. - RUNTIME.spawn(async move { - let response = tokio::spawn(vespera_inprocess::dispatch_from_bytes_async(input)) - .await - .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); - - // Re-attach to JVM on this worker thread; subsequent - // dispatches on the same thread will hit the TLS fast - // path (cheap). - let _ = jvm.attach_current_thread(|env| -> jni::errors::Result<()> { - complete_future(env, &future_global, &response) - }); - }); - - Ok(()) - }); - } - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchStreaming(byte[], OutputStream) -> byte[]` - /// - /// **Streaming** JNI entry point. Drives the dispatch - /// synchronously like [`Java_...dispatchBytes`], but emits the - /// response body chunk-by-chunk by calling `outputStream.write(byte[])` - /// for each chunk axum produces — no full-body materialisation on - /// either the Rust or JVM side. - /// - /// Returns the wire-format **header only** (`[u32 BE header_len | - /// header JSON]`) — the body is delivered through the - /// `OutputStream` argument while the dispatch is in flight. - /// Callers (e.g. Spring `StreamingResponseBody`) read the header - /// first to commit the HTTP status + response headers, then - /// continue serving the streamed body bytes. - /// - /// Failure modes mirror [`Java_...dispatchBytes`]: malformed wire, - /// version mismatch, no app registered, or Rust panic produce a - /// regular `error_wire(...)` response (header + small body) and - /// the `OutputStream` is **not** written to. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreaming< - 'local, - >( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - request_bytes: JByteArray<'local>, - output_stream: JObject<'local>, - ) -> jbyteArray { - unowned_env - .with_env(|env| -> jni::errors::Result> { - let Ok(input) = env.convert_byte_array(&request_bytes) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid input byte array (JNI conversion failed)", - ); - return Ok(env.byte_array_from_slice(&err)?.into()); - }; - - // Promote the OutputStream to Global so we can call - // .write() from a different attached thread inside - // the streaming callback. - let stream_global: Global> = env.new_global_ref(&output_stream)?; - let jvm = env.get_java_vm()?; - - let header_bytes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RUNTIME.block_on(vespera_inprocess::dispatch_streaming_async( - input, - |chunk: &[u8]| { - // Per-chunk: attach (cheap on subsequent - // calls — TLS fast path) + push a local - // frame to keep the local-ref table bounded - // even for streams with thousands of chunks. - let _ = jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.byte_array_from_slice(chunk)?; - let arr_obj: JObject = arr.into(); - env.call_method( - &stream_global, - jni_str!("write"), - jni_sig!("([B)V"), - &[JValue::Object(&arr_obj)], - )?; - // Any IOException thrown by write() is left - // pending on the env; clear it so subsequent - // chunks on the same thread aren't poisoned. - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - }) - }, - ); - }, - )) - })) - .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); - - Ok(env.byte_array_from_slice(&header_bytes)?.into()) - }) - .resolve::() - .into_raw() - } - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchFullStreaming(byte[], InputStream, OutputStream) -> byte[]` - /// - /// **Bidirectional streaming** JNI entry point. Reads the request - /// body chunk-by-chunk from `inputStream.read(byte[])` and emits - /// response body chunks via `outputStream.write(byte[])` — neither - /// side ever materialises the full body in memory, so 1 GiB - /// uploads with 1 GiB downloads run in O(chunk_size) RAM. - /// - /// Returns the wire-format **header only** (`[u32 BE header_len | - /// header JSON]`); the response body was delivered through - /// `outputStream`. - /// - /// Wire envelope contract: - /// - `headerBytes` is a wire-format request **without a body** - /// (just the 4-byte length prefix + JSON header). Send the - /// request body via `inputStream`, not embedded in this buffer. - /// - `inputStream.read(byte[])` semantics: returns `-1` on EOF, - /// `0` for an empty read (will be retried), or `>0` for the - /// number of bytes read into the supplied buffer. - /// - /// Failure modes mirror [`Java_...dispatchStreaming`]: malformed - /// wire / unknown version / no app / Rust panic produce a normal - /// `error_wire(...)` response in the returned bytes and neither - /// stream is touched. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreaming< - 'local, - >( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - header_bytes: JByteArray<'local>, - input_stream: JObject<'local>, - output_stream: JObject<'local>, - ) -> jbyteArray { - unowned_env - .with_env(|env| -> jni::errors::Result> { - let Ok(header_input) = env.convert_byte_array(&header_bytes) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid header byte array (JNI conversion failed)", - ); - return Ok(env.byte_array_from_slice(&err)?.into()); - }; - - let input_global: Global> = env.new_global_ref(&input_stream)?; - let output_global: Global> = env.new_global_ref(&output_stream)?; - let jvm = env.get_java_vm()?; - - // Closures capture clones of the JavaVM and Globals; - // both types are Send+Sync. - let pull_jvm = jvm.clone(); - let pull_global = input_global; - let push_jvm = jvm; - let push_global = output_global; - - let header_response = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RUNTIME.block_on(vespera_inprocess::dispatch_bidirectional_streaming( - header_input, - // Pull request body chunks from Java InputStream. - // Runs on a tokio blocking thread (spawn_blocking - // inside dispatch_bidirectional_streaming). - move || -> Option> { - let result: jni::errors::Result>> = pull_jvm - .attach_current_thread(|env| { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.new_byte_array(STREAMING_CHUNK_SIZE)?; - let n = env - .call_method( - &pull_global, - jni_str!("read"), - jni_sig!("([B)I"), - &[JValue::Object(arr.as_ref())], - )? - .i()?; - if env.exception_check() { - env.exception_clear(); - } - if n <= 0 { - return Ok(None); - } - let mut data = env.convert_byte_array(&arr)?; - data.truncate(usize::try_from(n).unwrap_or(0)); - Ok(Some(data)) - }) - }); - result.ok().flatten() - }, - // Push response body chunks to Java OutputStream. - // Runs on the tokio worker driving the dispatch. - |chunk: &[u8]| { - let _ = push_jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.byte_array_from_slice(chunk)?; - let arr_obj: JObject = arr.into(); - env.call_method( - &push_global, - jni_str!("write"), - jni_sig!("([B)V"), - &[JValue::Object(&arr_obj)], - )?; - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - }) - }, - ); - }, - )) - })) - .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); - - Ok(env.byte_array_from_slice(&header_response)?.into()) - }) - .resolve::() - .into_raw() - } - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchStreamingWithHeader(byte[], Consumer, OutputStream) -> void` - /// - /// Same as [`Java_...dispatchStreaming`] but emits the wire-format - /// response header via `headerConsumer.accept(byte[])` **before** - /// the first body byte reaches `outputStream`. This lets - /// Spring-style `HttpServletResponse` controllers commit status - /// and headers while the response is still uncommitted. - /// - /// `headerConsumer` is invoked exactly once on every code path - /// (success or error); the bytes are a normal wire-format header - /// (length-prefixed JSON). On error `outputStream` is not - /// touched. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStreamingWithHeader< - 'local, - >( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - request_bytes: JByteArray<'local>, - header_consumer: JObject<'local>, - output_stream: JObject<'local>, - ) { - let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { - let Ok(input) = env.convert_byte_array(&request_bytes) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid input byte array (JNI conversion failed)", - ); - let _ = call_header_consumer(env, &env.new_global_ref(&header_consumer)?, &err); - return Ok(()); - }; - - let header_global: Global> = env.new_global_ref(&header_consumer)?; - let stream_global: Global> = env.new_global_ref(&output_stream)?; - let jvm = env.get_java_vm()?; - - // Panic safety: catch_unwind absorbs Rust panics so the - // JVM never sees an unwinding stack across the FFI - // boundary. If the panic happens AFTER the header - // callback fires (the common case — most panics are in - // axum handlers), Spring's response is already partially - // committed; we have no way to recover that. If the - // panic happens BEFORE the header callback fires (very - // rare — e.g. wire parse), the Java side will see a - // dangling controller; document that follow-up callers - // should set a timeout. - let _panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let header_for_cb = header_global; - let stream_for_cb = stream_global; - let jvm_for_cb = jvm; - RUNTIME.block_on(vespera_inprocess::dispatch_streaming_with_header_async( - input, - |header_bytes: &[u8]| { - let _ = jvm_for_cb.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - call_header_consumer(env, &header_for_cb, header_bytes) - }, - ); - }, - |chunk: &[u8]| { - let _ = jvm_for_cb.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - write_chunk_to_stream(env, &stream_for_cb, chunk) - }) - }, - ); - }, - )); - })); - - Ok(()) - }); - } - - /// `com.devfive.vespera.bridge.VesperaBridge.dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream) -> void` - /// - /// Bidirectional streaming with the same header-callback contract - /// as [`Java_...dispatchStreamingWithHeader`]. Request body - /// pulled from `inputStream`, response header emitted via - /// `headerConsumer.accept(byte[])` once axum produces status + - /// headers, then response body chunks streamed to `outputStream`. - #[unsafe(no_mangle)] - pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFullStreamingWithHeader< - 'local, - >( - mut unowned_env: EnvUnowned<'local>, - _class: JClass<'local>, - header_bytes_in: JByteArray<'local>, - header_consumer: JObject<'local>, - input_stream: JObject<'local>, - output_stream: JObject<'local>, - ) { - let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { - let Ok(header_input) = env.convert_byte_array(&header_bytes_in) else { - let err = vespera_inprocess::error_wire( - 400, - "invalid header byte array (JNI conversion failed)", - ); - let _ = call_header_consumer(env, &env.new_global_ref(&header_consumer)?, &err); - return Ok(()); - }; - - let header_global: Global> = env.new_global_ref(&header_consumer)?; - let input_global: Global> = env.new_global_ref(&input_stream)?; - let output_global: Global> = env.new_global_ref(&output_stream)?; - let jvm = env.get_java_vm()?; - - let pull_jvm = jvm.clone(); - let pull_global = input_global; - let push_jvm = jvm.clone(); - let push_global = output_global; - let header_jvm = jvm; - let header_for_cb = header_global; - - // See dispatchStreamingWithHeader: panic absorbed silently, - // recovery semantics depend on which side of the header - // callback the panic landed. - let _panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RUNTIME.block_on( - vespera_inprocess::dispatch_bidirectional_streaming_with_header( - header_input, - move || -> Option> { - let result: jni::errors::Result>> = pull_jvm - .attach_current_thread(|env| { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.new_byte_array(STREAMING_CHUNK_SIZE)?; - let n = env - .call_method( - &pull_global, - jni_str!("read"), - jni_sig!("([B)I"), - &[JValue::Object(arr.as_ref())], - )? - .i()?; - if env.exception_check() { - env.exception_clear(); - } - if n <= 0 { - return Ok(None); - } - let mut data = env.convert_byte_array(&arr)?; - data.truncate(usize::try_from(n).unwrap_or(0)); - Ok(Some(data)) - }) - }); - result.ok().flatten() - }, - |chunk: &[u8]| { - let _ = push_jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - write_chunk_to_stream(env, &push_global, chunk) - }) - }, - ); - }, - |header_bytes: &[u8]| { - let _ = header_jvm.attach_current_thread( - |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { - call_header_consumer(env, &header_for_cb, header_bytes) - }, - ); - }, - ), - ); - })); - - Ok(()) - }); - } - - fn call_header_consumer( - env: &mut jni::Env<'_>, - consumer: &Global>, - header_bytes: &[u8], - ) -> jni::errors::Result<()> { - env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { - let arr = env.byte_array_from_slice(header_bytes)?; - let arr_obj: JObject = arr.into(); - env.call_method( - consumer, - jni_str!("accept"), - jni_sig!("(Ljava/lang/Object;)V"), - &[JValue::Object(&arr_obj)], - )?; - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - }) - } - - fn write_chunk_to_stream( - env: &mut jni::Env<'_>, - stream: &Global>, - chunk: &[u8], - ) -> jni::errors::Result<()> { - let arr = env.byte_array_from_slice(chunk)?; - let arr_obj: JObject = arr.into(); - env.call_method( - stream, - jni_str!("write"), - jni_sig!("([B)V"), - &[JValue::Object(&arr_obj)], - )?; - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - } - - /// Call `CompletableFuture.complete(byte[])` and clear any pending - /// JNI exception so the worker thread is left clean for subsequent - /// dispatches. - fn complete_future( - env: &mut jni::Env<'_>, - future: &Global>, - bytes: &[u8], - ) -> jni::errors::Result<()> { - let arr = env.byte_array_from_slice(bytes)?; - let arr_obj: JObject = arr.into(); - env.call_method( - future, - jni_str!("complete"), - jni_sig!("(Ljava/lang/Object;)Z"), - &[JValue::Object(&arr_obj)], - )?; - // Always clear any leftover exception (e.g. if Java's - // complete() threw via a buggy whenComplete handler): we MUST - // NOT leave the attached thread in a faulted state because - // subsequent JNI calls will misbehave silently. - if env.exception_check() { - env.exception_clear(); - } - Ok(()) - } -} +// SAFETY SCOPE: daemon attach/detach uses raw JNI invocation table calls. +#[allow(unsafe_code)] +mod daemon_env; +#[cfg(not(tarpaulin_include))] +// SAFETY SCOPE: byte-array transfers write directly into uninitialized Vec capacity. +#[allow(unsafe_code)] +mod jni_buf; +#[cfg(not(tarpaulin_include))] +// SAFETY SCOPE: JNI exports and direct-buffer submodule contain FFI entry points. +#[allow(unsafe_code)] +mod jni_impl; +#[cfg(not(tarpaulin_include))] +// SAFETY SCOPE: streaming callbacks use cached JMethodID calls and signed-byte views. +#[allow(unsafe_code)] +mod streaming_closures; diff --git a/crates/vespera_jni/src/streaming_closures.rs b/crates/vespera_jni/src/streaming_closures.rs new file mode 100644 index 00000000..1c0f97b6 --- /dev/null +++ b/crates/vespera_jni/src/streaming_closures.rs @@ -0,0 +1,680 @@ +//! Streaming closure factories and Java-side callback helpers. +//! +//! These helpers are shared by every `dispatch*Streaming*` JNI +//! entry symbol in [`crate::jni_impl`]. They are split out into +//! a sibling module so: +//! +//! * `jni_impl.rs` stays inside the repo's 1000-line file cap +//! while keeping every `Java_..._dispatch*` symbol together. +//! * The `JMethodID` cache for the per-chunk `InputStream.read` / +//! `OutputStream.write` calls and the repeated callback helpers +//! (`Consumer.accept` / `CompletableFuture.complete`) stays beside +//! the only call sites that rely on it. +//! +//! All items are `pub(crate)` — never re-exported from the crate +//! root — so the JNI ABI surface (the `Java_...` symbols) lives +//! exclusively in [`crate::jni_impl`]. + +use std::ops::ControlFlow; +use std::sync::{Arc, OnceLock}; + +use jni::ids::JMethodID; +use jni::objects::{JClass, JObject}; +use jni::refs::Global; +use jni::signature::{MethodSignature, Primitive, ReturnType}; +use jni::strings::JNIStr; +use jni::sys::{jint, jvalue}; +use jni::{JValue, JValueOwned, jni_sig, jni_str}; + +use crate::daemon_env::with_cached_daemon_env_no_frame; +use crate::jni_impl::streaming_chunk_size; + +struct CachedMethod { + _class: Global>, + method_id: JMethodID, +} + +impl CachedMethod { + fn resolve<'sig, 'sig_args, C, N, S>( + env: &mut jni::Env<'_>, + class_name: C, + method_name: N, + method_sig: S, + ) -> jni::errors::Result + where + C: AsRef, + N: AsRef, + S: AsRef>, + { + let class = env.find_class(class_name)?; + let method_id = env.get_method_id(&class, method_name, method_sig)?; + let class = env.new_global_ref(&class)?; + Ok(Self { + _class: class, + method_id, + }) + } + + fn method_id(&self) -> JMethodID { + // `_class` pins the Java class for as long as this method ID is cached: + // JNI method IDs can be invalidated if their class unloads. + self.method_id + } +} + +struct MethodCache { + input_stream_read: CachedMethod, + output_stream_write: CachedMethod, + consumer_accept: CachedMethod, + future_complete: CachedMethod, +} + +impl MethodCache { + fn resolve(env: &mut jni::Env<'_>) -> jni::errors::Result { + env.with_local_frame::<_, _, jni::errors::Error>(16, |env| { + Ok(Self { + input_stream_read: CachedMethod::resolve( + env, + jni_str!("java/io/InputStream"), + jni_str!("read"), + jni_sig!("([B)I"), + )?, + output_stream_write: CachedMethod::resolve( + env, + jni_str!("java/io/OutputStream"), + jni_str!("write"), + jni_sig!("([BII)V"), + )?, + consumer_accept: CachedMethod::resolve( + env, + jni_str!("java/util/function/Consumer"), + jni_str!("accept"), + jni_sig!("(Ljava/lang/Object;)V"), + )?, + future_complete: CachedMethod::resolve( + env, + jni_str!("java/util/concurrent/CompletableFuture"), + jni_str!("complete"), + jni_sig!("(Ljava/lang/Object;)Z"), + )?, + }) + }) + } +} + +/// Process-global cache of the four `java.*` callback method IDs. +/// +/// **Single-JVM-per-process invariant (deliberate).** The cached `JMethodID`s +/// and their pinning `Global` are JVM-local, and this `OnceLock` is +/// keyed only by the process — NOT by `JavaVM`. This is sound because: +/// +/// * HotSpot supports exactly one JVM per OS process — `JNI_CreateJavaVM` +/// fails on a second call — so a second `JavaVM` whose IDs could differ +/// cannot exist alongside the cached one. +/// * Every cached class (`InputStream`, `OutputStream`, `Consumer`, +/// `CompletableFuture`) is a bootstrap `java.*` class that never unloads, +/// so the cached IDs stay valid for the process lifetime. +/// * [`crate::daemon_env`] separately stores and compares the raw `JavaVM` +/// pointer on every cached-env reuse, so a thread attached to a *different* +/// VM cannot even obtain a live `Env` to reach this cache. +/// +/// A per-call `JavaVM` check is intentionally NOT added: it would require a +/// `GetJavaVM` JNI call on every streaming chunk — the exact per-chunk JNI +/// cost this cache exists to eliminate — to guard against a multi-JVM +/// configuration the platform already forbids. Trading hot-path throughput +/// for that guard would be a net regression. +enum MethodCacheState { + Ready(MethodCache), + Failed, +} + +static METHOD_CACHE: OnceLock = OnceLock::new(); + +const ZERO_READ_YIELD_THRESHOLD: u32 = 16; + +fn should_yield_after_zero_read(consecutive_empty_reads: u32) -> bool { + consecutive_empty_reads >= ZERO_READ_YIELD_THRESHOLD +} + +fn method_cache(env: &mut jni::Env<'_>) -> Option<&'static MethodCache> { + if let Some(state) = METHOD_CACHE.get() { + return match state { + MethodCacheState::Ready(cache) => Some(cache), + MethodCacheState::Failed => None, + }; + } + + let Ok(cache) = MethodCache::resolve(env) else { + // Cache init is best-effort. If class lookup, method lookup, + // or global-ref promotion fails, clear only that init-time + // exception and run the exact old string-based call path below. + if env.exception_check() { + env.exception_clear(); + } + let _ = METHOD_CACHE.set(MethodCacheState::Failed); + return None; + }; + + let _ = METHOD_CACHE.set(MethodCacheState::Ready(cache)); + match METHOD_CACHE.get() { + Some(MethodCacheState::Ready(cache)) => Some(cache), + Some(MethodCacheState::Failed) | None => None, + } +} + +fn can_call_unchecked(obj: &Global>) -> bool { + !obj.as_ref().as_raw().is_null() +} + +fn call_cached_method<'local>( + env: &mut jni::Env<'local>, + obj: &Global>, + method: &CachedMethod, + ret_ty: ReturnType, + args: &[jvalue], +) -> jni::errors::Result> { + // SAFETY: every `CachedMethod` is resolved by the JVM from a + // bootstrap `java.*` class using the exact name/signature strings + // previously passed to `Env::call_method`, and its `Global` + // pins that class for the process lifetime. Each caller builds raw + // `jvalue` arguments from the same `JValue` list as the former + // checked call and passes the matching `ReturnType`; null receivers + // are routed to the checked fallback before reaching this helper. + unsafe { env.call_method_unchecked(obj, method.method_id(), ret_ty, args) } +} + +fn call_input_stream_read( + env: &mut jni::Env<'_>, + stream: &Global>, + buf: &Global>, +) -> jni::errors::Result { + if can_call_unchecked(stream) + && let Some(cache) = method_cache(env) + { + let args: [jvalue; 1] = [JValue::Object(buf.as_ref()).as_jni()]; + return call_cached_method( + env, + stream, + &cache.input_stream_read, + ReturnType::Primitive(Primitive::Int), + &args, + )? + .i(); + } + + env.call_method( + stream, + jni_str!("read"), + jni_sig!("([B)I"), + &[JValue::Object(buf.as_ref())], + )? + .i() +} + +fn call_output_stream_write( + env: &mut jni::Env<'_>, + stream: &Global>, + buf: &Global>, + len: jint, +) -> jni::errors::Result<()> { + if can_call_unchecked(stream) + && let Some(cache) = method_cache(env) + { + let args: [jvalue; 3] = [ + JValue::Object(buf.as_ref()).as_jni(), + JValue::Int(0).as_jni(), + JValue::Int(len).as_jni(), + ]; + call_cached_method( + env, + stream, + &cache.output_stream_write, + ReturnType::Primitive(Primitive::Void), + &args, + )?; + return Ok(()); + } + + env.call_method( + stream, + jni_str!("write"), + jni_sig!("([BII)V"), + &[ + JValue::Object(buf.as_ref()), + JValue::Int(0), + JValue::Int(len), + ], + )?; + Ok(()) +} + +fn call_consumer_accept( + env: &mut jni::Env<'_>, + consumer: &Global>, + arg: &JObject<'_>, +) -> jni::errors::Result<()> { + if can_call_unchecked(consumer) + && let Some(cache) = method_cache(env) + { + let args: [jvalue; 1] = [JValue::Object(arg).as_jni()]; + call_cached_method( + env, + consumer, + &cache.consumer_accept, + ReturnType::Primitive(Primitive::Void), + &args, + )?; + return Ok(()); + } + + env.call_method( + consumer, + jni_str!("accept"), + jni_sig!("(Ljava/lang/Object;)V"), + &[JValue::Object(arg)], + )?; + Ok(()) +} + +fn call_future_complete( + env: &mut jni::Env<'_>, + future: &Global>, + arg: &JObject<'_>, +) -> jni::errors::Result<()> { + if can_call_unchecked(future) + && let Some(cache) = method_cache(env) + { + let args: [jvalue; 1] = [JValue::Object(arg).as_jni()]; + call_cached_method( + env, + future, + &cache.future_complete, + ReturnType::Primitive(Primitive::Boolean), + &args, + )?; + return Ok(()); + } + + env.call_method( + future, + jni_str!("complete"), + jni_sig!("(Ljava/lang/Object;)Z"), + &[JValue::Object(arg)], + )?; + Ok(()) +} + +/// Build the request-body pull closure shared by the two +/// full-streaming JNI entry points. +/// +/// The Java-side chunk buffer (`buf`) is allocated **once** by the +/// caller and promoted to a global ref — reused across every +/// chunk instead of `new_byte_array` per chunk. Bytes are copied +/// out via `get_byte_array_region`, which copies **only the `n` +/// bytes actually read** (the previous `convert_byte_array` +/// approach copied the full 16 KiB buffer regardless and then +/// truncated). +pub fn make_pull_closure( + jvm: jni::JavaVM, + stream: Arc>>, + buf: Arc>>, +) -> impl FnMut() -> vespera_inprocess::RequestChunk + Send + 'static { + use vespera_inprocess::RequestChunk; + let chunk_size = streaming_chunk_size(); + let mut consecutive_empty_reads = 0_u32; + move || -> RequestChunk { + // Daemon-attach this (Tokio `spawn_blocking`) thread once, + // cached in TLS, instead of attach+detach per chunk. No local + // frame: the body below creates no JNI local refs (cached + // unchecked `read` call + raw `get_region` into a Rust Vec), so + // the per-chunk frame would be pure overhead. + let result: jni::errors::Result = + with_cached_daemon_env_no_frame(&jvm, |env| { + let n = call_input_stream_read(env, &stream, &buf)?; + // The cached fast path calls `read()` via `call_method_unchecked`, + // which does NOT surface a thrown exception as `Err` — it returns a + // garbage `n` with the exception left pending. A thrown `read()` + // must ABORT the request body so a truncated upload is rejected, + // and must never be misread as EOF (`n < 0`) or a chunk. (The + // checked fallback in `call_input_stream_read` already aborts via + // `?`; acting on the pending exception here gives the unchecked + // path identical semantics instead of interpreting the garbage `n`.) + if env.exception_check() { + env.exception_clear(); + return Ok(RequestChunk::Error); + } + // InputStream.read(byte[]) contract (mirrored in the + // VesperaBridge javadoc): -1 = EOF, 0 = empty read that + // MUST be retried. The inprocess producer skips empty + // chunks and keeps pulling, so report `0` as an empty + // chunk rather than end-of-stream. + if n < 0 { + consecutive_empty_reads = 0; + return Ok(RequestChunk::End); + } + if n == 0 { + consecutive_empty_reads = consecutive_empty_reads.saturating_add(1); + if should_yield_after_zero_read(consecutive_empty_reads) { + std::thread::yield_now(); + } + return Ok(RequestChunk::Data(Vec::new())); + } + consecutive_empty_reads = 0; + // `n > 0` here (the `< 0` and `== 0` cases returned above), so a + // positive `jint` always fits `usize`. Avoid a panic site on + // this FFI hot path: an impossible conversion failure aborts the + // request body (`RequestChunk::Error`) instead of unwinding + // across the JNI boundary. + let Ok(n) = usize::try_from(n) else { + return Ok(RequestChunk::Error); + }; + // `InputStream.read(byte[])` MUST return at most the buffer + // length; a larger value is a contract violation (a buggy or + // hostile stream). Treat it as stream corruption and ABORT + // the request body instead of silently clamping it to a + // "valid" read — clamping would feed a truncated / mis-sized + // chunk downstream and accept a corrupted upload as complete. + if n > chunk_size { + return Ok(RequestChunk::Error); + } + // Copy the n bytes just read into the Java buffer straight into + // uninitialised capacity — no zero-fill to immediately overwrite. + let arr: &jni::objects::JByteArray<'_> = buf.as_ref().as_ref(); + let data = crate::jni_buf::read_byte_array_region(env, arr, n)?; + Ok(RequestChunk::Data(data)) + }); + // A JNI failure here — most importantly a `InputStream.read` + // that threw (jni-rs surfaces a pending Java exception as + // `Err`) — aborts the request body via `RequestChunk::Error` + // instead of being silently mistaken for a clean EOF, so a + // truncated upload is rejected rather than accepted as complete. + result.unwrap_or(RequestChunk::Error) + } +} + +/// Build the response-body push closure shared by all four +/// streaming JNI entry points. +/// +/// The Java-side buffer (`buf`, [`streaming_chunk_size`] bytes) is +/// allocated **once** by the caller and reused for every chunk via +/// `JByteArray::set_region` + `OutputStream.write(byte[], int, int)` +/// — the previous implementation allocated a fresh exact-size Java +/// array per chunk (`byte_array_from_slice`). Axum body frames are +/// unbounded in size, so frames larger than the buffer are written +/// in buffer-sized segments. +/// +/// NOTE: when request pull and response push run concurrently +/// (bidirectional streaming), each side MUST own a **separate** +/// buffer — they execute on different threads. +pub fn make_push_closure( + jvm: jni::JavaVM, + stream: Global>, + buf: Arc>>, +) -> impl FnMut(&[u8]) -> ControlFlow<()> + Send + 'static { + let chunk_size = streaming_chunk_size(); + // `chunk_size` is config-clamped to <= 8 MiB (see config::MAX_STREAMING_CHUNK_BYTES), + // so every segment length (<= chunk_size) fits an `i32`. Precompute the + // saturating bound once so the per-segment length conversion below needs no + // panic site; the `unwrap_or` fallback is the buffer size (never exceeds it), + // so it stays write-safe even if the clamp invariant were ever broken. + let chunk_size_i32 = i32::try_from(chunk_size).unwrap_or(i32::MAX); + // Latches once the Java OutputStream errors (e.g. the client + // disconnected mid-download): subsequent frames become a cheap + // no-op instead of repeatedly crossing JNI to write into a broken + // sink and clearing the resulting exception every time. + let mut failed = false; + move |chunk: &[u8]| { + if failed { + return ControlFlow::Break(()); + } + // Daemon-attach this thread once, cached in TLS, instead of + // attach+detach per frame. No local frame: the body below + // creates no JNI local refs (cached unchecked `write` call + + // `set_region`), so the per-chunk frame would be pure overhead. + let outcome = with_cached_daemon_env_no_frame(&jvm, |env| -> jni::errors::Result<()> { + let arr: &jni::objects::JByteArray<'_> = buf.as_ref().as_ref(); + for seg in chunk.chunks(chunk_size) { + // SAFETY: `u8` and `i8` (JNI's `jbyte`) have + // identical size/alignment; this views the + // segment as the signed slice `set_region` + // expects. `seg.len() <= chunk_size` (max + // 8 MiB) so it always fits both the buffer + // and `i32`. + let seg_i8 = + unsafe { std::slice::from_raw_parts(seg.as_ptr().cast::(), seg.len()) }; + arr.set_region(env, 0, seg_i8)?; + // seg.len() <= chunk_size <= 8 MiB always fits i32; the + // `unwrap_or(chunk_size_i32)` fallback is unreachable but keeps + // this FFI hot path panic-free (and write-safe: the fallback is + // the buffer length) if the clamp invariant ever changes. + let len = i32::try_from(seg.len()).unwrap_or(chunk_size_i32); + call_output_stream_write(env, &stream, &buf, len)?; + // The cached fast path calls `write()` via `call_method_unchecked`, + // which leaves a thrown `write()` (e.g. the client disconnected + // mid-download) PENDING instead of surfacing it as `Err`. Clear it + // AND propagate so the `failed` latch engages and we STOP writing + // the remaining segments/frames into a broken sink — instead of + // clearing it and futilely streaming the rest of the body to a + // dead stream. (The checked fallback already latches via `?`.) + if env.exception_check() { + env.exception_clear(); + return Err(jni::errors::Error::JavaException); + } + } + Ok(()) + }); + if outcome.is_err() { + failed = true; + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } +} + +pub fn call_header_consumer( + env: &mut jni::Env<'_>, + consumer: &Global>, + header_bytes: &[u8], +) -> jni::errors::Result<()> { + env.with_local_frame::<_, _, jni::errors::Error>(8, |env| { + let arr = env.byte_array_from_slice(header_bytes)?; + let arr_obj: JObject = arr.into(); + let result = call_consumer_accept(env, consumer, &arr_obj); + // `call_consumer_accept`'s cached `call_method_unchecked` fast path + // returns `Ok` with a thrown `Consumer.accept` left PENDING (only the + // checked fallback surfaces it as `Err`). A throwing header consumer + // is a FAILURE and MUST be reported as `Err`, exactly like the cached + // `read`/`write` paths convert their pending exception to an + // abort/`Err`. Otherwise the caller's `.is_ok()` records + // `header_sent = true` for a header the Java side never accepted, and + // the body keeps streaming over a failed header instead of aborting. + // Scrub on BOTH paths so the thread is left clean, then fail if a + // throw was detected. + let threw = env.exception_check(); + if threw { + env.exception_clear(); + return Err(jni::errors::Error::JavaException); + } + result?; + Ok(()) + }) +} + +/// Fire `Consumer.accept(byte[])` through a **local** consumer reference, +/// for the cold setup-failure / fallback paths of the streaming-with-header +/// dispatchers that run on the JNI entry thread (where the original +/// `header_consumer` local ref is still valid). +/// +/// Uses the checked `call_method` (no cached `JMethodID`) — these paths are +/// rare (oversized / failed ingress read, or a global-ref / VM-promotion / +/// buffer-checkout failure during setup). Crucially it does NOT promote the +/// consumer to a `Global` first, so it still delivers the mandatory single +/// header callback even when the very allocation that would promote it is what +/// failed — upholding the "header consumer invoked exactly once on every code +/// path" contract so the Java caller never hangs. +pub fn call_header_consumer_local( + env: &mut jni::Env<'_>, + consumer: &JObject<'_>, + header_bytes: &[u8], +) -> jni::errors::Result<()> { + // Scrub any exception already pending from the failed setup call that + // routed us here, so `byte_array_from_slice` below is not issued with an + // exception in flight. + if env.exception_check() { + env.exception_clear(); + } + // If the array allocation ITSELF fails (e.g. OOM), it leaves a NEW pending + // exception; clear it before surfacing the error so it does not leak into + // the caller's next JNI call on this thread (the `?` would otherwise return + // before the post-call scrub below). + let arr = match env.byte_array_from_slice(header_bytes) { + Ok(arr) => arr, + Err(e) => { + if env.exception_check() { + env.exception_clear(); + } + return Err(e); + } + }; + let arr_obj: JObject = arr.into(); + let result = env.call_method( + consumer, + jni_str!("accept"), + jni_sig!("(Ljava/lang/Object;)V"), + &[JValue::Object(&arr_obj)], + ); + // Scrub on BOTH paths so a throwing `accept` doesn't poison the thread's + // next JNI call (this is a cold best-effort delivery). + if env.exception_check() { + env.exception_clear(); + } + result?; + Ok(()) +} + +/// Complete a `CompletableFuture` via a **local** reference, for the +/// cold error / fallback paths of `dispatchAsync` that run on the JNI +/// entry thread (where the original `future` local ref is still valid). +/// +/// Uses the checked `call_method` — these paths are rare (oversized +/// request, JNI conversion failure, VM-promotion / scheduling failure), +/// so they do not need the cached-`JMethodID` fast path that +/// [`complete_future`] uses for the per-dispatch hot completion on the +/// worker thread. This lets `dispatchAsync` hold a **single** `Global` +/// ref (for the spawned task) instead of a second one kept solely for +/// these on-thread completions. +pub fn complete_future_local( + env: &mut jni::Env<'_>, + future: &JObject<'_>, + bytes: &[u8], +) -> jni::errors::Result<()> { + // Clear any exception ALREADY pending from the failed JNI call that routed + // us into this cold path (e.g. an `OutOfMemoryError` from `new_global_ref` + // / `get_java_vm`, or a failed request-array read). JNI functions must not + // be invoked with an exception pending — `byte_array_from_slice` below + // would otherwise fail and leave the Java `CompletableFuture` uncompleted + // (the caller hangs forever). We are converting that JNI failure into a + // best-effort `500` completion, so the original exception is intentionally + // discarded. + if env.exception_check() { + env.exception_clear(); + } + // If the array allocation ITSELF fails (e.g. OOM), it leaves a NEW pending + // exception; clear it before surfacing the error so the cold path does not + // leak it into the caller's next JNI call (the `?` would otherwise return + // before the post-call scrub below) — the `CompletableFuture` is then left + // uncompleted by THIS helper, but the caller treats the `Err` as a failed + // best-effort completion rather than hanging on a poisoned thread. + let arr = match env.byte_array_from_slice(bytes) { + Ok(arr) => arr, + Err(e) => { + if env.exception_check() { + env.exception_clear(); + } + return Err(e); + } + }; + let arr_obj: JObject = arr.into(); + let result = env.call_method( + future, + jni_str!("complete"), + jni_sig!("(Ljava/lang/Object;)Z"), + &[JValue::Object(&arr_obj)], + ); + // Scrub a pending Java exception on BOTH success and failure: a throwing + // `CompletableFuture.complete` must not leave the exception set for the + // caller's next JNI call (the result here is a cold-path best effort). + if env.exception_check() { + env.exception_clear(); + } + result?; + Ok(()) +} + +/// Best-effort `InputStream.close()` — invoked after a bidirectional +/// dispatch finishes to unblock a request producer parked in a blocking +/// `read`, so the dispatch cannot hang on a stuck upload. Any pending +/// exception (e.g. an `IOException` from closing an already-broken +/// stream) is cleared so the thread is left clean. +pub fn close_input_stream( + env: &mut jni::Env<'_>, + stream: &Global>, +) -> jni::errors::Result<()> { + let result = env.call_method(stream, jni_str!("close"), jni_sig!("()V"), &[]); + // Scrub a pending exception (e.g. an `IOException` from closing an + // already-broken stream) on BOTH success and failure — capturing the + // result and clearing BEFORE `?` so a throwing `close()` still leaves the + // thread clean, matching `complete_future{,_local}`'s self-contained + // contract (the prior `?`-before-clear returned early on a throw). + if env.exception_check() { + env.exception_clear(); + } + result?; + Ok(()) +} + +/// Call `CompletableFuture.complete(byte[])` and clear any pending +/// JNI exception so the worker thread is left clean for subsequent +/// dispatches. +pub fn complete_future( + env: &mut jni::Env<'_>, + future: &Global>, + bytes: &[u8], +) -> jni::errors::Result<()> { + // Capture the result instead of `?`-propagating so the exception clear + // below runs on EVERY path. The prior early `?` on byte_array_from_slice + // / complete() returned before the clear, leaking a pending exception + // onto the (pooled, daemon-attached) worker thread for the next dispatch + // — contradicting this function's own "left clean" contract. + let result = match env.byte_array_from_slice(bytes) { + Ok(arr) => { + let arr_obj: JObject = arr.into(); + call_future_complete(env, future, &arr_obj) + } + Err(e) => Err(e), + }; + // Always clear any leftover exception (e.g. if Java's complete() threw + // via a buggy whenComplete handler): we MUST NOT leave the attached + // thread in a faulted state because subsequent JNI calls will misbehave + // silently. + if env.exception_check() { + env.exception_clear(); + } + result +} + +#[cfg(test)] +mod tests { + use super::should_yield_after_zero_read; + + #[test] + fn zero_read_backoff_starts_after_repeated_empty_reads() { + // Given: a JNI InputStream that repeatedly reports empty reads. + // When: the count reaches the JNI-side threshold. + // Then: the pull closure yields the blocking worker instead of only + // relying on the inprocess producer's hard cap. + assert!(!should_yield_after_zero_read(15)); + assert!(should_yield_after_zero_read(16)); + } +} diff --git a/crates/vespera_macro/Cargo.toml b/crates/vespera_macro/Cargo.toml index a0534ac2..e4185e73 100644 --- a/crates/vespera_macro/Cargo.toml +++ b/crates/vespera_macro/Cargo.toml @@ -16,8 +16,16 @@ proc-macro = true # constraints into garde's runtime validators. The proc-macro itself # does NOT depend on `garde` — it only emits token streams that reference # the path; the user's `vespera = { features = ["validation"] }` is what -# actually pulls in the runtime crate. -validation = [] +# actually pulls in the runtime crate. `regex-syntax` is pulled in only + # here, to validate `#[schema(pattern = "...")]` literals at compile time. + validation = ["dep:regex-syntax"] + # Compile-time validation of `#[vespera::cron("...")]` expressions using the + # SAME parser the runtime uses (croner, via tokio-cron-scheduler) so a + # malformed cron string becomes a compile error instead of a startup + # `JobScheduler` panic. Enabled transitively by `vespera`'s `cron` feature, + # so croner (and its chrono/derive_builder/strum tree) only compiles when a + # crate actually uses cron. + cron = ["dep:croner"] [dependencies] quote = "1" @@ -26,12 +34,24 @@ proc-macro2 = { version = "1", features = ["span-locations"] } vespera_core = { workspace = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +# Compile-time validation of `#[schema(pattern = "...")]` regex literals +# (the exact parser `regex` uses) so an invalid pattern is a compile error +# instead of a first-validation runtime panic. Optional: pulled in only by +# the `validation` feature, which is what emits the pattern validator. +regex-syntax = { version = "0.8", optional = true } +# Compile-time validation of `#[cron("...")]` expressions (gated by the `cron` +# feature). MUST track the croner major version tokio-cron-scheduler resolves +# at runtime (0.15 → croner 3.x) so compile-time acceptance exactly matches the +# runtime `CronParser::builder().seconds(Seconds::Required)` parse. +croner = { version = "3", optional = true } [dev-dependencies] rstest = "0.26" -insta = "1.47" +insta = "1.48" +prettyplease = "0.2" tempfile = "3" serial_test = "3" +trybuild = "1" [lints] workspace = true diff --git a/crates/vespera_macro/src/args.rs b/crates/vespera_macro/src/args.rs index 67f2e8c3..adf19a86 100644 --- a/crates/vespera_macro/src/args.rs +++ b/crates/vespera_macro/src/args.rs @@ -1,20 +1,28 @@ use crate::http::is_http_method; +use crate::metadata::HeaderParam; +use syn::{LitBool, LitInt, LitStr, bracketed}; pub struct RouteArgs { pub method: Option, pub path: Option, pub error_status: Option, + pub responses: Option, + /// Declared non-200 success status from `status = ` (validated 2xx). + pub success_status: Option, pub tags: Option, + pub security: Option, + pub headers: Option>, + pub operation_id: Option, + pub summary: Option, + pub request_example: Option, + pub response_example: Option, + pub deprecated: bool, pub description: Option, } impl syn::parse::Parse for RouteArgs { fn parse(input: syn::parse::ParseStream) -> syn::Result { - let mut method: Option = None; - let mut path: Option = None; - let mut error_status: Option = None; - let mut tags: Option = None; - let mut description: Option = None; + let mut args = RouteArgsBuilder::default(); // Parse comma-separated list of arguments while !input.is_empty() { @@ -24,27 +32,7 @@ impl syn::parse::Parse for RouteArgs { // Try to parse as method identifier (get, post, etc.) let ident: syn::Ident = input.parse()?; let ident_str = ident.to_string().to_lowercase(); - if is_http_method(&ident_str) { - method = Some(ident); - } else if ident_str == "path" { - input.parse::()?; - let lit: syn::LitStr = input.parse()?; - path = Some(lit); - } else if ident_str == "error_status" { - input.parse::()?; - let array: syn::ExprArray = input.parse()?; - error_status = Some(array); - } else if ident_str == "tags" { - input.parse::()?; - let array: syn::ExprArray = input.parse()?; - tags = Some(array); - } else if ident_str == "description" { - input.parse::()?; - let lit: syn::LitStr = input.parse()?; - description = Some(lit); - } else { - return Err(lookahead.error()); - } + args.parse_ident(input, &ident, &ident_str, lookahead)?; // Check if there's a comma if input.peek(syn::Token![,]) { @@ -57,14 +45,423 @@ impl syn::parse::Parse for RouteArgs { } } - Ok(Self { - method, - path, - error_status, - tags, - description, - }) + Ok(args.finish()) + } +} + +#[derive(Default)] +struct RouteArgsBuilder { + method: Option, + path: Option, + error_status: Option, + responses: Option, + success_status: Option, + tags: Option, + security: Option, + headers: Option>, + operation_id: Option, + summary: Option, + request_example: Option, + response_example: Option, + deprecated: bool, + description: Option, +} + +impl RouteArgsBuilder { + fn parse_ident( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ident_str: &str, + lookahead: syn::parse::Lookahead1, + ) -> syn::Result<()> { + if is_http_method(ident_str) { + return self.parse_method(ident); + } + match ident_str { + "path" => self.parse_path(input, ident), + "error_status" => self.parse_error_status(input, ident), + "responses" => self.parse_responses(input, ident), + "status" => self.parse_status(input, ident), + "tags" => self.parse_tags(input, ident), + "security" => self.parse_security(input, ident), + "headers" => self.parse_headers(input, ident), + "operation_id" => self.parse_operation_id(input, ident), + "summary" => self.parse_summary(input, ident), + "request_example" => self.parse_request_example(input, ident), + "response_example" => self.parse_response_example(input, ident), + "deprecated" => self.parse_deprecated(ident), + "description" => self.parse_description(input, ident), + _ => Err(lookahead.error()), + } + } + + fn parse_method(&mut self, ident: &syn::Ident) -> syn::Result<()> { + reject_duplicate(self.method.as_ref(), ident, "HTTP method")?; + self.method = Some(ident.clone()); + Ok(()) + } + + fn parse_path( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + reject_duplicate(self.path.as_ref(), ident, "path")?; + input.parse::()?; + self.path = Some(input.parse()?); + Ok(()) + } + + fn parse_error_status( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + reject_duplicate(self.error_status.as_ref(), ident, "error_status")?; + input.parse::()?; + let array: syn::ExprArray = input.parse()?; + validate_error_status_array(&array)?; + self.error_status = Some(array); + Ok(()) + } + + fn parse_responses( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + reject_duplicate(self.responses.as_ref(), ident, "responses")?; + input.parse::()?; + let array: syn::ExprArray = input.parse()?; + validate_responses_array(&array)?; + self.responses = Some(array); + Ok(()) + } + + fn parse_status( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + reject_duplicate(self.success_status.as_ref(), ident, "status")?; + input.parse::()?; + let lit: LitInt = input.parse()?; + let code = lit.base10_parse::()?; + if !(200..300).contains(&code) { + return Err(syn::Error::new( + lit.span(), + "#[route] `status` must be a 2xx success status code (200-299).", + )); + } + self.success_status = Some(code); + Ok(()) + } + + fn parse_tags( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + reject_duplicate(self.tags.as_ref(), ident, "tags")?; + input.parse::()?; + self.tags = Some(input.parse()?); + Ok(()) + } + + fn parse_security( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + reject_duplicate(self.security.as_ref(), ident, "security")?; + input.parse::()?; + self.security = Some(input.parse()?); + Ok(()) + } + + fn parse_headers( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + reject_duplicate(self.headers.as_ref(), ident, "headers")?; + self.headers = Some(parse_header_values(input)?); + Ok(()) + } + + fn parse_lit_str_slot( + input: syn::parse::ParseStream, + ident: &syn::Ident, + slot: &mut Option, + name: &str, + ) -> syn::Result<()> { + reject_duplicate(slot.as_ref(), ident, name)?; + input.parse::()?; + *slot = Some(input.parse()?); + Ok(()) + } + + fn parse_operation_id( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + Self::parse_lit_str_slot(input, ident, &mut self.operation_id, "operation_id") + } + + fn parse_summary( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + Self::parse_lit_str_slot(input, ident, &mut self.summary, "summary") + } + + fn parse_request_example( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + Self::parse_lit_str_slot(input, ident, &mut self.request_example, "request_example") + } + + fn parse_response_example( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + Self::parse_lit_str_slot(input, ident, &mut self.response_example, "response_example") + } + + fn parse_deprecated(&mut self, ident: &syn::Ident) -> syn::Result<()> { + if self.deprecated { + return Err(duplicate_error(ident, "deprecated")); + } + self.deprecated = true; + Ok(()) + } + + fn parse_description( + &mut self, + input: syn::parse::ParseStream, + ident: &syn::Ident, + ) -> syn::Result<()> { + Self::parse_lit_str_slot(input, ident, &mut self.description, "description") + } + + fn finish(self) -> RouteArgs { + RouteArgs { + method: self.method, + path: self.path, + error_status: self.error_status, + responses: self.responses, + success_status: self.success_status, + tags: self.tags, + security: self.security, + headers: self.headers, + operation_id: self.operation_id, + summary: self.summary, + request_example: self.request_example, + response_example: self.response_example, + deprecated: self.deprecated, + description: self.description, + } + } +} + +fn reject_duplicate(slot: Option<&T>, ident: &syn::Ident, name: &str) -> syn::Result<()> { + if slot.is_some() { + Err(duplicate_error(ident, name)) + } else { + Ok(()) + } +} + +fn duplicate_error(ident: &syn::Ident, name: &str) -> syn::Error { + syn::Error::new( + ident.span(), + format!("#[route] `{name}` specified more than once"), + ) +} + +/// Validate `error_status = [, ...]`: every element must be an integer +/// literal in the `u16` range. A malformed entry is rejected with a +/// span-attached compile error instead of being silently dropped by the +/// downstream `filter_map` extraction (which would emit incomplete OpenAPI). +fn validate_error_status_array(array: &syn::ExprArray) -> syn::Result<()> { + for elem in &array.elems { + let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(lit_int), + .. + }) = elem + else { + return Err(syn::Error::new_spanned( + elem, + "#[route] `error_status` entries must be integer status codes, \ + e.g. `error_status = [400, 404]`.", + )); + }; + lit_int.base10_parse::().map_err(|_| { + syn::Error::new_spanned( + lit_int, + "#[route] `error_status` code must be in the u16 range (0-65535).", + ) + })?; + } + Ok(()) +} + +/// Validate `responses = [(, Type), ...]`: every element must be a +/// `(status, Type)` tuple with a `u16` status literal and a type **path**. +/// Malformed entries (a bare `(404)` parenthesized expr, a wrong-arity tuple, +/// a non-integer status, or a non-path type) are rejected with a span-attached +/// compile error instead of being silently dropped by the downstream +/// `filter_map` extraction — which previously produced incomplete OpenAPI with +/// no diagnostic (e.g. `responses = [(404)]` parsed "successfully" and emitted +/// nothing). +fn validate_responses_array(array: &syn::ExprArray) -> syn::Result<()> { + for elem in &array.elems { + let syn::Expr::Tuple(tuple) = elem else { + return Err(syn::Error::new_spanned( + elem, + "#[route] `responses` entries must be `(status, Type)` tuples, \ + e.g. `responses = [(404, NotFoundError)]`.", + )); + }; + if tuple.elems.len() != 2 { + return Err(syn::Error::new_spanned( + tuple, + "#[route] `responses` entry must be a `(status, Type)` tuple with \ + exactly two elements, e.g. `(404, NotFoundError)`.", + )); + } + let status = &tuple.elems[0]; + let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(lit_int), + .. + }) = status + else { + return Err(syn::Error::new_spanned( + status, + "#[route] `responses` status must be an integer literal, \ + e.g. `(404, NotFoundError)`.", + )); + }; + lit_int.base10_parse::().map_err(|_| { + syn::Error::new_spanned( + lit_int, + "#[route] `responses` status must be in the u16 range (0-65535).", + ) + })?; + let schema = &tuple.elems[1]; + if !matches!(schema, syn::Expr::Path(_)) { + return Err(syn::Error::new_spanned( + schema, + "#[route] `responses` type must be a type path, \ + e.g. `(404, NotFoundError)` or `(400, crate::errors::BadRequestError)`.", + )); + } + } + Ok(()) +} + +fn parse_header_values(input: syn::parse::ParseStream) -> syn::Result> { + input.parse::()?; + + let content; + let _ = bracketed!(content in input); + let mut headers = Vec::new(); + + while !content.is_empty() { + headers.push(parse_header_struct(&content)?); + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + Ok(headers) +} + +fn parse_header_struct(input: syn::parse::ParseStream) -> syn::Result { + let content; + syn::braced!(content in input); + + let mut name: Option = None; + let mut required = false; + let mut required_seen = false; + let mut description: Option = None; + + while !content.is_empty() { + let ident: syn::Ident = content.parse()?; + let ident_str = ident.to_string(); + content.parse::()?; + + // Reject a repeated field in one header object instead of letting the + // later value silently win (which produced ambiguous OpenAPI with no + // diagnostic). `required` is a bare `bool`, so it needs its own + // seen-flag; `name`/`description` are `Option`s already. + match ident_str.as_str() { + "name" => { + if name.is_some() { + return Err(syn::Error::new( + ident.span(), + "duplicate header field `name`", + )); + } + name = Some(content.parse::()?.value()); + } + "required" => { + if required_seen { + return Err(syn::Error::new( + ident.span(), + "duplicate header field `required`", + )); + } + required = content.parse::()?.value; + required_seen = true; + } + "description" => { + if description.is_some() { + return Err(syn::Error::new( + ident.span(), + "duplicate header field `description`", + )); + } + description = Some(content.parse::()?.value()); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!( + "unknown header field: `{ident_str}`. Expected `name`, `required`, or `description`" + ), + )); + } + } + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } } + + let name = name.ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + "#[route] headers entry missing required `name` field.", + ) + })?; + + Ok(HeaderParam { + name, + required, + description, + }) } #[cfg(test)] @@ -109,6 +506,11 @@ mod tests { #[case("invalid", false, None, None, None)] #[case("path", false, None, None, None)] #[case("error_status", false, None, None, None)] + // Malformed error_status entries are now span-attached compile errors: + #[case("error_status = [\"400\"]", false, None, None, None)] // not an integer + #[case("error_status = [400, \"404\"]", false, None, None, None)] // mixed + #[case("error_status = [70000]", false, None, None, None)] // out of u16 range + #[case("error_status = [NotFound]", false, None, None, None)] // path, not int #[case("get, invalid", false, None, None, None)] #[case("path =", false, None, None, None)] #[case("error_status =", false, None, None, None)] @@ -276,4 +678,287 @@ mod tests { } } } + + #[rstest] + // Security only + #[case("security = [\"bearerAuth\"]", true, vec!["bearerAuth"])] + #[case("security = [\"bearerAuth\", \"apiKey\"]", true, vec!["bearerAuth", "apiKey"])] + // Security with method/path + #[case("get, security = [\"bearerAuth\"]", true, vec!["bearerAuth"])] + #[case("post, path = \"/users\", security = [\"apiKey\"]", true, vec!["apiKey"])] + // Empty security array means explicit no auth + #[case("security = []", true, vec![])] + fn test_route_args_parse_security( + #[case] input: &str, + #[case] should_parse: bool, + #[case] expected_security: Vec<&str>, + ) { + let result = syn::parse_str::(input); + + match (should_parse, result) { + (true, Ok(route_args)) => { + let security_array = route_args + .security + .as_ref() + .unwrap_or_else(|| panic!("Expected security for input: {input}")); + let mut parsed_security = Vec::new(); + for elem in &security_array.elems { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = elem + { + parsed_security.push(lit_str.value()); + } + } + assert_eq!( + parsed_security, expected_security, + "Security mismatch for input: {input}" + ); + } + (false, Err(_)) => {} + (true, Err(e)) => { + panic!("Expected successful parse but got error: {e} for input: {input}"); + } + (false, Ok(_)) => { + panic!("Expected parse error but got success for input: {input}"); + } + } + } + + #[rstest] + #[case( + r#"headers = [{ name = "Authorization", required = true, description = "Bearer token" }, { name = "X-Trace-Id" }]"#, + vec![ + HeaderParam { name: "Authorization".to_string(), required: true, description: Some("Bearer token".to_string()) }, + HeaderParam { name: "X-Trace-Id".to_string(), required: false, description: None }, + ] + )] + #[case(r"get, headers = []", vec![])] + fn test_route_args_parse_headers( + #[case] input: &str, + #[case] expected_headers: Vec, + ) { + let route_args = syn::parse_str::(input) + .unwrap_or_else(|e| panic!("Expected successful parse for {input}: {e}")); + assert_eq!(route_args.headers.unwrap(), expected_headers); + } + + #[rstest] + #[case(r"headers = [{ required = true }]")] + #[case(r#"headers = [{ name = "Authorization", unknown = "x" }]"#)] + #[case(r#"headers = [{ name = "Authorization", required = "yes" }]"#)] + fn test_route_args_parse_headers_invalid(#[case] input: &str) { + assert!(syn::parse_str::(input).is_err()); + } + + #[rstest] + #[case("responses = [(404, NotFoundError)]", true, vec![(404, "NotFoundError")])] + #[case("responses = [(400, crate::errors::BadRequestError)]", true, vec![(400, "BadRequestError")])] + #[case("get, responses = [(404, NotFoundError), (400, crate::errors::BadRequestError)]", true, vec![(404, "NotFoundError"), (400, "BadRequestError")])] + #[case("responses", false, vec![])] + // Malformed entries are now a span-attached compile error (previously parsed + // "successfully" and silently emitted no response): + #[case("responses = [(404)]", false, vec![])] // bare paren expr, missing Type + #[case("responses = [(404, NotFoundError, Extra)]", false, vec![])] // wrong arity + #[case("responses = [404]", false, vec![])] // not a tuple + #[case("responses = [(\"404\", NotFoundError)]", false, vec![])] // status not int + #[case("responses = [(404, \"NotFoundError\")]", false, vec![])] // type not a path + #[case("responses = [(70000, NotFoundError)]", false, vec![])] // status out of u16 + #[case("responses = []", true, vec![])] // empty is valid (no entries) + fn test_route_args_parse_responses( + #[case] input: &str, + #[case] should_parse: bool, + #[case] expected_responses: Vec<(u16, &str)>, + ) { + let result = syn::parse_str::(input); + + match (should_parse, result) { + (true, Ok(route_args)) => { + let responses_array = route_args + .responses + .as_ref() + .unwrap_or_else(|| panic!("Expected responses for input: {input}")); + let parsed_responses: Vec<(u16, String)> = responses_array + .elems + .iter() + .filter_map(|elem| { + let syn::Expr::Tuple(tuple) = elem else { + return None; + }; + let status = tuple.elems.first().and_then(|status| { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(lit_int), + .. + }) = status + { + lit_int.base10_parse::().ok() + } else { + None + } + })?; + let schema_name = tuple.elems.get(1).and_then(|schema| { + if let syn::Expr::Path(path) = schema { + path.path.segments.last().map(|seg| seg.ident.to_string()) + } else { + None + } + })?; + Some((status, schema_name)) + }) + .collect(); + let expected: Vec<(u16, String)> = expected_responses + .into_iter() + .map(|(status, schema)| (status, schema.to_string())) + .collect(); + assert_eq!( + parsed_responses, expected, + "Responses mismatch for input: {input}" + ); + } + (false, Err(_)) => {} + (true, Err(e)) => { + panic!("Expected successful parse but got error: {e} for input: {input}"); + } + (false, Ok(_)) => { + panic!("Expected parse error but got success for input: {input}"); + } + } + } + + #[rstest] + #[case("deprecated", true)] + #[case("get, deprecated", true)] + #[case("post, path = \"/users\", deprecated", true)] + #[case("deprecated = true", false)] + fn test_route_args_parse_deprecated(#[case] input: &str, #[case] should_parse: bool) { + let result = syn::parse_str::(input); + + match (should_parse, result) { + (true, Ok(route_args)) => assert!(route_args.deprecated), + (false, Err(_)) => {} + (true, Err(e)) => { + panic!("Expected successful parse but got error: {e} for input: {input}"); + } + (false, Ok(_)) => { + panic!("Expected parse error but got success for input: {input}"); + } + } + } + + #[rstest] + #[case("operation_id = \"getUser\"", true, Some("getUser"))] + #[case("get, operation_id = \"listUsers\"", true, Some("listUsers"))] + #[case("operation_id", false, None)] + #[case("operation_id = 123", false, None)] + fn test_route_args_parse_operation_id( + #[case] input: &str, + #[case] should_parse: bool, + #[case] expected_operation_id: Option<&str>, + ) { + let result = syn::parse_str::(input); + + match (should_parse, result) { + (true, Ok(route_args)) => assert_eq!( + route_args.operation_id.as_ref().map(syn::LitStr::value), + expected_operation_id.map(str::to_string) + ), + (false, Err(_)) => {} + (true, Err(e)) => { + panic!("Expected successful parse but got error: {e} for input: {input}"); + } + (false, Ok(_)) => { + panic!("Expected parse error but got success for input: {input}"); + } + } + } + + #[rstest] + #[case("summary = \"Get a user\"", true, Some("Get a user"))] + #[case("get, summary = \"List users\"", true, Some("List users"))] + #[case("summary", false, None)] + #[case("summary = 123", false, None)] + fn test_route_args_parse_summary( + #[case] input: &str, + #[case] should_parse: bool, + #[case] expected_summary: Option<&str>, + ) { + let result = syn::parse_str::(input); + + match (should_parse, result) { + (true, Ok(route_args)) => assert_eq!( + route_args.summary.as_ref().map(syn::LitStr::value), + expected_summary.map(str::to_string) + ), + (false, Err(_)) => {} + (true, Err(e)) => { + panic!("Expected successful parse but got error: {e} for input: {input}"); + } + (false, Ok(_)) => { + panic!("Expected parse error but got success for input: {input}"); + } + } + } + + #[rstest] + #[case( + r#"request_example = "{\"name\":\"Alice\"}""#, + Some(r#"{"name":"Alice"}"#), + None + )] + #[case(r#"response_example = "{\"id\":1}""#, None, Some(r#"{"id":1}"#))] + fn test_route_args_parse_examples( + #[case] input: &str, + #[case] expected_request: Option<&str>, + #[case] expected_response: Option<&str>, + ) { + let route_args = syn::parse_str::(input).unwrap(); + assert_eq!( + route_args.request_example.as_ref().map(syn::LitStr::value), + expected_request.map(str::to_string) + ); + assert_eq!( + route_args.response_example.as_ref().map(syn::LitStr::value), + expected_response.map(str::to_string) + ); + } + + #[rstest] + // Valid 2xx success statuses + #[case("status = 200", true, Some(200))] + #[case("status = 201", true, Some(201))] + #[case("status = 204", true, Some(204))] + #[case("status = 299", true, Some(299))] + #[case("get, status = 204", true, Some(204))] + #[case("delete, path = \"/x\", status = 204", true, Some(204))] + // Non-2xx status codes are rejected with a compile error + #[case("status = 199", false, None)] + #[case("status = 300", false, None)] + #[case("status = 404", false, None)] + #[case("status = 500", false, None)] + // Malformed: missing value / non-integer / out of u16 range + #[case("status", false, None)] + #[case("status =", false, None)] + #[case("status = \"204\"", false, None)] + #[case("status = 70000", false, None)] + fn test_route_args_parse_status( + #[case] input: &str, + #[case] should_parse: bool, + #[case] expected_status: Option, + ) { + let result = syn::parse_str::(input); + match (should_parse, result) { + (true, Ok(route_args)) => { + assert_eq!( + route_args.success_status, expected_status, + "status mismatch for input: {input}" + ); + } + (false, Err(_)) => {} + (true, Err(e)) => { + panic!("Expected successful parse but got error: {e} for input: {input}") + } + (false, Ok(_)) => panic!("Expected parse error but got success for input: {input}"), + } + } } diff --git a/crates/vespera_macro/src/collector.rs b/crates/vespera_macro/src/collector.rs index 28e5534a..a9c464de 100644 --- a/crates/vespera_macro/src/collector.rs +++ b/crates/vespera_macro/src/collector.rs @@ -5,14 +5,45 @@ use std::path::Path; use syn::Item; +mod path_scan; + +pub use path_scan::normalize_path_key; +pub use path_scan::{fingerprints_from_scan, scan_route_folder}; + use crate::{ error::{MacroResult, err_call_site}, - file_utils::{collect_files, file_to_segments}, + file_utils::{file_to_segments, normalize_display_path}, metadata::{CollectedMetadata, RouteMetadata}, route::{extract_doc_comment, extract_route_info}, route_impl::StoredRouteInfo, }; +/// Kebab-case a route path for the file-based routing convention +/// (snake_case file / folder segments → kebab-case URL), but PRESERVE the +/// contents of `{...}` path parameters verbatim. Hyphenating a `{user_id}` +/// parameter to `{user-id}` would corrupt the OpenAPI parameter name and +/// break the match with the handler's `Path` extractor, so underscores +/// inside `{...}` are left untouched. +fn kebab_case_path(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + let mut in_param = false; + for ch in path.chars() { + match ch { + '{' => { + in_param = true; + out.push(ch); + } + '}' => { + in_param = false; + out.push(ch); + } + '_' if !in_param => out.push('-'), + other => out.push(other), + } + } + out +} + /// Collect routes and structs from a folder. /// /// When `route_storage` contains entries with `file_path`, files covered by @@ -22,24 +53,62 @@ use crate::{ /// /// Returns the metadata AND the parsed file ASTs, so downstream consumers /// (e.g., `openapi_generator`) can reuse them without re-reading files from disk. +// Test-only convenience wrapper: `vespera!` / `export_app!` reach the collector +// through `collect_metadata_from_files` (which reuses the cache's single +// directory walk), so this folder-walking variant exists purely for the unit +// tests that exercise the collector end-to-end. `#[cfg(test)]` keeps it (and its +// `collect_files` dependency) out of the shipped proc-macro entirely. +#[cfg(test)] #[allow(clippy::option_if_let_else, clippy::too_many_lines)] pub fn collect_metadata( folder_path: &Path, folder_name: &str, route_storage: &[StoredRouteInfo], ) -> MacroResult<(CollectedMetadata, HashMap)> { - let mut metadata = CollectedMetadata::new(); - - let files = collect_files(folder_path).map_err(|e| err_call_site(format!("vespera! macro: failed to scan route folder '{}': {}. Verify the folder exists and is readable.", folder_path.display(), e)))?; + let files = crate::file_utils::collect_files(folder_path).map_err(|e| err_call_site(format!("vespera! macro: failed to scan route folder '{}': {}. Verify the folder exists and is readable.", folder_path.display(), e)))?; + collect_metadata_from_files( + files.iter().map(std::path::PathBuf::as_path), + folder_path, + folder_name, + route_storage, + ) +} - let mut file_asts = HashMap::with_capacity(files.len()); +/// [`collect_metadata`] over a **pre-scanned** file list — lets +/// `vespera!` reuse the single directory walk it already performed +/// for cache fingerprinting instead of walking the folder twice. +#[allow(clippy::option_if_let_else, clippy::too_many_lines)] +pub fn collect_metadata_from_files<'a>( + files: impl IntoIterator, + folder_path: &Path, + folder_name: &str, + route_storage: &[StoredRouteInfo], +) -> MacroResult<(CollectedMetadata, HashMap)> { + let mut metadata = CollectedMetadata::new(); - // Index ROUTE_STORAGE entries by file path for O(1) lookup - let storage_by_file: HashMap<&str, Vec<&StoredRouteInfo>> = { - let mut map: HashMap<&str, Vec<&StoredRouteInfo>> = HashMap::new(); + // Borrows the caller's path source (slice or pre-scanned `(path, mtime)` + // pairs) by `&Path`, so neither `vespera!` (cache miss) nor + // `collect_metadata` needs to clone the path list. `file_asts` only holds + // slow-path (non-ROUTE_STORAGE) parses, so a default-capacity map is fine. + let mut file_asts = HashMap::new(); + + // Index ROUTE_STORAGE entries by **canonicalized** file path for O(1) + // lookup. `#[route]` records `Span::local_file()`, which rustc + // reports relative to its invocation directory (e.g. + // `src\routes\users.rs`), while the collector walks + // `{CARGO_MANIFEST_DIR}/src/{folder}` producing absolute paths with + // platform separators. Comparing the raw strings never matches — + // silently disabling the fast path and re-parsing every route file + // on each cache miss. Canonicalizing both sides makes the keys + // comparable regardless of cwd-relativity or separator style. + let cwd = std::env::current_dir().unwrap_or_default(); + let storage_by_file: HashMap> = { + let mut map: HashMap> = HashMap::new(); for stored in route_storage { if let Some(ref fp) = stored.file_path { - map.entry(fp.as_str()).or_default().push(stored); + map.entry(normalize_path_key(fp, &cwd)) + .or_default() + .push(stored); } } map @@ -50,9 +119,13 @@ pub fn collect_metadata( continue; } - let file_path = file.display().to_string(); + let mut file_path = normalize_display_path(file); + // Fast-path lookup key, computed once and reused below. Feeding the + // already-built `file_path` borrow (not a fresh `file.to_string_lossy()`) + // avoids an extra owned-string allocation; `normalize_path_key` does its + // own separator + component folding, so the key is identical either way. + let file_key = normalize_path_key(&file_path, &cwd); - // Get module path (cheap — no parsing needed) let segments = file .strip_prefix(folder_path) .map(|file_stem| file_to_segments(file_stem, folder_path)) @@ -65,42 +138,68 @@ pub fn collect_metadata( )) })?; - let module_path = if folder_name.is_empty() { + let mut module_path = if folder_name.is_empty() { segments.join("::") } else { format!("{}::{}", folder_name, segments.join("::")) }; - // Pre-compute base path once per file (avoids repeated segments.join per route) let base_path = format!("/{}", segments.join("/")); // Fast path: ROUTE_STORAGE has entries for this file — skip syn::parse_file() - if let Some(stored_routes) = storage_by_file.get(file_path.as_str()) { - for stored in stored_routes { + // + // Per-file invariants (`module_path`, `file_path`) are CLONED for + // every non-last route but MOVED into the last route's push — + // refcount-free amortization of two String allocations per file. + if let Some(stored_routes) = storage_by_file.get(&file_key) { + let n = stored_routes.len(); + for (i, stored) in stored_routes.iter().enumerate() { let route_path = if let Some(ref custom_path) = stored.custom_path { let trimmed_base = base_path.trim_end_matches('/'); format!("{trimmed_base}/{}", custom_path.trim_start_matches('/')) } else { base_path.clone() }; - let route_path = route_path.replace('_', "-"); - - // Extract doc comment from fn_item_str if no explicit description - let description = stored.description.clone().or_else(|| { - syn::parse_str::(&stored.fn_item_str) - .ok() - .and_then(|fn_item| extract_doc_comment(&fn_item.attrs)) - }); + let route_path = kebab_case_path(&route_path); + + // `#[route]` already resolved the description at expansion + // time (explicit attribute OR doc comment — see + // `process_route_attribute`), so `stored.description` is + // authoritative. Re-parsing `fn_sig_str` here could never + // find a doc comment the attribute macro didn't. + let description = stored.description.clone(); + + let (mp, fp) = if i + 1 == n { + ( + std::mem::take(&mut module_path), + std::mem::take(&mut file_path), + ) + } else { + (module_path.clone(), file_path.clone()) + }; metadata.routes.push(RouteMetadata { - method: stored.method.clone().unwrap_or_default(), + // `#[route]` bare form defaults to GET — mirror the + // slow path (`route::utils`), which resolves a + // missing method to "get". `unwrap_or_default()` + // produced "" here, silently dropping such routes + // from the OpenAPI doc when the fast path is active. + method: stored.method.clone().unwrap_or_else(|| "get".to_string()), path: route_path, function_name: stored.fn_name.clone(), - module_path: module_path.clone(), - file_path: file_path.clone(), - signature: stored.fn_item_str.clone(), + module_path: mp, + file_path: fp, + success_status: stored.success_status, error_status: stored.error_status.clone(), + typed_responses: stored.typed_responses.clone(), tags: stored.tags.clone(), + security: stored.security.clone(), + headers: stored.headers.clone(), + operation_id: stored.operation_id.clone(), + summary: stored.summary.clone(), + request_example: stored.request_example.clone(), + response_example: stored.response_example.clone(), + deprecated: stored.deprecated, description, }); } @@ -109,45 +208,68 @@ pub fn collect_metadata( // #[derive(Schema)] already extracts serde(default = "fn") values // into SCHEMA_STORAGE.field_defaults (Priority 0 in process_default_functions) } else { - // Slow path: full parsing (fallback for files not in ROUTE_STORAGE) - // Uses get_parsed_file: single syn::parse_file entry point + content cache - let file_ast = crate::schema_macro::file_cache::get_parsed_file(&file).ok_or_else(|| err_call_site(format!("vespera! macro: cannot read or parse '{}'. Fix the Rust syntax errors in this file.", file.display())))?; + let file_ast = crate::schema_macro::file_cache::get_parsed_file(file).ok_or_else(|| err_call_site(format!("vespera! macro: cannot read or parse '{}'. Fix the Rust syntax errors in this file.", file.display())))?; - // Store file AST for downstream reuse file_asts.insert(file_path.clone(), file_ast); let file_ast = &file_asts[&file_path]; - // Collect routes from AST + // Pre-collect (fn_item, owned RouteInfo) pairs so we can + // 1. detect the last route up-front (symmetric with fast path), + // 2. MOVE owned RouteInfo fields (method / error_status / tags / + // description) into RouteMetadata instead of re-cloning them. + let mut route_entries: Vec<(&syn::ItemFn, crate::route::RouteInfo)> = Vec::new(); for item in &file_ast.items { if let Item::Fn(fn_item) = item && let Some(route_info) = extract_route_info(&fn_item.attrs) { - let route_path = if let Some(custom_path) = &route_info.path { - let trimmed_base = base_path.trim_end_matches('/'); - format!("{trimmed_base}/{}", custom_path.trim_start_matches('/')) - } else { - base_path.clone() - }; - let route_path = route_path.replace('_', "-"); + route_entries.push((fn_item, route_info)); + } + } - // Description priority: route attribute > doc comment - let description = route_info - .description - .clone() - .or_else(|| extract_doc_comment(&fn_item.attrs)); + let n = route_entries.len(); + for (i, (fn_item, route_info)) in route_entries.into_iter().enumerate() { + let route_path = if let Some(custom_path) = &route_info.path { + let trimmed_base = base_path.trim_end_matches('/'); + format!("{trimmed_base}/{}", custom_path.trim_start_matches('/')) + } else { + base_path.clone() + }; + let route_path = kebab_case_path(&route_path); + + // Description priority: route attribute > doc comment + // (move the owned Option instead of cloning + dropping it) + let description = route_info + .description + .or_else(|| extract_doc_comment(&fn_item.attrs)); + + let (mp, fp) = if i + 1 == n { + ( + std::mem::take(&mut module_path), + std::mem::take(&mut file_path), + ) + } else { + (module_path.clone(), file_path.clone()) + }; - metadata.routes.push(RouteMetadata { - method: route_info.method, - path: route_path, - function_name: fn_item.sig.ident.to_string(), - module_path: module_path.clone(), - file_path: file_path.clone(), - signature: quote::quote!(#fn_item).to_string(), - error_status: route_info.error_status.clone(), - tags: route_info.tags.clone(), - description, - }); - } + metadata.routes.push(RouteMetadata { + method: route_info.method, + path: route_path, + function_name: fn_item.sig.ident.to_string(), + module_path: mp, + file_path: fp, + success_status: route_info.success_status, + error_status: route_info.error_status, + typed_responses: route_info.typed_responses, + tags: route_info.tags, + security: route_info.security, + headers: route_info.headers, + operation_id: route_info.operation_id, + summary: route_info.summary, + request_example: route_info.request_example, + response_example: route_info.response_example, + deprecated: route_info.deprecated, + description, + }); } } } @@ -155,956 +277,5 @@ pub fn collect_metadata( Ok((metadata, file_asts)) } -/// Collect file modification times without reading content. -/// Used for cache invalidation — much cheaper than full `collect_metadata()`. -pub fn collect_file_fingerprints(folder_path: &Path) -> MacroResult> { - let files = collect_files(folder_path).map_err(|e| { - err_call_site(format!( - "vespera! macro: failed to scan route folder '{}': {}", - folder_path.display(), - e - )) - })?; - - let mut fingerprints = HashMap::with_capacity(files.len()); - for file in files { - if file.extension().is_none_or(|e| e != "rs") { - continue; - } - let mtime = std::fs::metadata(&file) - .and_then(|m| m.modified()) - .map_or(0, |t| { - t.duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - }); - fingerprints.insert(file.display().to_string(), mtime); - } - Ok(fingerprints) -} - #[cfg(test)] -mod tests { - use std::fs; - - use rstest::rstest; - use tempfile::TempDir; - - use super::*; - - fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { - let file_path = dir.path().join(filename); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).expect("Failed to create parent directory"); - } - fs::write(&file_path, content).expect("Failed to write temp file"); - file_path - } - - #[test] - fn test_collect_metadata_empty_folder() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - assert!(metadata.routes.is_empty()); - assert!(metadata.structs.is_empty()); - - drop(temp_dir); - } - - #[rstest] - #[case::single_get_route( - "routes", - vec![( - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users", - "get_users", - "routes::users", - )] - #[case::single_post_route( - "routes", - vec![( - "create_user.rs", - r#" -#[route(post)] -pub fn create_user() -> String { - "created".to_string() -} -"#, - )], - "post", - "/create-user", - "create_user", - "routes::create_user", - )] - #[case::route_with_custom_path( - "routes", - vec![( - "users.rs", - r#" -#[route(get, path = "/api/users")] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users/api/users", - "get_users", - "routes::users", - )] - #[case::route_with_error_status( - "routes", - vec![( - "users.rs", - r#" -#[route(get, error_status = [400, 404])] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users", - "get_users", - "routes::users", - )] - #[case::nested_module( - "routes", - vec![( - "api/users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/api/users", - "get_users", - "routes::api::users", - )] - #[case::deeply_nested_module( - "routes", - vec![( - "api/v1/users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/api/v1/users", - "get_users", - "routes::api::v1::users", - )] - fn test_collect_metadata_routes( - #[case] folder_name: &str, - #[case] files: Vec<(&str, &str)>, - #[case] expected_method: &str, - #[case] expected_path: &str, - #[case] expected_function_name: &str, - #[case] expected_module_path: &str, - ) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - for (filename, content) in &files { - create_temp_file(&temp_dir, filename, content); - } - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - let route = &metadata.routes[0]; - assert_eq!(route.method, expected_method); - assert_eq!(route.path, expected_path); - assert_eq!(route.function_name, expected_function_name); - assert_eq!(route.module_path, expected_module_path); - if let Some((first_filename, _)) = files.first() { - assert!( - route - .file_path - .contains(first_filename.split('/').next().unwrap()) - ); - } - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_single_struct() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - assert_eq!(metadata.routes.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_struct_without_schema() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "user.rs", - r" -pub struct User { - pub id: i32, - pub name: String, -} -", - ); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - assert_eq!(metadata.routes.len(), 0); - assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_route_and_struct() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "user.rs", - r#" -use vespera::Schema; - -#[derive(Schema)] -pub struct User { - pub id: i32, - pub name: String, -} - -#[route(get)] -pub fn get_user() -> User { - User { id: 1, name: "Alice".to_string() } -} -"#, - ); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - - let route = &metadata.routes[0]; - assert_eq!(route.function_name, "get_user"); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_multiple_routes() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} - -#[route(post)] -pub fn create_users() -> String { - "created".to_string() -} -"#, - ); - - create_temp_file( - &temp_dir, - "posts.rs", - r#" -#[route(get)] -pub fn get_posts() -> String { - "posts".to_string() -} -"#, - ); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - assert_eq!(metadata.routes.len(), 3); - assert_eq!(metadata.structs.len(), 0); - - // Check all routes are present - let function_names: Vec<&str> = metadata - .routes - .iter() - .map(|r| r.function_name.as_str()) - .collect(); - assert!(function_names.contains(&"get_users")); - assert!(function_names.contains(&"create_users")); - assert!(function_names.contains(&"get_posts")); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_multiple_structs() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "user.rs", - r" -use vespera::Schema; - -#[derive(Schema)] -pub struct User { - pub id: i32, - pub name: String, -} -", - ); - - create_temp_file( - &temp_dir, - "post.rs", - r" -use vespera::Schema; - -#[derive(Schema)] -pub struct Post { - pub id: i32, - pub title: String, -} -", - ); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - assert_eq!(metadata.routes.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_with_mod_rs() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "mod.rs", - r#" -#[route(get)] -pub fn index() -> String { - "index".to_string() -} -"#, - ); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.function_name, "index"); - assert_eq!(route.path, "/"); - assert_eq!(route.module_path, "routes::"); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_empty_folder_name() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = ""; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.module_path, "users"); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_ignores_non_rs_files() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - create_temp_file(&temp_dir, "config.txt", "some config content"); - - create_temp_file(&temp_dir, "readme.md", "# Readme"); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - // Only .rs file should be processed - assert_eq!(metadata.routes.len(), 1); - assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_ignores_invalid_syntax() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "valid.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - create_temp_file(&temp_dir, "invalid.rs", "invalid rust syntax {"); - - let metadata = collect_metadata(temp_dir.path(), folder_name, &[]).map(|(m, _)| m); - - // Only valid file should be processed - assert!(metadata.is_err()); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_error_status() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get, error_status = [400, 404, 500])] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.method, "get"); - assert!(route.error_status.is_some()); - let error_status = route.error_status.as_ref().unwrap(); - assert_eq!(error_status.len(), 3); - assert!(error_status.contains(&400)); - assert!(error_status.contains(&404)); - assert!(error_status.contains(&500)); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_all_http_methods() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "routes.rs", - r#" -#[route(get)] -pub fn get_handler() -> String { "get".to_string() } - -#[route(post)] -pub fn post_handler() -> String { "post".to_string() } - -#[route(put)] -pub fn put_handler() -> String { "put".to_string() } - -#[route(patch)] -pub fn patch_handler() -> String { "patch".to_string() } - -#[route(delete)] -pub fn delete_handler() -> String { "delete".to_string() } - -#[route(head)] -pub fn head_handler() -> String { "head".to_string() } - -#[route(options)] -pub fn options_handler() -> String { "options".to_string() } -"#, - ); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - assert_eq!(metadata.routes.len(), 7); - - let methods: Vec<&str> = metadata.routes.iter().map(|r| r.method.as_str()).collect(); - assert!(methods.contains(&"get")); - assert!(methods.contains(&"post")); - assert!(methods.contains(&"put")); - assert!(methods.contains(&"patch")); - assert!(methods.contains(&"delete")); - assert!(methods.contains(&"head")); - assert!(methods.contains(&"options")); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_collect_files_error() { - // Test: collect_files returns error (non-existent directory) - let non_existent_path = std::path::Path::new("/nonexistent/path/that/does/not/exist"); - let folder_name = "routes"; - - let result = collect_metadata(non_existent_path, folder_name, &[]); - - // Should return error when collect_files fails - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("failed to scan route folder")); - } - - #[test] - #[cfg(unix)] - fn test_collect_metadata_file_read_error_permissions() { - // Test line 31-37: file read error due to permission denial - // On Unix, we can create a file and then remove read permissions - use std::fs; - use std::os::unix::fs::PermissionsExt; - - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create a file with valid Rust syntax - let file_path = temp_dir.path().join("unreadable.rs"); - fs::write( - &file_path, - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ) - .expect("Failed to write temp file"); - - // Remove read permissions - let permissions = fs::Permissions::from_mode(0o000); - fs::set_permissions(&file_path, permissions).expect("Failed to set permissions"); - - // Verify permissions actually took effect (they don't on WSL with Windows filesystem) - // If we can still read the file, skip this test - if fs::read_to_string(&file_path).is_ok() { - // Restore permissions for cleanup - let permissions = fs::Permissions::from_mode(0o644); - fs::set_permissions(&file_path, permissions).ok(); - eprintln!( - "Skipping test: filesystem doesn't respect Unix permissions (likely WSL with NTFS)" - ); - return; - } - - // Attempt to collect metadata - should fail with "failed to read route file" error - let result = collect_metadata(temp_dir.path(), folder_name, &[]); - - // Verify error message - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("failed to read route file")); - - // Restore permissions so tempdir cleanup doesn't fail - let permissions = fs::Permissions::from_mode(0o644); - fs::set_permissions(&file_path, permissions).ok(); - - drop(temp_dir); - } - - #[test] - #[cfg(windows)] - fn test_collect_metadata_file_read_error_documentation_windows() { - // Test line 31-37: Documentation of file read error handling on Windows - // - // On Windows, file permission errors are harder to reliably trigger in tests - // because standard read/write operations on temp files typically succeed. - // The error path at line 31-37 is exercised by edge cases: - // 1. Files deleted between collect_files scan and read attempt - // 2. Network drive disconnections - // 3. Permission changes during execution - // - // These are difficult to simulate reliably in automated tests. - // The error handling code itself is straightforward: - // - std::fs::read_to_string() returns an io::Error - // - map_err() wraps it with context message - // - Caller receives "failed to read route file" error - // - // This is tested indirectly via test_collect_metadata_file_read_error_via_invalid_syntax - // which verifies error propagation works correctly. - - // Verify the documented behavior with a comment-only test - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Successfully create a readable file to verify the happy path - create_temp_file( - &temp_dir, - "readable.rs", - r#" -#[route(get)] -pub fn get() -> String { "ok".to_string() } -"#, - ); - - let result = collect_metadata(temp_dir.path(), folder_name, &[]); - assert!(result.is_ok()); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_file_read_error_via_invalid_syntax() { - // Test line 31-37: verify error handling by parsing invalid files - // While we can't easily trigger read errors on all platforms, - // we verify the code path by ensuring errors are properly propagated - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create a file that will fail to parse (syntax error) - create_temp_file(&temp_dir, "invalid.rs", "{{{"); - - // This should fail during syntax parsing, not file reading - let result = collect_metadata(temp_dir.path(), folder_name, &[]); - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("syntax error")); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_strip_prefix_succeeds_in_normal_case() { - // Test line 49-58: strip_prefix succeeds in the normal case - // - // DEFENSIVE CODE ANALYSIS (line 49-58): - // The strip_prefix error path is nearly impossible to trigger in practice because: - // 1. collect_files() returns paths by walking folder_path - // 2. All returned files are guaranteed to be under folder_path - // 3. Therefore, strip_prefix(folder_path) should always succeed - // - // The error path is defensive programming that would only trigger if: - // - Path normalization differences existed between collect_files and strip_prefix - // - Or if folder_path contained symlinks with different absolute paths - // - Or if the filesystem changed between collect_files and this loop - // - // This test verifies the normal case works correctly. - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create a subdirectory - let sub_dir = temp_dir.path().join("routes"); - std::fs::create_dir_all(&sub_dir).expect("Failed to create subdirectory"); - - // Create a file in the subdirectory - create_temp_file( - &temp_dir, - "routes/valid.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - // Collect metadata from the subdirectory - let (metadata, _file_asts) = collect_metadata(&sub_dir, folder_name, &[]).unwrap(); - - // Should collect the route (strip_prefix succeeds in normal cases) - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.function_name, "get_users"); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_struct_without_derive() { - // Test line 81: attr.path().is_ident("derive") returns false - // Struct with non-derive attributes should not be collected - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "user.rs", - r" -pub struct User { - pub id: i32, - pub name: String, -} -", - ); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - // Struct without Schema derive should not be collected - assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_struct_with_other_derive() { - // Test line 81: struct with other derive attributes (not Schema) - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "user.rs", - r" -#[derive(Debug, Clone)] -pub struct User { - pub id: i32, - pub name: String, -} -", - ); - - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - - // Struct with only Debug/Clone derive (no Schema) should not be collected - assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_fast_path_with_route_storage() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create a .rs file that the fast path will match against - let file_path = create_temp_file( - &temp_dir, - "users.rs", - r#" -pub async fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let file_path_str = file_path.display().to_string(); - - // Create StoredRouteInfo entries that match this file - let route_storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: Some(vec!["users".to_string()]), - description: Some("Get all users".to_string()), - fn_item_str: "pub async fn get_users() -> String { \"users\".to_string() }".to_string(), - file_path: Some(file_path_str.clone()), - }]; - - let (metadata, file_asts) = - collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); - - // Fast path should produce route metadata - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.function_name, "get_users"); - assert_eq!(route.method, "get"); - assert_eq!(route.tags, Some(vec!["users".to_string()])); - assert_eq!(route.description, Some("Get all users".to_string())); - assert_eq!(route.module_path, "routes::users"); - - // Fast path should NOT insert file ASTs (no parsing needed) - assert!( - file_asts.is_empty(), - "Fast path should not populate file_asts" - ); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_fast_path_with_custom_path() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let file_path = create_temp_file( - &temp_dir, - "users.rs", - r#" -pub async fn get_user() -> String { - "user".to_string() -} -"#, - ); - - let file_path_str = file_path.display().to_string(); - - let route_storage = vec![StoredRouteInfo { - fn_name: "get_user".to_string(), - method: Some("get".to_string()), - custom_path: Some("/{id}".to_string()), - error_status: Some(vec![404]), - tags: None, - description: None, - fn_item_str: "pub async fn get_user(id: i32) -> String { \"user\".to_string() }" - .to_string(), - file_path: Some(file_path_str.clone()), - }]; - - let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.path, "/users/{id}"); - assert!(route.error_status.is_some()); - assert_eq!(route.error_status.as_ref().unwrap(), &vec![404]); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_fast_path_empty_folder_name() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = ""; - - let file_path = create_temp_file( - &temp_dir, - "users.rs", - r#" -pub async fn list_users() -> String { - "list".to_string() -} -"#, - ); - - let file_path_str = file_path.display().to_string(); - - let route_storage = vec![StoredRouteInfo { - fn_name: "list_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: None, - description: None, - fn_item_str: "pub async fn list_users() -> String { \"list\".to_string() }".to_string(), - file_path: Some(file_path_str), - }]; - - let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - // With empty folder_name, module_path should be just segments (no prefix) - assert_eq!(route.module_path, "users"); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_fast_path_doc_comment_extraction() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let file_path = create_temp_file(&temp_dir, "items.rs", "// placeholder\n"); - - let file_path_str = file_path.display().to_string(); - - // fn_item_str includes a doc comment, description is None - // so the fast path should extract the doc comment - let route_storage = vec![StoredRouteInfo { - fn_name: "get_items".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: None, - description: None, // No explicit description -> should extract from doc comment - fn_item_str: - "/// List all items\npub async fn get_items() -> String { \"items\".to_string() }" - .to_string(), - file_path: Some(file_path_str), - }]; - - let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - // Description should be extracted from the doc comment in fn_item_str - assert_eq!(route.description, Some("List all items".to_string())); - - drop(temp_dir); - } - - #[test] - fn test_collect_file_fingerprints_skips_non_rs_files() { - // Exercises line 121: non-.rs files should be skipped - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create both .rs and non-.rs files - create_temp_file(&temp_dir, "valid.rs", "pub fn hello() {}"); - create_temp_file(&temp_dir, "readme.txt", "This is a readme"); - create_temp_file(&temp_dir, "data.json", "{}"); - create_temp_file(&temp_dir, "script.py", "print('hello')"); - - let fingerprints = collect_file_fingerprints(temp_dir.path()).unwrap(); - - // Only .rs files should be in fingerprints - assert_eq!( - fingerprints.len(), - 1, - "Only .rs files should be fingerprinted" - ); - let keys: Vec<&String> = fingerprints.keys().collect(); - assert!( - keys[0].ends_with("valid.rs"), - "The only fingerprinted file should be valid.rs" - ); - - drop(temp_dir); - } -} +mod tests; diff --git a/crates/vespera_macro/src/collector/path_scan.rs b/crates/vespera_macro/src/collector/path_scan.rs new file mode 100644 index 00000000..d5bdb0a8 --- /dev/null +++ b/crates/vespera_macro/src/collector/path_scan.rs @@ -0,0 +1,461 @@ +//! Route-folder scanning and the path-normalization key that makes +//! `#[route]`'s cwd-relative span paths comparable with the +//! collector's absolute walk paths (the fast-path match). + +use std::collections::HashMap; +use std::path::Path; + +use crate::error::{MacroResult, err_call_site}; + +pub use crate::file_utils::normalize_path_key; + +/// Single directory walk returning `(path, mtime)` pairs — the shared +/// scan that both cache fingerprinting and route collection consume. +pub fn scan_route_folder(folder_path: &Path) -> MacroResult> { + crate::file_utils::collect_files_with_mtimes(folder_path).map_err(|e| { + err_call_site(format!( + "vespera! macro: failed to scan route folder '{}': {}. Verify the folder exists and is readable.", + folder_path.display(), + e + )) + }) +} + +/// Build the cache fingerprint map (`.rs` files only) from a scan. +pub fn fingerprints_from_scan(scanned: &[(std::path::PathBuf, u64)]) -> HashMap { + scanned + .iter() + .filter(|(file, _)| file.extension().is_some_and(|e| e == "rs")) + .map(|(file, mtime)| (file.display().to_string(), *mtime)) + .collect() +} + +#[cfg(test)] +mod tests { + use std::fs; + + use rstest::rstest; + use tempfile::TempDir; + + use super::*; + use crate::collector::collect_metadata; + use crate::route_impl::StoredRouteInfo; + + fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = dir.path().join(filename); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path + } + + // + // The fast path matches `#[route]`'s `Span::local_file()` strings + // (cwd-relative) against the collector's absolute walk paths. + // Before normalization existed the keys NEVER matched and the + // fast path was silently dead — every route file was re-parsed on + // every cache miss with zero test failures. These tests pin the + // matching semantics so a regression is loud. + + #[rstest] + // Relative path resolves against cwd → equals the absolute form. + #[case("src/routes/users.rs", "/work/src/routes/users.rs", "/work")] + // Separator style must not matter. + #[case("src\\routes\\users.rs", "/work/src/routes/users.rs", "/work")] + // `.` and `..` components fold on either side. + #[case( + "src/./routes/../routes/users.rs", + "/work/src/routes/users.rs", + "/work" + )] + #[case("src/routes/users.rs", "/work/extra/../src/routes/users.rs", "/work")] + fn normalize_path_key_matches_equivalent_paths( + #[case] stored: &str, + #[case] walked: &str, + #[case] cwd: &str, + ) { + let cwd = Path::new(cwd); + assert_eq!( + normalize_path_key(stored, cwd), + normalize_path_key(walked, cwd), + "stored={stored:?} and walked={walked:?} must produce the same key" + ); + } + + #[test] + fn normalize_path_key_distinguishes_different_files() { + let cwd = Path::new("/work"); + assert_ne!( + normalize_path_key("src/routes/users.rs", cwd), + normalize_path_key("src/routes/posts.rs", cwd), + ); + } + + #[cfg(windows)] + #[test] + fn normalize_path_key_windows_verbatim_prefix_and_case() { + let cwd = Path::new("C:\\work"); + // `fs::canonicalize` output style (\\?\ verbatim prefix) must + // match plain absolute paths, and drive/file case must fold. + assert_eq!( + normalize_path_key("\\\\?\\C:\\Work\\Src\\Users.RS", cwd), + normalize_path_key("c:/work/src/users.rs", cwd), + ); + } + + /// END-TO-END lock for the fast-path activation bug: storage + /// carries a **cwd-relative** path (exactly what + /// `Span::local_file()` yields) while the collector walks an + /// absolute folder. The route file is deliberately INVALID Rust — + /// the slow path would fail with a parse error, so a successful + /// collect proves the fast path matched without parsing. + #[test] + fn fast_path_matches_cwd_relative_storage_paths_without_parsing() { + // cargo runs tests with cwd = this crate's manifest dir, so a + // path under the workspace `target/` dir has a stable relative + // form mirroring rustc's span paths. + let unique = format!("vespera_fastpath_lock_{}", std::process::id()); + let abs_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target") + .join(&unique); + fs::create_dir_all(&abs_dir).expect("create test route dir"); + fs::write( + abs_dir.join("users.rs"), + "this is deliberately not rust {{{", + ) + .expect("write route file"); + + let relative_stored_path = format!("../../target/{unique}/users.rs"); + let route_storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: None, + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: "async fn get_users() -> String".to_string(), + file_path: Some(relative_stored_path), + }]; + + let result = collect_metadata(&abs_dir, "routes", &route_storage); + fs::remove_dir_all(&abs_dir).ok(); + + let (metadata, file_asts) = result.expect( + "fast path must match the relative storage path WITHOUT parsing — \ + a parse error here means key normalization regressed and the \ + slow path ran against the invalid file", + ); + assert_eq!(metadata.routes.len(), 1, "route must come from storage"); + assert!( + file_asts.is_empty(), + "fast path must not parse any file ASTs" + ); + } + + /// Lock for the method-default bug: `#[route]` without a method + /// stores `method: None`; the fast path must resolve it to "get" + /// like the slow path does. The original `unwrap_or_default()` + /// produced "" — silently dropping such routes from the OpenAPI + /// doc AND the generated router. + #[test] + fn fast_path_defaults_missing_method_to_get() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let file_path = create_temp_file(&temp_dir, "items.rs", "// placeholder\n"); + + let route_storage = vec![StoredRouteInfo { + fn_name: "list_items".to_string(), + method: None, // bare `#[route]` / `#[route(path = ...)]` + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: "async fn list_items() -> String".to_string(), + file_path: Some(file_path.display().to_string()), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), "routes", &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + assert_eq!( + metadata.routes[0].method, "get", + "missing method must default to GET — \"\" silently drops the route" + ); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_with_route_storage() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create a .rs file that the fast path will match against + let file_path = create_temp_file( + &temp_dir, + "users.rs", + r#" + pub async fn get_users() -> String { + "users".to_string() + } + "#, + ); + + let file_path_str = file_path.display().to_string(); + + // Create StoredRouteInfo entries that match this file + let route_storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: Some(vec!["users".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: Some("Get all users".to_string()), + fn_sig_str: "async fn get_users() -> String".to_string(), + file_path: Some(file_path_str.clone()), + }]; + + let (metadata, file_asts) = + collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + // Fast path should produce route metadata + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.function_name, "get_users"); + assert_eq!(route.method, "get"); + assert_eq!(route.tags, Some(vec!["users".to_string()])); + assert_eq!(route.description, Some("Get all users".to_string())); + assert_eq!(route.module_path, "routes::users"); + + // Fast path should NOT insert file ASTs (no parsing needed) + assert!( + file_asts.is_empty(), + "Fast path should not populate file_asts" + ); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_with_custom_path() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let file_path = create_temp_file( + &temp_dir, + "users.rs", + r#" + pub async fn get_user() -> String { + "user".to_string() + } + "#, + ); + + let file_path_str = file_path.display().to_string(); + + let route_storage = vec![StoredRouteInfo { + fn_name: "get_user".to_string(), + method: Some("get".to_string()), + custom_path: Some("/{id}".to_string()), + error_status: Some(vec![404]), + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: "async fn get_user(id: i32) -> String".to_string(), + file_path: Some(file_path_str.clone()), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.path, "/users/{id}"); + assert!(route.error_status.is_some()); + assert_eq!(route.error_status.as_ref().unwrap(), &vec![404]); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_empty_folder_name() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = ""; + + let file_path = create_temp_file( + &temp_dir, + "users.rs", + r#" + pub async fn list_users() -> String { + "list".to_string() + } + "#, + ); + + let file_path_str = file_path.display().to_string(); + + let route_storage = vec![StoredRouteInfo { + fn_name: "list_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: "async fn list_users() -> String".to_string(), + file_path: Some(file_path_str), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + // With empty folder_name, module_path should be just segments (no prefix) + assert_eq!(route.module_path, "users"); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_uses_stored_description() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let file_path = create_temp_file(&temp_dir, "items.rs", "// placeholder\n"); + + let file_path_str = file_path.display().to_string(); + + // `#[route]` resolves the description (explicit attribute OR doc + // comment) at expansion time — see `process_route_attribute`. + // The collector fast path must pass it through verbatim WITHOUT + // re-parsing `fn_sig_str`. + let route_storage = vec![StoredRouteInfo { + fn_name: "get_items".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: Some("List all items".to_string()), + fn_sig_str: + "/// List all items\npub async fn get_items() -> String { \"items\".to_string() }" + .to_string(), + file_path: Some(file_path_str.clone()), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + assert_eq!( + metadata.routes[0].description, + Some("List all items".to_string()) + ); + + // A storage entry with no description stays None — the fast path + // does NOT re-extract from fn_sig_str (expansion already did). + let route_storage_none = vec![StoredRouteInfo { + fn_name: "get_items".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: "async fn get_items() -> String".to_string(), + file_path: Some(file_path_str), + }]; + let (metadata, _) = + collect_metadata(temp_dir.path(), folder_name, &route_storage_none).unwrap(); + assert_eq!(metadata.routes[0].description, None); + + drop(temp_dir); + } + + #[test] + fn test_collect_file_fingerprints_skips_non_rs_files() { + // Exercises line 121: non-.rs files should be skipped + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create both .rs and non-.rs files + create_temp_file(&temp_dir, "valid.rs", "pub fn hello() {}"); + create_temp_file(&temp_dir, "readme.txt", "This is a readme"); + create_temp_file(&temp_dir, "data.json", "{}"); + create_temp_file(&temp_dir, "script.py", "print('hello')"); + + let fingerprints = fingerprints_from_scan(&scan_route_folder(temp_dir.path()).unwrap()); + + // Only .rs files should be in fingerprints + assert_eq!( + fingerprints.len(), + 1, + "Only .rs files should be fingerprinted" + ); + let keys: Vec<&String> = fingerprints.keys().collect(); + assert!( + keys[0].ends_with("valid.rs"), + "The only fingerprinted file should be valid.rs" + ); + + drop(temp_dir); + } +} diff --git a/crates/vespera_macro/src/collector/tests.rs b/crates/vespera_macro/src/collector/tests.rs new file mode 100644 index 00000000..f319df0d --- /dev/null +++ b/crates/vespera_macro/src/collector/tests.rs @@ -0,0 +1,665 @@ +use std::fs; + +use rstest::rstest; +use tempfile::TempDir; + +use super::*; + +fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = dir.path().join(filename); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path +} + +#[test] +fn test_collect_metadata_empty_folder() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert!(metadata.routes.is_empty()); + assert!(metadata.structs.is_empty()); +} + +#[rstest] +#[case::single_get_route( + "routes", + vec![( + "users.rs", + r#" + #[route(get)] + pub fn get_users() -> String { + "users".to_string() + } + "#, + )], + "get", + "/users", + "get_users", + "routes::users", + )] +#[case::single_post_route( + "routes", + vec![( + "create_user.rs", + r#" + #[route(post)] + pub fn create_user() -> String { + "created".to_string() + } + "#, + )], + "post", + "/create-user", + "create_user", + "routes::create_user", + )] +#[case::route_with_custom_path( + "routes", + vec![( + "users.rs", + r#" + #[route(get, path = "/api/users")] + pub fn get_users() -> String { + "users".to_string() + } + "#, + )], + "get", + "/users/api/users", + "get_users", + "routes::users", + )] +#[case::route_with_error_status( + "routes", + vec![( + "users.rs", + r#" + #[route(get, error_status = [400, 404])] + pub fn get_users() -> String { + "users".to_string() + } + "#, + )], + "get", + "/users", + "get_users", + "routes::users", + )] +#[case::nested_module( + "routes", + vec![( + "api/users.rs", + r#" + #[route(get)] + pub fn get_users() -> String { + "users".to_string() + } + "#, + )], + "get", + "/api/users", + "get_users", + "routes::api::users", + )] +#[case::deeply_nested_module( + "routes", + vec![( + "api/v1/users.rs", + r#" + #[route(get)] + pub fn get_users() -> String { + "users".to_string() + } + "#, + )], + "get", + "/api/v1/users", + "get_users", + "routes::api::v1::users", + )] +fn test_collect_metadata_routes( + #[case] folder_name: &str, + #[case] files: Vec<(&str, &str)>, + #[case] expected_method: &str, + #[case] expected_path: &str, + #[case] expected_function_name: &str, + #[case] expected_module_path: &str, +) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + for (filename, content) in &files { + create_temp_file(&temp_dir, filename, content); + } + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + let route = &metadata.routes[0]; + assert_eq!(route.method, expected_method); + assert_eq!(route.path, expected_path); + assert_eq!(route.function_name, expected_function_name); + assert_eq!(route.module_path, expected_module_path); + if let Some((first_filename, _)) = files.first() { + assert!( + route + .file_path + .contains(first_filename.split('/').next().unwrap()) + ); + } +} + +#[test] +fn test_collect_metadata_single_struct() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 0); +} + +#[test] +fn test_collect_metadata_struct_without_schema() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "user.rs", + r" + pub struct User { + pub id: i32, + pub name: String, + } + ", + ); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 0); + assert_eq!(metadata.structs.len(), 0); +} + +#[test] +fn test_collect_metadata_route_and_struct() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "user.rs", + r#" + use vespera::Schema; + + #[derive(Schema)] + pub struct User { + pub id: i32, + pub name: String, + } + + #[route(get)] + pub fn get_user() -> User { + User { id: 1, name: "Alice".to_string() } + } + "#, + ); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + + let route = &metadata.routes[0]; + assert_eq!(route.function_name, "get_user"); +} + +#[test] +fn test_collect_metadata_multiple_routes() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" + #[route(get)] + pub fn get_users() -> String { + "users".to_string() + } + + #[route(post)] + pub fn create_users() -> String { + "created".to_string() + } + "#, + ); + + create_temp_file( + &temp_dir, + "posts.rs", + r#" + #[route(get)] + pub fn get_posts() -> String { + "posts".to_string() + } + "#, + ); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 3); + assert_eq!(metadata.structs.len(), 0); + + let function_names: Vec<&str> = metadata + .routes + .iter() + .map(|r| r.function_name.as_str()) + .collect(); + assert!(function_names.contains(&"get_users")); + assert!(function_names.contains(&"create_users")); + assert!(function_names.contains(&"get_posts")); +} + +#[test] +fn test_collect_metadata_multiple_structs() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "user.rs", + r" + use vespera::Schema; + + #[derive(Schema)] + pub struct User { + pub id: i32, + pub name: String, + } + ", + ); + + create_temp_file( + &temp_dir, + "post.rs", + r" + use vespera::Schema; + + #[derive(Schema)] + pub struct Post { + pub id: i32, + pub title: String, + } + ", + ); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 0); +} + +#[test] +fn test_collect_metadata_with_mod_rs() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "mod.rs", + r#" + #[route(get)] + pub fn index() -> String { + "index".to_string() + } + "#, + ); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.function_name, "index"); + assert_eq!(route.path, "/"); + assert_eq!(route.module_path, "routes::"); +} + +#[test] +fn test_collect_metadata_empty_folder_name() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = ""; + + create_temp_file( + &temp_dir, + "users.rs", + r#" + #[route(get)] + pub fn get_users() -> String { + "users".to_string() + } + "#, + ); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.module_path, "users"); +} + +#[test] +fn test_collect_metadata_ignores_non_rs_files() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" + #[route(get)] + pub fn get_users() -> String { + "users".to_string() + } + "#, + ); + + create_temp_file(&temp_dir, "config.txt", "some config content"); + + create_temp_file(&temp_dir, "readme.md", "# Readme"); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + assert_eq!(metadata.structs.len(), 0); +} + +#[test] +fn test_collect_metadata_ignores_invalid_syntax() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "valid.rs", + r#" + #[route(get)] + pub fn get_users() -> String { + "users".to_string() + } + "#, + ); + + create_temp_file(&temp_dir, "invalid.rs", "invalid rust syntax {"); + + let metadata = collect_metadata(temp_dir.path(), folder_name, &[]).map(|(m, _)| m); + + assert!(metadata.is_err()); +} + +#[test] +fn test_collect_metadata_error_status() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" + #[route(get, error_status = [400, 404, 500])] + pub fn get_users() -> String { + "users".to_string() + } + "#, + ); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.method, "get"); + assert!(route.error_status.is_some()); + let error_status = route.error_status.as_ref().unwrap(); + assert_eq!(error_status.len(), 3); + assert!(error_status.contains(&400)); + assert!(error_status.contains(&404)); + assert!(error_status.contains(&500)); +} + +#[test] +fn test_collect_metadata_all_http_methods() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "routes.rs", + r#" + #[route(get)] + pub fn get_handler() -> String { "get".to_string() } + + #[route(post)] + pub fn post_handler() -> String { "post".to_string() } + + #[route(put)] + pub fn put_handler() -> String { "put".to_string() } + + #[route(patch)] + pub fn patch_handler() -> String { "patch".to_string() } + + #[route(delete)] + pub fn delete_handler() -> String { "delete".to_string() } + + #[route(head)] + pub fn head_handler() -> String { "head".to_string() } + + #[route(options)] + pub fn options_handler() -> String { "options".to_string() } + "#, + ); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 7); + + let methods: Vec<&str> = metadata.routes.iter().map(|r| r.method.as_str()).collect(); + assert!(methods.contains(&"get")); + assert!(methods.contains(&"post")); + assert!(methods.contains(&"put")); + assert!(methods.contains(&"patch")); + assert!(methods.contains(&"delete")); + assert!(methods.contains(&"head")); + assert!(methods.contains(&"options")); +} + +#[test] +fn test_collect_metadata_collect_files_error() { + let non_existent_path = std::path::Path::new("/nonexistent/path/that/does/not/exist"); + let folder_name = "routes"; + + let result = collect_metadata(non_existent_path, folder_name, &[]); + + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("failed to scan route folder")); +} + +#[test] +#[cfg(unix)] +fn test_collect_metadata_file_read_error_permissions() { + // On Unix, we can create a file and then remove read permissions + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let file_path = temp_dir.path().join("unreadable.rs"); + fs::write( + &file_path, + r#" + #[route(get)] + pub fn get_users() -> String { + "users".to_string() + } + "#, + ) + .expect("Failed to write temp file"); + + let permissions = fs::Permissions::from_mode(0o000); + fs::set_permissions(&file_path, permissions).expect("Failed to set permissions"); + + // Verify permissions actually took effect (they don't on WSL with Windows filesystem) + // If we can still read the file, skip this test + if fs::read_to_string(&file_path).is_ok() { + // Restore permissions for cleanup + let permissions = fs::Permissions::from_mode(0o644); + fs::set_permissions(&file_path, permissions).ok(); + eprintln!( + "Skipping test: filesystem doesn't respect Unix permissions (likely WSL with NTFS)" + ); + return; + } + + let result = collect_metadata(temp_dir.path(), folder_name, &[]); + + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("failed to read route file")); + + let permissions = fs::Permissions::from_mode(0o644); + fs::set_permissions(&file_path, permissions).ok(); +} + +#[test] +#[cfg(windows)] +fn test_collect_metadata_file_read_error_documentation_windows() { + // Test line 31-37: Documentation of file read error handling on Windows + // + // On Windows, file permission errors are harder to reliably trigger in tests + // because standard read/write operations on temp files typically succeed. + // The error path at line 31-37 is exercised by edge cases: + // 1. Files deleted between collect_files scan and read attempt + // 2. Network drive disconnections + // 3. Permission changes during execution + // + // These are difficult to simulate reliably in automated tests. + // The error handling code itself is straightforward: + // - std::fs::read_to_string() returns an io::Error + // - map_err() wraps it with context message + // - Caller receives "failed to read route file" error + // + // This is tested indirectly via test_collect_metadata_file_read_error_via_invalid_syntax + // which verifies error propagation works correctly. + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "readable.rs", + r#" + #[route(get)] + pub fn get() -> String { "ok".to_string() } + "#, + ); + + let result = collect_metadata(temp_dir.path(), folder_name, &[]); + assert!(result.is_ok()); +} + +#[test] +fn test_collect_metadata_file_read_error_via_invalid_syntax() { + // While we can't easily trigger read errors on all platforms, + // we verify the code path by ensuring errors are properly propagated + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file(&temp_dir, "invalid.rs", "{{{"); + + let result = collect_metadata(temp_dir.path(), folder_name, &[]); + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("syntax error")); +} + +#[test] +fn test_collect_metadata_strip_prefix_succeeds_in_normal_case() { + // DEFENSIVE CODE ANALYSIS (line 49-58): + // The strip_prefix error path is nearly impossible to trigger in practice because: + // 1. collect_files() returns paths by walking folder_path + // 2. All returned files are guaranteed to be under folder_path + // 3. Therefore, strip_prefix(folder_path) should always succeed + // + // The error path is defensive programming that would only trigger if: + // - Path normalization differences existed between collect_files and strip_prefix + // - Or if folder_path contained symlinks with different absolute paths + // - Or if the filesystem changed between collect_files and this loop + // + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let sub_dir = temp_dir.path().join("routes"); + std::fs::create_dir_all(&sub_dir).expect("Failed to create subdirectory"); + + create_temp_file( + &temp_dir, + "routes/valid.rs", + r#" + #[route(get)] + pub fn get_users() -> String { + "users".to_string() + } + "#, + ); + + let (metadata, _file_asts) = collect_metadata(&sub_dir, folder_name, &[]).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.function_name, "get_users"); +} + +#[test] +fn test_collect_metadata_struct_without_derive() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "user.rs", + r" + pub struct User { + pub id: i32, + pub name: String, + } + ", + ); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.structs.len(), 0); +} + +#[test] +fn test_collect_metadata_struct_with_other_derive() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "user.rs", + r" + #[derive(Debug, Clone)] + pub struct User { + pub id: i32, + pub name: String, + } + ", + ); + + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + + assert_eq!(metadata.structs.len(), 0); +} diff --git a/crates/vespera_macro/src/cron_impl.rs b/crates/vespera_macro/src/cron_impl.rs index 0bee79dd..68ee9970 100644 --- a/crates/vespera_macro/src/cron_impl.rs +++ b/crates/vespera_macro/src/cron_impl.rs @@ -20,6 +20,7 @@ //! } //! ``` +use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; /// Metadata stored by `#[cron]` for later consumption by `vespera!()`. @@ -36,10 +37,60 @@ pub struct StoredCronInfo { pub file_path: Option, } -/// Global storage for cron metadata collected by `#[cron]` attribute macros. -/// Read by `vespera!()` to build the cron scheduler. -pub static CRON_STORAGE: LazyLock>> = - LazyLock::new(|| Mutex::new(Vec::new())); +/// Per-crate storage for cron metadata collected by `#[cron]` attribute +/// macros, read by `vespera!()` to build the cron scheduler. +/// +/// Keyed by [`crate::schema_impl::current_crate_key`] so a long-lived +/// rust-analyzer proc-macro server (one process, many crates) never schedules +/// crate A's cron jobs into crate B. See +/// [`SCHEMA_STORAGE`](crate::schema_impl::SCHEMA_STORAGE) for the rationale. +pub static CRON_STORAGE: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn same_cron_source(left: &StoredCronInfo, right: &StoredCronInfo) -> bool { + left.fn_name == right.fn_name + && left + .file_path + .as_deref() + .unwrap_or_default() + .replace('\\', "/") + == right + .file_path + .as_deref() + .unwrap_or_default() + .replace('\\', "/") +} + +/// Replace-insert a `#[cron]` metadata entry in the current crate's bucket. +pub fn register_cron(info: StoredCronInfo) { + let mut guard = CRON_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let bucket = guard + .entry(crate::schema_impl::current_crate_key()) + .or_default(); + if let Some(existing) = bucket + .iter_mut() + .find(|existing| same_cron_source(existing, &info)) + { + *existing = info; + } else { + bucket.push(info); + } +} + +/// Snapshot (clone) of the current crate's registered cron jobs, so the +/// scheduler in `vespera!` never picks up another crate's jobs in a shared +/// proc-macro server. +#[must_use] +pub fn current_crate_crons() -> Vec { + CRON_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&crate::schema_impl::current_crate_key()) + .cloned() + .unwrap_or_default() +} /// Validate cron function - must be pub, async, and take no parameters. pub fn validate_cron_fn(item_fn: &syn::ItemFn) -> Result<(), syn::Error> { @@ -70,6 +121,12 @@ pub fn process_cron_attribute( item: proc_macro2::TokenStream, ) -> syn::Result { let expression: syn::LitStr = syn::parse2(attr).map_err(|_| syn::Error::new(proc_macro2::Span::call_site(), "#[cron] attribute: expected a cron expression string. Example: #[cron(\"0 */5 * * * *\")]"))?; + // Compile-time cron-syntax validation (gated by the `cron` feature, enabled + // transitively by `vespera`'s `cron` feature). A malformed expression is a + // span-attached compile error here instead of a `JobScheduler` panic at app + // startup (see `router_codegen::generator`'s `Job::new_async(...).expect`). + #[cfg(feature = "cron")] + validate_cron_expression(&expression)?; let item_fn: syn::ItemFn = syn::parse2(item.clone()).map_err(|e| syn::Error::new(e.span(), "#[cron] attribute: can only be applied to functions, not other items. Move or remove the attribute."))?; validate_cron_fn(&item_fn)?; @@ -80,14 +137,43 @@ pub fn process_cron_attribute( .local_file() .map(|p| p.display().to_string()), }; - CRON_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(stored); + register_cron(stored); Ok(item) } +/// Validate a cron expression at **compile time** using the SAME parser the +/// runtime uses, so a malformed expression is a clean span-attached compile +/// error instead of a `JobScheduler` panic at application startup. +/// +/// Parity basis: `tokio-cron-scheduler`'s `Job::new_async` parses the schedule +/// with `croner`'s `CronParser::builder().seconds(Seconds::Required).build()`. +/// `vespera` enables `tokio-cron-scheduler` **without** its `english` feature, +/// so the runtime `schedule_to_cron` step is an identity passthrough and the +/// only parse is the 6-field (seconds-required) croner parse replicated here. +/// The `croner` major version is pinned (in `Cargo.toml`) to the one +/// `tokio-cron-scheduler` resolves, so compile-time acceptance exactly matches +/// runtime acceptance. +#[cfg(feature = "cron")] +fn validate_cron_expression(expression: &syn::LitStr) -> syn::Result<()> { + use croner::parser::{CronParser, Seconds}; + let expr = expression.value(); + CronParser::builder() + .seconds(Seconds::Required) + .build() + .parse(&expr) + .map_err(|e| { + syn::Error::new_spanned( + expression, + format!( + "#[cron] invalid cron expression `{expr}`: {e}. Expected a 6-field \ + expression `sec min hour day month weekday`, e.g. \"0 */5 * * * *\"." + ), + ) + })?; + Ok(()) +} + #[cfg(test)] mod tests { use quote::quote; @@ -240,4 +326,74 @@ mod tests { let err = result.unwrap_err().to_string(); assert!(err.contains("must take no parameters")); } + + #[test] + fn test_register_cron_replaces_same_file_and_function() { + let file_path = Some("/tmp/vespera/tasks/replaced.rs".to_string()); + let fn_name = "__test_replace_cron".to_string(); + register_cron(StoredCronInfo { + fn_name: fn_name.clone(), + expression: "0 */5 * * * *".to_string(), + file_path: file_path.clone(), + }); + register_cron(StoredCronInfo { + fn_name: fn_name.clone(), + expression: "0 */10 * * * *".to_string(), + file_path, + }); + + let matches: Vec<_> = current_crate_crons() + .into_iter() + .filter(|entry| entry.fn_name == fn_name) + .collect(); + assert_eq!(matches.len(), 1, "same source cron should replace"); + assert_eq!(matches[0].expression, "0 */10 * * * *"); + } + + // ===== Compile-time cron-syntax validation (gated by the `cron` feature) ===== + + #[cfg(feature = "cron")] + #[test] + fn test_process_cron_attribute_valid_cron_syntax_passes() { + for expr in [ + quote!("0 */5 * * * *"), + quote!("1/10 * * * * *"), + quote!("0 0 0 * * *"), + quote!("0 30 9 * * Mon-Fri"), + ] { + let item = quote!( + pub async fn my_job() {} + ); + assert!( + process_cron_attribute(expr.clone(), item).is_ok(), + "expected valid cron `{expr}` to pass" + ); + } + } + + #[cfg(feature = "cron")] + #[test] + fn test_process_cron_attribute_invalid_cron_syntax_is_compile_error() { + // Each is rejected at compile time (was a runtime `JobScheduler` panic): + // 1-field, 5-field (missing seconds), out-of-range minute, garbage token. + for bad in [ + quote!("invalid"), + quote!("* * * * *"), + quote!("0 99 * * * *"), + quote!("not a cron at all"), + ] { + let item = quote!( + pub async fn my_job() {} + ); + let result = process_cron_attribute(bad.clone(), item); + assert!(result.is_err(), "expected invalid cron `{bad}` to error"); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid cron expression"), + "expected `invalid cron expression` message for `{bad}`" + ); + } + } } diff --git a/crates/vespera_macro/src/file_utils.rs b/crates/vespera_macro/src/file_utils.rs index b5981249..751de690 100644 --- a/crates/vespera_macro/src/file_utils.rs +++ b/crates/vespera_macro/src/file_utils.rs @@ -3,26 +3,160 @@ use std::{ path::{Path, PathBuf}, }; +/// Render a path for compile-time strings and diagnostics with `/` separators. +pub fn normalize_display_path(path: impl AsRef) -> String { + path.as_ref().display().to_string().replace('\\', "/") +} + +/// Normalize a path string into a comparison key **without touching the filesystem**. +/// +/// Relative paths are absolutized against `cwd`, `.`/`..` components are folded, +/// separators normalize to `/`, the Windows `\\?\` verbatim prefix is stripped, +/// and (Windows only) the drive letter case is folded. +pub fn normalize_path_key(path: &str, cwd: &Path) -> String { + use std::path::Component; + + let p = Path::new(path); + let abs = if p.is_absolute() { + p.to_path_buf() + } else { + cwd.join(p) + }; + let mut folded = PathBuf::new(); + for comp in abs.components() { + match comp { + Component::CurDir => {} + Component::ParentDir => { + folded.pop(); + } + other => folded.push(other), + } + } + let mut key = normalize_display_path(&folded); + if let Some(stripped) = key.strip_prefix("//?/") { + key = stripped.to_owned(); + } + if cfg!(windows) { + key.make_ascii_lowercase(); + } + key +} + +/// Render a path for use in `include_str!` literals. +pub fn path_to_include_str_literal(path: impl AsRef) -> String { + normalize_display_path(path) +} + +// `#[cfg(test)]`: the only caller left is the test-only `collector::collect_metadata` +// (plus this module's own tests); production scanning goes through +// `collect_files_with_mtimes`, so the path-only wrapper never ships. +#[cfg(test)] pub fn collect_files(folder_path: &Path) -> io::Result> { + Ok(collect_files_with_mtimes(folder_path)? + .into_iter() + .map(|(path, _)| path) + .collect()) +} + +/// Recursively collect files together with their mtime fingerprints +/// (nanoseconds since `UNIX_EPOCH`; `0` when unavailable). +/// +/// One walk serves both route discovery and cache fingerprinting — +/// previously the folder was walked twice and every file paid an +/// extra `fs::metadata` syscall on top of the directory-entry data +/// the OS already returned. +pub fn collect_files_with_mtimes(folder_path: &Path) -> io::Result> { let mut files = Vec::new(); + collect_with_mtimes_into(folder_path, &mut files)?; + Ok(files) +} + +/// Compile-time cache fingerprint for a source file's modification time. +/// +/// Uses **nanosecond** resolution rather than whole seconds: two edits to +/// the same file within one wall-clock second — routine under fast +/// incremental rebuilds and long-lived rust-analyzer processes — still +/// yield distinct fingerprints, so a stale router / OpenAPI spec is never +/// served from the route cache. Returns `0` when the mtime is +/// unavailable. Truncating the u128 nanos-since-epoch to `u64` preserves +/// every sub-second bit (the value only exceeds `u64` past the year ~2554, +/// saturated to `u64::MAX`); the fingerprint is only ever compared for +/// equality, so the absolute units never matter. +fn mtime_fingerprint(modified: Option) -> u64 { + modified.map_or(0, |t| { + let nanos = t + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + u64::try_from(nanos).unwrap_or(u64::MAX) + }) +} + +/// Mix a file's mtime fingerprint with its byte length into a single +/// equality-only cache fingerprint. +/// +/// mtime alone misses a content edit that PRESERVES the modification time — +/// a timestamp-preserving checkout, `cp -p`, or build-cache restore can +/// rewrite a route file's contents while leaving its mtime untouched, which +/// would otherwise serve a STALE generated router / OpenAPI spec from the +/// cache. Folding in `len()` catches every such edit that changes the file +/// size (the overwhelming majority), at ZERO extra compile-time cost: the +/// metadata is already stat'd for the mtime and no file contents are ever +/// hashed. The fingerprint is only ever compared for equality, so any stable +/// mix works; this one is strictly more sensitive than mtime alone. +fn combine_fingerprint(mtime: u64, len: u64) -> u64 { + mtime.rotate_left(1).wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ len.wrapping_mul(0xD1B5_4A32_D192_ED03) +} + +/// Compile-time cache fingerprint for a source file from its already-fetched +/// [`std::fs::Metadata`] — combines mtime ([`mtime_fingerprint`]) and size +/// ([`combine_fingerprint`]). Returns `0` when the metadata is unavailable +/// (same sentinel the previous mtime-only path used). +fn file_fingerprint(meta: Option<&std::fs::Metadata>) -> u64 { + meta.map_or(0, |m| { + combine_fingerprint(mtime_fingerprint(m.modified().ok()), m.len()) + }) +} + +fn collect_with_mtimes_into(folder_path: &Path, out: &mut Vec<(PathBuf, u64)>) -> io::Result<()> { for entry in std::fs::read_dir(folder_path)? { let entry = entry?; + let file_type = entry.file_type()?; let path = entry.path(); - if path.is_file() { - files.push(folder_path.join(path)); - } else if path.is_dir() { - files.extend(collect_files(&folder_path.join(&path))?); + if file_type.is_file() { + // Only `.rs` files feed route discovery and cache + // fingerprinting — both consumers (`collect_metadata_from_files` + // and `fingerprints_from_scan`) filter by extension — so skip + // the `metadata()` stat for every other file (fixtures, JSON, + // uploads, …). On Unix that is one `stat` saved per non-Rust + // file at compile time; the entry still keeps its place in the + // list with mtime `0` (never read for non-`.rs` paths). + let mtime = if path.extension().is_some_and(|e| e == "rs") { + file_fingerprint(entry.metadata().ok().as_ref()) + } else { + 0 + }; + out.push((path, mtime)); + } else if file_type.is_dir() { + collect_with_mtimes_into(&path, out)?; } } - Ok(files) + Ok(()) } pub fn file_to_segments(file: &Path, base_path: &Path) -> Vec { - let file_stem = file.strip_prefix(base_path).map_or_else( - |_| file.display().to_string(), - |file_stem| file_stem.display().to_string(), - ); - let file_stem = file_stem.replace(".rs", "").replace('\\', "/"); + let file_stem = file + .strip_prefix(base_path) + .map_or_else(|_| normalize_display_path(file), normalize_display_path); + // Strip ONLY a trailing `.rs` extension (not every `.rs` substring): a + // path component that legitimately contains `.rs` (e.g. a directory named + // `v1.rs`) must keep it, so `replace(".rs", "")` — which mangled every + // occurrence — is wrong. Normalize `\` → `/` afterwards. + let file_stem = file_stem + .strip_suffix(".rs") + .unwrap_or(&file_stem) + .replace('\\', "/"); let mut segments: Vec = file_stem .split('/') .filter(|s| !s.is_empty()) @@ -248,4 +382,48 @@ mod tests { temp_dir.close().expect("Failed to close temp dir"); } + + #[test] + fn mtime_fingerprint_distinguishes_subsecond_edits() { + use std::time::{Duration, UNIX_EPOCH}; + + // Two mtimes in the SAME wall-clock second, 1 ms apart (1 ms is + // safely above the 100 ns `SystemTime`/FILETIME resolution on + // Windows, so the delta is actually representable): the prior + // seconds-only fingerprint collapsed these to one value (the + // stale-cache bug); the nanosecond fingerprint MUST tell them apart + // so a same-second edit always invalidates the route cache. + let base = UNIX_EPOCH + Duration::new(1_700_000_000, 0); + let same_second_later = base + Duration::from_millis(1); + assert_ne!( + mtime_fingerprint(Some(base)), + mtime_fingerprint(Some(same_second_later)), + "same-second edits must produce distinct cache fingerprints" + ); + + // A whole-second difference is of course still distinguished. + let next_second = base + Duration::from_secs(1); + assert_ne!( + mtime_fingerprint(Some(base)), + mtime_fingerprint(Some(next_second)) + ); + + // Unavailable mtime collapses to 0 (unchanged contract). + assert_eq!(mtime_fingerprint(None), 0); + } + + #[test] + fn combine_fingerprint_is_sensitive_to_mtime_and_size() { + // Same mtime, DIFFERENT size — the timestamp-preserving content edit + // the size term is here to catch — must produce distinct fingerprints. + assert_ne!( + combine_fingerprint(42, 100), + combine_fingerprint(42, 101), + "same mtime + different size must differ (stale-cache guard)" + ); + // Different mtime, same size — still distinguished (mtime term). + assert_ne!(combine_fingerprint(42, 100), combine_fingerprint(43, 100)); + // Identical (mtime, size) — equal (a genuine cache hit). + assert_eq!(combine_fingerprint(42, 100), combine_fingerprint(42, 100)); + } } diff --git a/crates/vespera_macro/src/garde_emit.rs b/crates/vespera_macro/src/garde_emit.rs index 3b8d7de9..5fbd24af 100644 --- a/crates/vespera_macro/src/garde_emit.rs +++ b/crates/vespera_macro/src/garde_emit.rs @@ -35,10 +35,10 @@ use proc_macro2::Span; #[cfg(feature = "validation")] use quote::{format_ident, quote}; #[cfg(feature = "validation")] -use syn::{Data, Fields, GenericArgument, PathArguments, Type}; +use syn::{Data, Fields, Type}; #[cfg(feature = "validation")] -use crate::parser::schema::schema_attrs::{SchemaConstraints, extract_schema_constraints}; +use crate::parser::schema::schema_attrs::{SchemaConstraints, try_extract_schema_constraints}; /// Public entry point used by `process_derive_schema`. /// @@ -69,11 +69,15 @@ fn emit_impl(input: &DeriveInput) -> TokenStream { // Collect per-field constraints up-front so we can short-circuit // when nothing on the struct opts into validation. - let per_field: Vec<(&syn::Field, SchemaConstraints)> = fields_named + let per_field = fields_named .named .iter() - .map(|f| (f, extract_schema_constraints(&f.attrs))) - .collect(); + .map(|f| try_extract_schema_constraints(&f.attrs).map(|constraints| (f, constraints))) + .collect::>(); + let per_field: Vec<(&syn::Field, SchemaConstraints)> = match per_field { + Ok(per_field) => per_field, + Err(error) => return error.to_compile_error(), + }; if per_field.iter().all(|(_, c)| !c.has_runtime_rule()) { // No field requested a runtime rule — skip Validate emission. @@ -156,14 +160,16 @@ fn emit_field_block( } let field_name_str = field_ident.to_string(); - let numeric_kind = rust_numeric_kind(peel_option(field_ty).unwrap_or(field_ty)); + let numeric_kind = rust_numeric_kind( + crate::schema_macro::type_utils::option_inner(field_ty).unwrap_or(field_ty), + ); let rule_blocks = emit_rule_blocks(c, &field_name_str, numeric_kind.as_deref()); let dive_block = emit_dive_block(c); if rule_blocks.is_empty() && dive_block.is_empty() { return None; } - let block = if is_option_type(field_ty) { + let block = if crate::schema_macro::type_utils::is_option_type(field_ty) { // `field_ident` is `&Option` after the `let Self { .. } = self` destructure. // Match ergonomics make `inner` end up as `&T`. quote! { @@ -282,25 +288,66 @@ fn emit_rule_blocks( // ── Pattern (pattern = "..." → static LazyLock) ──────────── if let Some(pattern) = &c.pattern { - let static_ident = format_ident!("__VESPERA_PATTERN_{}", field_name.to_ascii_uppercase()); - blocks.push(quote! { - { - static #static_ident: ::std::sync::LazyLock< - ::vespera::__validation::garde::rules::pattern::regex::Regex, - > = ::std::sync::LazyLock::new(|| { - ::vespera::__validation::garde::rules::pattern::regex::Regex::new(#pattern) - .expect("regex literal validated at vespera::Schema derive time") - }); - if let ::std::result::Result::Err(__garde_error) = - (::vespera::__validation::garde::rules::pattern::apply)( - &*__garde_binding, - (&*#static_ident,), - ) + // Validate the user-supplied regex at MACRO-EXPANSION time with + // `regex-syntax` (the exact parser `regex` uses), so an invalid + // pattern becomes a COMPILE error naming the field instead of a + // first-validation runtime panic. Only a syntactically valid pattern + // reaches codegen; the runtime `Regex::new` fallback below is retained + // solely for the rare case a valid pattern exceeds `regex`'s compiled + // size limit (which `regex-syntax` parsing does not enforce). + if let Err(__err) = regex_syntax::Parser::new().parse(pattern) { + let msg = format!( + "vespera: `#[schema(pattern = {pattern:?})]` on field `{field_name}` is not a valid regex: {__err}" + ); + blocks.push(quote! { ::std::compile_error!(#msg); }); + } else { + // Sanitize the field name into a valid identifier fragment before + // splicing it into a `static` name: strip a raw-identifier `r#` + // prefix and map any non-alphanumeric byte to `_`. A raw ident + // (`r#type`) or otherwise unusual field name would otherwise make + // `format_ident!` PANIC at macro-expansion time (e.g. + // `__VESPERA_PATTERN_R#TYPE` is not a valid ident). Each pattern + // block is emitted in its own `{ }` scope, so the sanitized name + // never needs to be unique across fields. + let ident_fragment: String = field_name + .trim_start_matches("r#") + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_uppercase() + } else { + '_' + } + }) + .collect(); + let static_ident = format_ident!("__VESPERA_PATTERN_{}", ident_fragment); + blocks.push(quote! { { - __garde_report.append(__garde_path(), __garde_error); + static #static_ident: ::std::sync::LazyLock< + ::vespera::__validation::garde::rules::pattern::regex::Regex, + > = ::std::sync::LazyLock::new(|| { + // Pattern syntax was validated at macro expansion; this + // fallback only trips on the rare compiled-size-limit + // case, with an actionable message naming the pattern. + ::vespera::__validation::garde::rules::pattern::regex::Regex::new(#pattern) + .unwrap_or_else(|__e| { + ::std::panic!( + "vespera: `#[schema(pattern = {:?})]` is not a valid regex: {__e}", + #pattern + ) + }) + }); + if let ::std::result::Result::Err(__garde_error) = + (::vespera::__validation::garde::rules::pattern::apply)( + &*__garde_binding, + (&*#static_ident,), + ) + { + __garde_report.append(__garde_path(), __garde_error); + } } - } - }); + }); + } } // ── Format-driven rules (email / uri / ipv4 / ipv6 / ip) ────────── @@ -397,35 +444,6 @@ fn numeric_some(value: Option, numeric_kind: Option<&str>) -> TokenStream { ) } -#[cfg(feature = "validation")] -fn is_option_type(ty: &Type) -> bool { - let Type::Path(tp) = ty else { - return false; - }; - tp.path - .segments - .last() - .is_some_and(|seg| seg.ident == "Option") -} - -#[cfg(feature = "validation")] -fn peel_option(ty: &Type) -> Option<&Type> { - let Type::Path(tp) = ty else { - return None; - }; - let last = tp.path.segments.last()?; - if last.ident != "Option" { - return None; - } - let PathArguments::AngleBracketed(args) = &last.arguments else { - return None; - }; - args.args.iter().find_map(|arg| match arg { - GenericArgument::Type(t) => Some(t), - _ => None, - }) -} - #[cfg(feature = "validation")] fn rust_numeric_kind(ty: &Type) -> Option { let Type::Path(tp) = ty else { @@ -455,492 +473,4 @@ fn rust_numeric_kind(ty: &Type) -> Option { // ── tests ──────────────────────────────────────────────────────────── #[cfg(all(test, feature = "validation"))] -mod tests { - use super::*; - use syn::parse_quote; - - #[allow(clippy::needless_pass_by_value)] // test helper takes owned input by convention - fn emit_to_string(input: DeriveInput) -> String { - emit_garde_validate(&input).to_string() - } - - #[test] - fn no_constraints_emits_nothing() { - let s: DeriveInput = parse_quote! { - struct User { - pub name: String, - pub age: i32, - } - }; - assert!(emit_to_string(s).is_empty()); - } - - #[test] - fn min_length_only_emits_length_chars_apply() { - let s: DeriveInput = parse_quote! { - struct User { - #[schema(min_length = 3)] - pub name: String, - } - }; - let out = emit_to_string(s); - assert!(out.contains("impl :: vespera :: __validation :: garde :: Validate for User")); - assert!(out.contains("length :: chars :: apply")); - assert!(out.contains("3usize") || out.contains("3 usize")); - } - - #[test] - fn min_and_max_length_combined_in_single_call() { - let s: DeriveInput = parse_quote! { - struct User { - #[schema(min_length = 3, max_length = 32)] - pub name: String, - } - }; - let out = emit_to_string(s); - // single length::chars::apply call carrying both bounds - let occurrences = out.matches("length :: chars :: apply").count(); - assert_eq!(occurrences, 1); - } - - #[test] - fn range_emit_uses_field_numeric_type() { - let s: DeriveInput = parse_quote! { - struct User { - #[schema(minimum = 0, maximum = 150)] - pub age: u32, - } - }; - let out = emit_to_string(s); - assert!(out.contains("range :: apply")); - assert!(out.contains("as u32")); - } - - #[test] - fn range_emit_on_float_field_keeps_decimal_point() { - let s: DeriveInput = parse_quote! { - struct Price { - #[schema(minimum = 0.01, maximum = 99.99)] - pub amount: f64, - } - }; - let out = emit_to_string(s); - assert!(out.contains("range :: apply")); - assert!(out.contains("as f64")); - } - - #[test] - fn pattern_emits_static_lazy_lock_regex() { - let s: DeriveInput = parse_quote! { - struct User { - #[schema(pattern = "^[a-z]+$")] - pub username: String, - } - }; - let out = emit_to_string(s); - assert!(out.contains("static __VESPERA_PATTERN_USERNAME")); - assert!(out.contains("LazyLock")); - assert!(out.contains("regex :: Regex :: new")); - assert!(out.contains("pattern :: apply")); - } - - #[test] - fn format_email_emits_email_apply() { - let s: DeriveInput = parse_quote! { - struct User { - #[schema(format = "email")] - pub email: String, - } - }; - let out = emit_to_string(s); - assert!(out.contains("email :: apply")); - } - - #[test] - fn format_uri_emits_url_apply() { - let s: DeriveInput = parse_quote! { - struct Site { - #[schema(format = "uri")] - pub home: String, - } - }; - let out = emit_to_string(s); - assert!(out.contains("url :: apply")); - } - - #[test] - fn format_ipv4_emits_ip_apply_with_v4_kind() { - let s: DeriveInput = parse_quote! { - struct Host { - #[schema(format = "ipv4")] - pub addr: String, - } - }; - let out = emit_to_string(s); - assert!(out.contains("ip :: apply")); - assert!(out.contains("IpKind :: V4")); - } - - #[test] - fn format_uuid_is_annotation_only_no_runtime_rule() { - let s: DeriveInput = parse_quote! { - struct Entity { - #[schema(format = "uuid")] - pub id: String, - } - }; - // uuid alone has no garde rule → no Validate impl emitted. - assert!(emit_to_string(s).is_empty()); - } - - #[test] - fn option_field_wraps_rule_block_in_if_let_some() { - let s: DeriveInput = parse_quote! { - struct User { - #[schema(min_length = 3)] - pub nickname: Option, - } - }; - let out = emit_to_string(s); - assert!(out.contains("if let :: std :: option :: Option :: Some")); - assert!(out.contains("length :: chars :: apply")); - } - - #[test] - fn min_max_items_on_vec_emits_length_simple() { - let s: DeriveInput = parse_quote! { - struct Post { - #[schema(min_items = 1, max_items = 5)] - pub tags: Vec, - } - }; - let out = emit_to_string(s); - assert!(out.contains("length :: simple :: apply")); - } - - #[test] - fn enum_emits_nothing() { - let e: DeriveInput = parse_quote! { - enum Status { Active, Inactive } - }; - assert!(emit_to_string(e).is_empty()); - } - - #[test] - fn tuple_struct_emits_nothing() { - let s: DeriveInput = parse_quote! { - struct Wrapper(pub String); - }; - assert!(emit_to_string(s).is_empty()); - } - - #[test] - fn unit_struct_emits_nothing() { - let s: DeriveInput = parse_quote! { - struct Empty; - }; - assert!(emit_to_string(s).is_empty()); - } - - #[test] - fn generic_struct_with_constraints_produces_compile_error() { - let s: DeriveInput = parse_quote! { - struct Wrapper { - #[schema(min_length = 3)] - pub name: String, - pub inner: T, - } - }; - let out = emit_to_string(s); - assert!(out.contains("compile_error")); - assert!(out.contains("generic")); - } - - #[test] - fn annotation_only_constraints_emit_nothing() { - // example / read_only / write_only / unique_items / multiple_of / - // exclusive bounds are OpenAPI annotations only; they should not - // drag a Validate impl into existence on their own. - let s: DeriveInput = parse_quote! { - struct Doc { - #[schema(read_only, example = "abc", unique_items, multiple_of = 0.5)] - pub id: String, - } - }; - assert!(emit_to_string(s).is_empty()); - } - - // ── nested validation (`#[schema(dive)]`) emission ────────────── - - #[test] - fn dive_on_plain_field_emits_validate_into_call() { - let s: DeriveInput = parse_quote! { - struct Order { - #[schema(dive)] - pub address: Address, - } - }; - let out = emit_to_string(s); - assert!(out.contains("impl :: vespera :: __validation :: garde :: Validate for Order")); - assert!(out.contains("Validate :: validate_into")); - assert!(out.contains("\"address\"")); - } - - #[test] - fn dive_on_option_wraps_in_if_let_some() { - let s: DeriveInput = parse_quote! { - struct Order { - #[schema(dive)] - pub address: Option

, - } - }; - let out = emit_to_string(s); - assert!(out.contains("if let :: std :: option :: Option :: Some")); - assert!(out.contains("Validate :: validate_into")); - } - - #[test] - fn dive_on_vec_emits_single_validate_into_call() { - // garde's runtime `Vec: Validate` impl iterates and pushes - // `[idx]` path components automatically — the macro only emits - // one `validate_into` call regardless of container kind. - let s: DeriveInput = parse_quote! { - struct Order { - #[schema(dive)] - pub items: Vec, - } - }; - let out = emit_to_string(s); - assert!(out.contains("Validate :: validate_into")); - // `validate_into` appears twice: once as the outer fn declaration - // (`fn validate_into(...)`) and once as the inner trait dispatch - // (`Validate :: validate_into(...)`). Anything more would mean - // the macro is iterating itself, which is what we explicitly - // delegate to garde's runtime `Vec: Validate` impl. - assert_eq!( - out.matches("validate_into").count(), - 2, - "expected outer fn + one inner trait call; iteration is garde-runtime, \ - so the macro must NOT emit a `for` loop" - ); - // `for` keyword appears in `impl ... for Order` — count only - // tokens that look like loop iteration (`for in `). - let loop_count = out.matches("in __garde_binding").count(); - assert_eq!(loop_count, 0, "macro must not emit explicit iteration"); - } - - #[test] - fn dive_combined_with_length_emits_both_rules() { - let s: DeriveInput = parse_quote! { - struct Order { - #[schema(min_items = 1, max_items = 10, dive)] - pub items: Vec, - } - }; - let out = emit_to_string(s); - assert!(out.contains("length :: simple :: apply")); - assert!(out.contains("Validate :: validate_into")); - } - - #[test] - fn dive_false_disables_emission() { - let s: DeriveInput = parse_quote! { - struct Order { - #[schema(dive = false)] - pub address: Address, - } - }; - // `dive = false` is the same as no annotation — no rule - // produced means no `impl Validate` emitted. - assert!(emit_to_string(s).is_empty()); - } - - // ── format=ipv6 / format=ip / unknown format ──────────────────── - - #[test] - fn format_ipv6_emits_ip_apply_with_v6_kind() { - let s: DeriveInput = parse_quote! { - struct Host { - #[schema(format = "ipv6")] - pub addr: String, - } - }; - let out = emit_to_string(s); - assert!(out.contains("ip :: apply")); - assert!(out.contains("IpKind :: V6")); - } - - #[test] - fn format_ip_emits_ip_apply_with_any_kind() { - let s: DeriveInput = parse_quote! { - struct Host { - #[schema(format = "ip")] - pub addr: String, - } - }; - let out = emit_to_string(s); - assert!(out.contains("ip :: apply")); - assert!(out.contains("IpKind :: Any")); - } - - #[test] - fn format_url_alias_emits_url_apply() { - // `format = "url"` is the documented alias for `"uri"` — - // both must dispatch to garde's `url::apply`. - let s: DeriveInput = parse_quote! { - struct Site { - #[schema(format = "url")] - pub home: String, - } - }; - let out = emit_to_string(s); - assert!(out.contains("url :: apply")); - } - - #[test] - fn unknown_format_with_other_rule_skips_format_branch() { - // Combining an unsupported `format = "custom"` with a known - // runtime rule (`min_length = 3`) forces the emitter to enter - // `emit_rule_blocks` AND fall through the unknown-format - // branch — exercising the `_ => {}` arm. - let s: DeriveInput = parse_quote! { - struct Doc { - #[schema(min_length = 3, format = "custom-thing")] - pub id: String, - } - }; - let out = emit_to_string(s); - assert!(out.contains("length :: chars :: apply")); - // The unknown format MUST NOT produce any `ip::`/`email::`/ - // `url::` call — confirms the `_ => {}` arm took effect. - assert!(!out.contains("ip :: apply")); - assert!(!out.contains("email :: apply")); - assert!(!out.contains("url :: apply")); - } - - // ── mixed-field structs exercising the no-runtime-rule early exit - // inside emit_field_block ──────────────────────────────────── - - #[test] - fn mixed_validated_and_unvalidated_fields_emit_only_validated_blocks() { - // `a` has a runtime rule; `b` does not. emit_field_block must - // hit its early `return None` for `b` while still emitting `a`. - let s: DeriveInput = parse_quote! { - struct Mixed { - #[schema(min_length = 3)] - pub a: String, - pub b: String, - } - }; - let out = emit_to_string(s); - assert!(out.contains("impl :: vespera :: __validation :: garde :: Validate for Mixed")); - assert!(out.contains("\"a\"")); - // Field `b` has no constraint — no path literal should appear. - assert!(!out.contains("\"b\"")); - } - - // ── one-sided numeric bounds exercising numeric_some(None, _) ─── - - #[test] - fn only_minimum_set_emits_none_for_max_bound() { - let s: DeriveInput = parse_quote! { - struct N { - #[schema(minimum = 0)] - pub n: u32, - } - }; - let out = emit_to_string(s); - assert!(out.contains("range :: apply")); - // The missing upper bound must serialize as Option::None. - assert!(out.contains("Option :: None")); - } - - #[test] - fn only_maximum_set_emits_none_for_min_bound() { - let s: DeriveInput = parse_quote! { - struct N { - #[schema(maximum = 100)] - pub n: u32, - } - }; - let out = emit_to_string(s); - assert!(out.contains("range :: apply")); - assert!(out.contains("Option :: None")); - } - - // ── numeric_some with unknown numeric_kind (non-primitive field) ─ - - #[test] - fn minimum_on_non_primitive_field_falls_back_to_as_wildcard() { - // Field type is a user-defined `Money` newtype — peel_option - // returns None and rust_numeric_kind returns None, forcing - // numeric_some down the `as _` fallback branch. - let s: DeriveInput = parse_quote! { - struct Order { - #[schema(minimum = 0)] - pub price: Money, - } - }; - let out = emit_to_string(s); - assert!(out.contains("range :: apply")); - assert!( - out.contains("as _"), - "non-primitive field should emit `as _` fallback, got: {out}" - ); - } - - // ── is_option_type / peel_option / rust_numeric_kind branches ─── - - #[test] - fn tuple_typed_field_does_not_trip_option_or_numeric_helpers() { - // Tuple types are Type::Tuple, not Type::Path — drives the - // non-Path early-return branches inside is_option_type, - // peel_option, and rust_numeric_kind. - let s: DeriveInput = parse_quote! { - struct WithTuple { - #[schema(min_length = 3)] - pub x: (String,), - } - }; - let out = emit_to_string(s); - // Tuple is not an Option — outer rule block must NOT wrap in - // `if let Some`. - assert!(!out.contains("if let :: std :: option :: Option :: Some")); - assert!(out.contains("length :: chars :: apply")); - } - - #[test] - fn bare_option_without_angle_brackets_falls_through_peel() { - // `Option` with no type argument hits the PathArguments::None - // branch inside peel_option. is_option_type still returns - // true (last segment is `Option`), so the rule block wraps in - // `if let Some`. - let s: DeriveInput = parse_quote! { - struct BareOption { - #[schema(min_length = 3)] - pub x: Option, - } - }; - let out = emit_to_string(s); - assert!(out.contains("if let :: std :: option :: Option :: Some")); - } - - #[test] - fn option_with_lifetime_only_arg_falls_through_find_map() { - // `Option<'static>` is syntactically a valid path with one - // angle-bracketed argument — but the argument is a Lifetime, - // not a Type, so peel_option's `find_map` returns None. - // Semantically nonsensical, but the macro must not panic. - let s: DeriveInput = parse_quote! { - struct WithLifetime { - #[schema(min_length = 3)] - pub x: Option<'static>, - } - }; - let out = emit_to_string(s); - // The rule block still emits — peel_option returning None just - // means rust_numeric_kind is invoked on the outer `Option<'a>` - // type, which also returns None. No panic, no compile_error. - assert!(out.contains("length :: chars :: apply")); - } -} +mod tests; diff --git a/crates/vespera_macro/src/garde_emit/tests.rs b/crates/vespera_macro/src/garde_emit/tests.rs new file mode 100644 index 00000000..84e5a313 --- /dev/null +++ b/crates/vespera_macro/src/garde_emit/tests.rs @@ -0,0 +1,508 @@ +use super::*; +use syn::parse_quote; + +#[allow(clippy::needless_pass_by_value)] // test helper takes owned input by convention +fn emit_to_string(input: DeriveInput) -> String { + emit_garde_validate(&input).to_string() +} + +#[test] +fn no_constraints_emits_nothing() { + let s: DeriveInput = parse_quote! { + struct User { + pub name: String, + pub age: i32, + } + }; + assert!(emit_to_string(s).is_empty()); +} + +#[test] +fn min_length_only_emits_length_chars_apply() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(min_length = 3)] + pub name: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("impl :: vespera :: __validation :: garde :: Validate for User")); + assert!(out.contains("length :: chars :: apply")); + assert!(out.contains("3usize") || out.contains("3 usize")); +} + +#[test] +fn min_and_max_length_combined_in_single_call() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(min_length = 3, max_length = 32)] + pub name: String, + } + }; + let out = emit_to_string(s); + // single length::chars::apply call carrying both bounds + let occurrences = out.matches("length :: chars :: apply").count(); + assert_eq!(occurrences, 1); +} + +#[test] +fn invalid_pattern_emits_compile_error_not_runtime_panic() { + // An unbalanced group is a regex SYNTAX error: it must be caught at + // macro expansion (compile_error!), not deferred to a runtime panic. + let s: DeriveInput = parse_quote! { + struct User { + #[schema(pattern = "(")] + pub name: String, + } + }; + let out = emit_to_string(s); + assert!( + out.contains("compile_error"), + "invalid pattern should emit compile_error, got: {out}" + ); + assert!( + !out.contains("LazyLock"), + "invalid pattern must not emit a runtime regex validator: {out}" + ); +} + +#[test] +fn valid_pattern_emits_regex_validator() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(pattern = "^[a-z0-9_]+$")] + pub name: String, + } + }; + let out = emit_to_string(s); + assert!( + out.contains("LazyLock"), + "valid pattern should emit a regex validator: {out}" + ); + assert!(out.contains("pattern :: apply")); + assert!(!out.contains("compile_error")); +} + +#[test] +fn range_emit_uses_field_numeric_type() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(minimum = 0, maximum = 150)] + pub age: u32, + } + }; + let out = emit_to_string(s); + assert!(out.contains("range :: apply")); + assert!(out.contains("as u32")); +} + +#[test] +fn range_emit_on_float_field_keeps_decimal_point() { + let s: DeriveInput = parse_quote! { + struct Price { + #[schema(minimum = 0.01, maximum = 99.99)] + pub amount: f64, + } + }; + let out = emit_to_string(s); + assert!(out.contains("range :: apply")); + assert!(out.contains("as f64")); +} + +#[test] +fn pattern_emits_static_lazy_lock_regex() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(pattern = "^[a-z]+$")] + pub username: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("static __VESPERA_PATTERN_USERNAME")); + assert!(out.contains("LazyLock")); + assert!(out.contains("regex :: Regex :: new")); + assert!(out.contains("pattern :: apply")); +} + +#[test] +fn format_email_emits_email_apply() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(format = "email")] + pub email: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("email :: apply")); +} + +#[test] +fn format_uri_emits_url_apply() { + let s: DeriveInput = parse_quote! { + struct Site { + #[schema(format = "uri")] + pub home: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("url :: apply")); +} + +#[test] +fn format_ipv4_emits_ip_apply_with_v4_kind() { + let s: DeriveInput = parse_quote! { + struct Host { + #[schema(format = "ipv4")] + pub addr: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("ip :: apply")); + assert!(out.contains("IpKind :: V4")); +} + +#[test] +fn format_uuid_is_annotation_only_no_runtime_rule() { + let s: DeriveInput = parse_quote! { + struct Entity { + #[schema(format = "uuid")] + pub id: String, + } + }; + // uuid alone has no garde rule → no Validate impl emitted. + assert!(emit_to_string(s).is_empty()); +} + +#[test] +fn option_field_wraps_rule_block_in_if_let_some() { + let s: DeriveInput = parse_quote! { + struct User { + #[schema(min_length = 3)] + pub nickname: Option, + } + }; + let out = emit_to_string(s); + assert!(out.contains("if let :: std :: option :: Option :: Some")); + assert!(out.contains("length :: chars :: apply")); +} + +#[test] +fn min_max_items_on_vec_emits_length_simple() { + let s: DeriveInput = parse_quote! { + struct Post { + #[schema(min_items = 1, max_items = 5)] + pub tags: Vec, + } + }; + let out = emit_to_string(s); + assert!(out.contains("length :: simple :: apply")); +} + +#[test] +fn enum_emits_nothing() { + let e: DeriveInput = parse_quote! { + enum Status { Active, Inactive } + }; + assert!(emit_to_string(e).is_empty()); +} + +#[test] +fn tuple_struct_emits_nothing() { + let s: DeriveInput = parse_quote! { + struct Wrapper(pub String); + }; + assert!(emit_to_string(s).is_empty()); +} + +#[test] +fn unit_struct_emits_nothing() { + let s: DeriveInput = parse_quote! { + struct Empty; + }; + assert!(emit_to_string(s).is_empty()); +} + +#[test] +fn generic_struct_with_constraints_produces_compile_error() { + let s: DeriveInput = parse_quote! { + struct Wrapper { + #[schema(min_length = 3)] + pub name: String, + pub inner: T, + } + }; + let out = emit_to_string(s); + assert!(out.contains("compile_error")); + assert!(out.contains("generic")); +} + +#[test] +fn annotation_only_constraints_emit_nothing() { + // example / read_only / write_only / unique_items / multiple_of / + // exclusive bounds are OpenAPI annotations only; they should not + // drag a Validate impl into existence on their own. + let s: DeriveInput = parse_quote! { + struct Doc { + #[schema(read_only, example = "abc", unique_items, multiple_of = 0.5)] + pub id: String, + } + }; + assert!(emit_to_string(s).is_empty()); +} + +// ── nested validation (`#[schema(dive)]`) emission ────────────── + +#[test] +fn dive_on_plain_field_emits_validate_into_call() { + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(dive)] + pub address: Address, + } + }; + let out = emit_to_string(s); + assert!(out.contains("impl :: vespera :: __validation :: garde :: Validate for Order")); + assert!(out.contains("Validate :: validate_into")); + assert!(out.contains("\"address\"")); +} + +#[test] +fn dive_on_option_wraps_in_if_let_some() { + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(dive)] + pub address: Option
, + } + }; + let out = emit_to_string(s); + assert!(out.contains("if let :: std :: option :: Option :: Some")); + assert!(out.contains("Validate :: validate_into")); +} + +#[test] +fn dive_on_vec_emits_single_validate_into_call() { + // garde's runtime `Vec: Validate` impl iterates and pushes + // `[idx]` path components automatically — the macro only emits + // one `validate_into` call regardless of container kind. + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(dive)] + pub items: Vec, + } + }; + let out = emit_to_string(s); + assert!(out.contains("Validate :: validate_into")); + // `validate_into` appears twice: once as the outer fn declaration + // (`fn validate_into(...)`) and once as the inner trait dispatch + // (`Validate :: validate_into(...)`). Anything more would mean + // the macro is iterating itself, which is what we explicitly + // delegate to garde's runtime `Vec: Validate` impl. + assert_eq!( + out.matches("validate_into").count(), + 2, + "expected outer fn + one inner trait call; iteration is garde-runtime, \ + so the macro must NOT emit a `for` loop" + ); + // `for` keyword appears in `impl ... for Order` — count only + // tokens that look like loop iteration (`for in `). + let loop_count = out.matches("in __garde_binding").count(); + assert_eq!(loop_count, 0, "macro must not emit explicit iteration"); +} + +#[test] +fn dive_combined_with_length_emits_both_rules() { + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(min_items = 1, max_items = 10, dive)] + pub items: Vec, + } + }; + let out = emit_to_string(s); + assert!(out.contains("length :: simple :: apply")); + assert!(out.contains("Validate :: validate_into")); +} + +#[test] +fn dive_false_disables_emission() { + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(dive = false)] + pub address: Address, + } + }; + // `dive = false` is the same as no annotation — no rule + // produced means no `impl Validate` emitted. + assert!(emit_to_string(s).is_empty()); +} + +// ── format=ipv6 / format=ip / unknown format ──────────────────── + +#[test] +fn format_ipv6_emits_ip_apply_with_v6_kind() { + let s: DeriveInput = parse_quote! { + struct Host { + #[schema(format = "ipv6")] + pub addr: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("ip :: apply")); + assert!(out.contains("IpKind :: V6")); +} + +#[test] +fn format_ip_emits_ip_apply_with_any_kind() { + let s: DeriveInput = parse_quote! { + struct Host { + #[schema(format = "ip")] + pub addr: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("ip :: apply")); + assert!(out.contains("IpKind :: Any")); +} + +#[test] +fn format_url_alias_emits_url_apply() { + // `format = "url"` is the documented alias for `"uri"` — + // both must dispatch to garde's `url::apply`. + let s: DeriveInput = parse_quote! { + struct Site { + #[schema(format = "url")] + pub home: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("url :: apply")); +} + +#[test] +fn unknown_format_with_other_rule_skips_format_branch() { + // Combining an unsupported `format = "custom"` with a known + // runtime rule (`min_length = 3`) forces the emitter to enter + // `emit_rule_blocks` AND fall through the unknown-format + // branch — exercising the `_ => {}` arm. + let s: DeriveInput = parse_quote! { + struct Doc { + #[schema(min_length = 3, format = "custom-thing")] + pub id: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("length :: chars :: apply")); + // The unknown format MUST NOT produce any `ip::`/`email::`/ + // `url::` call — confirms the `_ => {}` arm took effect. + assert!(!out.contains("ip :: apply")); + assert!(!out.contains("email :: apply")); + assert!(!out.contains("url :: apply")); +} + +// ── mixed-field structs exercising the no-runtime-rule early exit +// inside emit_field_block ──────────────────────────────────── + +#[test] +fn mixed_validated_and_unvalidated_fields_emit_only_validated_blocks() { + // `a` has a runtime rule; `b` does not. emit_field_block must + // hit its early `return None` for `b` while still emitting `a`. + let s: DeriveInput = parse_quote! { + struct Mixed { + #[schema(min_length = 3)] + pub a: String, + pub b: String, + } + }; + let out = emit_to_string(s); + assert!(out.contains("impl :: vespera :: __validation :: garde :: Validate for Mixed")); + assert!(out.contains("\"a\"")); + // Field `b` has no constraint — no path literal should appear. + assert!(!out.contains("\"b\"")); +} + +// ── one-sided numeric bounds exercising numeric_some(None, _) ─── + +#[test] +fn only_minimum_set_emits_none_for_max_bound() { + let s: DeriveInput = parse_quote! { + struct N { + #[schema(minimum = 0)] + pub n: u32, + } + }; + let out = emit_to_string(s); + assert!(out.contains("range :: apply")); + // The missing upper bound must serialize as Option::None. + assert!(out.contains("Option :: None")); +} + +#[test] +fn only_maximum_set_emits_none_for_min_bound() { + let s: DeriveInput = parse_quote! { + struct N { + #[schema(maximum = 100)] + pub n: u32, + } + }; + let out = emit_to_string(s); + assert!(out.contains("range :: apply")); + assert!(out.contains("Option :: None")); +} + +// ── numeric_some with unknown numeric_kind (non-primitive field) ─ + +#[test] +fn minimum_on_non_primitive_field_falls_back_to_as_wildcard() { + // Field type is a user-defined `Money` newtype — peel_option + // returns None and rust_numeric_kind returns None, forcing + // numeric_some down the `as _` fallback branch. + let s: DeriveInput = parse_quote! { + struct Order { + #[schema(minimum = 0)] + pub price: Money, + } + }; + let out = emit_to_string(s); + assert!(out.contains("range :: apply")); + assert!( + out.contains("as _"), + "non-primitive field should emit `as _` fallback, got: {out}" + ); +} + +#[test] +fn tuple_typed_field_does_not_trip_option_or_numeric_helpers() { + let s: DeriveInput = parse_quote! { + struct WithTuple { + #[schema(min_length = 3)] + pub x: (String,), + } + }; + let out = emit_to_string(s); + assert!(!out.contains("if let :: std :: option :: Option :: Some")); + assert!(out.contains("length :: chars :: apply")); +} + +#[test] +fn bare_option_without_angle_brackets_falls_through_peel() { + let s: DeriveInput = parse_quote! { + struct BareOption { + #[schema(min_length = 3)] + pub x: Option, + } + }; + let out = emit_to_string(s); + assert!(!out.contains("if let :: std :: option :: Option :: Some")); + assert!(out.contains("length :: chars :: apply")); +} + +#[test] +fn option_with_lifetime_only_arg_falls_through_find_map() { + let s: DeriveInput = parse_quote! { + struct WithLifetime { + #[schema(min_length = 3)] + pub x: Option<'static>, + } + }; + let out = emit_to_string(s); + assert!(out.contains("length :: chars :: apply")); +} diff --git a/crates/vespera_macro/src/http.rs b/crates/vespera_macro/src/http.rs index 50b20813..01218d38 100644 --- a/crates/vespera_macro/src/http.rs +++ b/crates/vespera_macro/src/http.rs @@ -44,7 +44,11 @@ pub const HTTP_METHODS: &[&str] = &[ /// assert!(!is_http_method("invalid")); /// ``` pub fn is_http_method(s: &str) -> bool { - HTTP_METHODS.contains(&s.to_lowercase().as_str()) + // Case-insensitive match without allocating a lowercased copy + // (HTTP method names are ASCII; HTTP_METHODS are lowercase). + HTTP_METHODS + .iter() + .any(|&method| s.eq_ignore_ascii_case(method)) } #[cfg(test)] diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index f19f6a77..6f794c3c 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -60,10 +60,7 @@ mod schema_impl; mod schema_macro; mod vespera_impl; -pub(crate) use cron_impl::CRON_STORAGE; use proc_macro::TokenStream; -pub(crate) use route_impl::ROUTE_STORAGE; -pub(crate) use schema_impl::SCHEMA_STORAGE; use crate::{ router_codegen::{AutoRouterInput, ExportAppInput, process_vespera_input}, @@ -120,22 +117,21 @@ pub fn cron(attr: TokenStream, item: TokenStream) -> TokenStream { #[cfg(not(tarpaulin_include))] #[proc_macro_derive(Schema, attributes(schema, serde))] pub fn derive_schema(input: TokenStream) -> TokenStream { + schema_macro::file_cache::bump_epoch(); + let input = syn::parse_macro_input!(input as syn::DeriveInput); let (metadata, expanded) = schema_impl::process_derive_schema(&input); + let Some(metadata) = metadata else { + return TokenStream::from(expanded); + }; let name = metadata.name.clone(); - let mut storage = SCHEMA_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - - if let Some(existing) = storage.get(&name) - && existing.definition != metadata.definition - { - // Two distinct struct definitions both ask for the same - // OpenAPI schema name. Surface this as a hard compile error - // — the alternative (silent last-write-wins overwrite) hides - // schemas from the generated `openapi.json` in a way that is - // only discovered by inspecting the spec. + // Register into the current crate's bucket (see `current_crate_key`). + // `Err` means a DIFFERENT definition is already registered under this name + // for this crate — surface it as a hard compile error rather than the + // silent last-write-wins overwrite that would hide a schema from the + // generated `openapi.json`. + if schema_impl::register_schema(name.clone(), metadata).is_err() { let span = input.ident.span(); let msg = format!( "duplicate vespera Schema name `{name}` -- two different struct \ @@ -149,7 +145,6 @@ pub fn derive_schema(input: TokenStream) -> TokenStream { return TokenStream::from(err); } - storage.insert(name, metadata); TokenStream::from(expanded) } @@ -226,12 +221,11 @@ pub fn derive_multipart(input: TokenStream) -> TokenStream { #[cfg(not(tarpaulin_include))] #[proc_macro] pub fn schema(input: TokenStream) -> TokenStream { + schema_macro::file_cache::bump_epoch(); + let input = syn::parse_macro_input!(input as schema_macro::SchemaInput); - // Get stored schemas - let storage = SCHEMA_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let storage = schema_impl::current_crate_schemas(); match schema_macro::generate_schema_code(&input, &storage) { Ok(tokens) => TokenStream::from(tokens), @@ -296,14 +290,13 @@ pub fn schema(input: TokenStream) -> TokenStream { #[cfg(not(tarpaulin_include))] #[proc_macro] pub fn schema_type(input: TokenStream) -> TokenStream { + schema_macro::file_cache::bump_epoch(); + let input = syn::parse_macro_input!(input as schema_macro::SchemaTypeInput); let ignore_schema = input.ignore_schema; - // Get stored schemas and generate code let (tokens, generated_metadata) = { - let storage = SCHEMA_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let storage = schema_impl::current_crate_schemas(); match schema_macro::generate_schema_type_code(&input, &storage) { Ok(result) => result, Err(e) => return e.to_compile_error().into(), @@ -326,10 +319,7 @@ pub fn schema_type(input: TokenStream) -> TokenStream { // expanded struct token stream). if ignore_schema && let Some(metadata) = generated_metadata { let name = metadata.name.clone(); - SCHEMA_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(name, metadata); + schema_impl::insert_schema(name, metadata); } TokenStream::from(tokens) } @@ -337,16 +327,25 @@ pub fn schema_type(input: TokenStream) -> TokenStream { #[cfg(not(tarpaulin_include))] #[proc_macro] pub fn vespera(input: TokenStream) -> TokenStream { + schema_macro::file_cache::bump_epoch(); + let input = syn::parse_macro_input!(input as AutoRouterInput); + // Capture the `dir = "..."` literal span (or the macro call site when + // `dir` is omitted) before `process_vespera_input` consumes `input`, so a + // "route folder not found" diagnostic points at the offending argument + // rather than the whole `vespera!` invocation. + let folder_span = input + .dir + .as_ref() + .map_or_else(proc_macro2::Span::call_site, syn::LitStr::span); let processed = process_vespera_input(input); - let schema_storage = SCHEMA_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let route_storage = ROUTE_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + // Per-crate snapshots (see `schema_impl::current_crate_key`): a shared + // rust-analyzer proc-macro server never leaks another crate's schemas / + // routes into this `vespera!` expansion. + let schema_storage = schema_impl::current_crate_schemas(); + let route_storage = route_impl::current_crate_routes(); - match process_vespera_macro(&processed, &schema_storage, &route_storage) { + match process_vespera_macro(&processed, &schema_storage, &route_storage, folder_span) { Ok(tokens) => tokens.into(), Err(e) => e.to_compile_error().into(), } @@ -377,21 +376,25 @@ pub fn vespera(input: TokenStream) -> TokenStream { #[cfg(not(tarpaulin_include))] #[proc_macro] pub fn export_app(input: TokenStream) -> TokenStream { + schema_macro::file_cache::bump_epoch(); + let ExportAppInput { name, dir } = syn::parse_macro_input!(input as ExportAppInput); + // Capture the `dir = "..."` literal span (or the macro call site when + // `dir` is omitted) before `dir` is consumed below, so a "route folder + // not found" diagnostic points at the offending argument. + let folder_span = dir + .as_ref() + .map_or_else(proc_macro2::Span::call_site, syn::LitStr::span); let folder_name = dir .map(|d| d.value()) .or_else(|| std::env::var("VESPERA_DIR").ok()) .unwrap_or_else(|| "routes".to_string()); - let schema_storage = SCHEMA_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let schema_storage = schema_impl::current_crate_schemas(); let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else { return syn::Error::new(proc_macro2::Span::call_site(), "export_app! macro: CARGO_MANIFEST_DIR is not set. This macro must be used within a cargo build.").to_compile_error().into(); }; - let route_storage = ROUTE_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let route_storage = route_impl::current_crate_routes(); match process_export_app( &name, @@ -399,6 +402,7 @@ pub fn export_app(input: TokenStream) -> TokenStream { &schema_storage, &manifest_dir, &route_storage, + folder_span, ) { Ok(tokens) => tokens.into(), Err(e) => e.to_compile_error().into(), diff --git a/crates/vespera_macro/src/metadata.rs b/crates/vespera_macro/src/metadata.rs index 414816ad..ccdbae59 100644 --- a/crates/vespera_macro/src/metadata.rs +++ b/crates/vespera_macro/src/metadata.rs @@ -4,6 +4,16 @@ use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; +/// Header parameter declared at the route site via `headers = [...]`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeaderParam { + pub name: String, + #[serde(default)] + pub required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + /// Route metadata #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RouteMetadata { @@ -17,14 +27,40 @@ pub struct RouteMetadata { pub module_path: String, /// File path pub file_path: String, - /// Function signature (as string for serialization) - pub signature: String, + + /// Declared non-200 success status from the `status` attribute. + #[serde(skip_serializing_if = "Option::is_none")] + pub success_status: Option, /// Additional error status codes from `error_status` attribute #[serde(skip_serializing_if = "Option::is_none")] pub error_status: Option>, + /// Typed error responses from `responses` attribute. + #[serde(skip_serializing_if = "Option::is_none")] + pub typed_responses: Option>, /// Tags for `OpenAPI` grouping #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, + /// Per-route OpenAPI security requirements. + #[serde(skip_serializing_if = "Option::is_none")] + pub security: Option>, + /// Header parameters declared by custom extractors at the route site. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub headers: Vec, + /// Explicit OpenAPI operationId override. + #[serde(skip_serializing_if = "Option::is_none")] + pub operation_id: Option, + /// OpenAPI operation summary. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Operation-level request example JSON/string. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_example: Option, + /// Operation-level response example JSON/string. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_example: Option, + /// Whether the OpenAPI operation is deprecated. + #[serde(default)] + pub deprecated: bool, /// Description for `OpenAPI` (from route attribute or doc comment) #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, @@ -47,6 +83,13 @@ pub struct StructMetadata { /// Populated by `#[derive(Schema)]` to avoid AST re-parsing in `vespera!()`. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub field_defaults: BTreeMap, + /// Stable source identity for proc-macro-server re-expansions. + /// + /// This is not part of the OpenAPI output. It lets `#[derive(Schema)]` + /// replace metadata for the same source item after an IDE edit while still + /// rejecting two distinct items that claim the same OpenAPI schema name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_identity: Option, } const fn default_include_in_openapi() -> bool { @@ -60,6 +103,7 @@ impl Default for StructMetadata { definition: String::new(), include_in_openapi: true, field_defaults: BTreeMap::new(), + source_identity: None, } } } @@ -72,6 +116,7 @@ impl StructMetadata { definition, include_in_openapi: true, field_defaults: BTreeMap::new(), + source_identity: None, } } @@ -82,8 +127,16 @@ impl StructMetadata { definition, include_in_openapi: false, field_defaults: BTreeMap::new(), + source_identity: None, } } + + /// Attach the source identity used for same-item replacement in global storage. + #[must_use] + pub fn with_source_identity(mut self, source_identity: String) -> Self { + self.source_identity = Some(source_identity); + self + } } /// Cron job metadata diff --git a/crates/vespera_macro/src/multipart_impl.rs b/crates/vespera_macro/src/multipart_impl.rs deleted file mode 100644 index 923669f9..00000000 --- a/crates/vespera_macro/src/multipart_impl.rs +++ /dev/null @@ -1,1172 +0,0 @@ -//! Vespera's `Multipart` derive macro implementation. -//! -//! This is a re-implementation of `axum_typed_multipart`'s derive macro that -//! natively supports `#[serde(rename_all)]` and `#[serde(rename)]` attributes -//! for field name resolution in multipart form data. -//! -//! ## Why? -//! -//! `axum_typed_multipart`'s derive macro only reads `#[try_from_multipart(rename_all)]` -//! and ignores `#[serde(rename_all)]`. This causes a mismatch: the OpenAPI spec -//! (generated by `Schema` derive) shows camelCase field names, but the runtime -//! multipart parser expects snake_case Rust field names. -//! -//! ## Field Name Resolution Priority -//! -//! 1. `#[form_data(field_name = "...")]` — explicit override (highest priority) -//! 2. `#[serde(rename = "...")]` — serde field rename -//! 3. `#[serde(rename_all = "...")]` or `#[try_from_multipart(rename_all = "...")]` applied to Rust name -//! 4. Rust field name as-is (lowest priority) - -use proc_macro2::TokenStream; -use quote::quote; -use syn::{DeriveInput, Fields, Type}; - -use crate::parser::{extract_default, extract_field_rename, extract_rename_all, rename_field}; - -/// Collected codegen fragments for each struct field. -struct FieldCodegen<'a> { - declarations: Vec, - assignments: Vec, - post_loop: Vec, - idents: Vec<&'a syn::Ident>, -} - -/// How a missing field should be handled. -enum DefaultKind { - /// No default — field is required; emit `MissingField` error. - None, - /// Use `Default::default()` — from `#[serde(default)]` or `#[form_data(default)]`. - Trait, - /// Call a custom function — from `#[serde(default = "path::to::fn")]`. - Function(String), -} - -/// Process all named fields into codegen fragments. -fn process_fields<'a>( - fields: impl Iterator, - rename_all: Option<&str>, - strict: bool, - struct_default: bool, -) -> FieldCodegen<'a> { - let mut cg = FieldCodegen { - declarations: Vec::new(), - assignments: Vec::new(), - post_loop: Vec::new(), - idents: Vec::new(), - }; - - for field in fields { - let ident = field.ident.as_ref().unwrap(); - let ty = &field.ty; - let is_vec = is_vec_type(ty); - let is_option = is_option_type(ty); - let field_name = resolve_field_name(ident, &field.attrs, rename_all); - let limit_tokens = extract_limit_tokens(&field.attrs); - let default_kind = resolve_default_kind(&field.attrs, struct_default); - - // The concrete type for TryFromFieldWithState turbofish. For Option - // and Vec the derive wraps the parsed value, so the trait Self is T. - let parse_ty = if is_option || is_vec { - extract_inner_generic(ty).unwrap_or_else(|| ty.clone()) - } else { - ty.clone() - }; - - // Variable declaration - if is_vec { - cg.declarations - .push(quote! { let mut #ident: #ty = std::vec::Vec::new(); }); - } else if is_option { - cg.declarations - .push(quote! { let mut #ident: #ty = std::option::Option::None; }); - } else { - cg.declarations.push( - quote! { let mut #ident: std::option::Option<#ty> = std::option::Option::None; }, - ); - } - - // Field value parsing — explicit turbofish types are required because - // RPITIT opaque return types prevent the compiler from inferring - // `TryFromFieldWithState::Self` through `.await`. - let try_from_call = quote! { <#parse_ty as vespera::multipart::TryFromFieldWithState<__VesperaS__>>::try_from_field_with_state }; - let parse_value = quote! { #try_from_call(__field__, #limit_tokens, __state__).await? }; - - let assignment = if is_vec { - quote! { #ident.push(#parse_value); } - } else if strict { - let set_value = quote! { #ident = std::option::Option::Some(#parse_value) }; - let dup_err = quote! { return std::result::Result::Err(vespera::multipart::TypedMultipartError::DuplicateField { field_name: std::string::String::from(#field_name) }) }; - quote! { if #ident.is_none() { #set_value ; } else { #dup_err ; } } - } else { - quote! { #ident = std::option::Option::Some(#parse_value); } - }; - - let field_match = quote! { if __field_name__ == #field_name { #assignment } }; - cg.assignments.push(field_match); - - // Post-loop: required field checks / defaults - if !is_option && !is_vec { - match &default_kind { - DefaultKind::Trait => { - cg.post_loop.push(quote! { - let #ident: #ty = #ident.unwrap_or_default(); - }); - } - DefaultKind::Function(fn_path) => { - let path: syn::ExprPath = - syn::parse_str(fn_path).expect("invalid default function path"); - cg.post_loop.push(quote! { - let #ident: #ty = #ident.unwrap_or_else(#path); - }); - } - DefaultKind::None => { - cg.post_loop.push(quote! { - let #ident = #ident.ok_or( - vespera::multipart::TypedMultipartError::MissingField { - field_name: std::string::String::from(#field_name) - } - )?; - }); - } - } - } - - cg.idents.push(ident); - } - - cg -} - -/// Process the `#[derive(TryFromMultipart)]` macro input. -pub fn process_derive(input: &DeriveInput) -> TokenStream { - let struct_name = &input.ident; - let rename_all = extract_rename_all(&input.attrs); - let strict = extract_strict(&input.attrs); - let struct_default = extract_struct_default(&input.attrs); - - let fields = match &input.data { - syn::Data::Struct(data) => match &data.fields { - Fields::Named(named) => &named.named, - _ => { - return syn::Error::new_spanned( - &input.ident, - "Multipart only supports structs with named fields", - ) - .to_compile_error(); - } - }, - _ => { - return syn::Error::new_spanned( - &input.ident, - "Multipart can only be derived for structs", - ) - .to_compile_error(); - } - }; - - let mut cg = process_fields(fields.iter(), rename_all.as_deref(), strict, struct_default); - - if strict { - cg.assignments.push(quote! { - { - return std::result::Result::Err( - vespera::multipart::TypedMultipartError::UnknownField { - field_name: __field_name__ - } - ); - } - }); - } - - let missing_name_fallback = if strict { - quote! { - return std::result::Result::Err( - vespera::multipart::TypedMultipartError::NamelessField - ) - } - } else { - quote! { continue } - }; - - let FieldCodegen { - declarations, - assignments, - post_loop, - idents, - .. - } = &cg; - - quote! { - impl<__VesperaS__: Send + Sync> vespera::multipart::TryFromMultipartWithState<__VesperaS__> for #struct_name { - async fn try_from_multipart_with_state( - __multipart__: &mut vespera::axum::extract::Multipart, - __state__: &__VesperaS__, - ) -> std::result::Result { - #(#declarations)* - - while let std::option::Option::Some(__field__) = __multipart__ - .next_field().await - .map_err(vespera::multipart::TypedMultipartError::from)? { - let __field_name__ = match __field__.name() { - | std::option::Option::Some("") - | std::option::Option::None => #missing_name_fallback, - | std::option::Option::Some(__name__) => __name__.to_string(), - }; - - #(#assignments) else * - } - - #(#post_loop)* - - std::result::Result::Ok(Self { #(#idents),* }) - } - } - } -} - -// ─── Field Name Resolution ────────────────────────────────────────────────── - -/// Resolve the multipart field name using serde + form_data attributes. -/// -/// Priority: -/// 1. `#[form_data(field_name = "...")]` -/// 2. `#[serde(rename = "...")]` -/// 3. struct-level `rename_all` applied to Rust field name -/// 4. Rust field name as-is -fn resolve_field_name( - ident: &syn::Ident, - attrs: &[syn::Attribute], - rename_all: Option<&str>, -) -> String { - // 1. Explicit form_data override - if let Some(name) = extract_form_data_field_name(attrs) { - return name; - } - - // 2. Serde field rename - if let Some(name) = extract_field_rename(attrs) { - return name; - } - - // 3. Apply rename_all to Rust field name - let rust_name = strip_raw_prefix(&ident.to_string()); - rename_field(&rust_name, rename_all) -} - -// ─── Attribute Extraction ─────────────────────────────────────────────────── - -/// Extract `field_name` from `#[form_data(field_name = "...")]`. -fn extract_form_data_field_name(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if attr.path().is_ident("form_data") { - let mut found = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("field_name") - && let Ok(value) = meta.value() - && let Ok(lit) = value.parse::() - { - found = Some(lit.value()); - } - Ok(()) - }); - if found.is_some() { - return found; - } - } - } - None -} - -/// Extract `strict` flag from `#[try_from_multipart(strict)]`. -fn extract_strict(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("try_from_multipart") { - let mut strict = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("strict") { - strict = true; - } - Ok(()) - }); - if strict { - return true; - } - } - } - false -} - -/// Extract `limit` from `#[form_data(limit = "10MiB")]` and emit as `Option` tokens. -fn extract_limit_tokens(attrs: &[syn::Attribute]) -> TokenStream { - for attr in attrs { - if attr.path().is_ident("form_data") { - let mut limit_str = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("limit") - && let Ok(value) = meta.value() - && let Ok(lit) = value.parse::() - { - limit_str = Some(lit.value()); - } - Ok(()) - }); - if let Some(s) = limit_str { - if s == "unlimited" { - return quote! { std::option::Option::None }; - } - if let Some(bytes) = parse_byte_unit(&s) { - return quote! { std::option::Option::Some(#bytes) }; - } - } - } - } - // Default: no limit (None) - quote! { std::option::Option::None } -} - -/// Resolve the default behavior for a field. -/// -/// Priority: -/// 1. `#[form_data(default)]` — explicit form_data override (bare default) -/// 2. `#[serde(default)]` — bare default via `Default::default()` -/// 3. `#[serde(default = "fn_path")]` — custom default function -/// 4. Struct-level `#[serde(default)]` — all fields get `Default::default()` -/// 5. No default — field is required -fn resolve_default_kind(attrs: &[syn::Attribute], struct_default: bool) -> DefaultKind { - // 1. Check #[form_data(default)] - if extract_form_data_default(attrs) { - return DefaultKind::Trait; - } - - // 2-3. Check #[serde(default)] or #[serde(default = "fn")] - if let Some(serde_default) = extract_default(attrs) { - return serde_default.map_or(DefaultKind::Trait, DefaultKind::Function); - } - - // 4. Struct-level #[serde(default)] - if struct_default { - return DefaultKind::Trait; - } - - DefaultKind::None -} - -/// Extract `default` flag from `#[form_data(default)]`. -fn extract_form_data_default(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("form_data") { - let mut has_default = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("default") { - has_default = true; - } - Ok(()) - }); - if has_default { - return true; - } - } - } - false -} - -/// Check if the struct has `#[serde(default)]` at the struct level. -fn extract_struct_default(attrs: &[syn::Attribute]) -> bool { - // Reuse extract_default — if it returns Some(None), it's bare #[serde(default)] - // For struct-level, we only support bare default (no custom function) - extract_default(attrs).is_some() -} - -// ─── Type Utilities ───────────────────────────────────────────────────────── - -/// Extract the first generic type argument from a type like `Option` or `Vec`. -fn extract_inner_generic(ty: &Type) -> Option { - let Type::Path(type_path) = ty else { - return None; - }; - let segment = type_path.path.segments.last()?; - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner)) = args.args.first() - { - return Some(inner.clone()); - } - None -} - -/// Check if a type matches `Option`. -fn is_option_type(ty: &Type) -> bool { - matches_type_name( - ty, - &["Option", "std::option::Option", "core::option::Option"], - ) -} - -/// Check if a type matches `Vec`. -fn is_vec_type(ty: &Type) -> bool { - matches_type_name(ty, &["Vec", "std::vec::Vec"]) -} - -/// Check if a type's path matches any of the given names. -fn matches_type_name(ty: &Type, names: &[&str]) -> bool { - let path = match ty { - Type::Path(type_path) if type_path.qself.is_none() => &type_path.path, - _ => return false, - }; - let sig = path - .segments - .iter() - .map(|s| s.ident.to_string()) - .collect::>() - .join("::"); - names.contains(&sig.as_str()) -} - -/// Strip leading `r#` from raw identifiers. -fn strip_raw_prefix(s: &str) -> String { - s.strip_prefix("r#").unwrap_or(s).to_string() -} - -// ─── Byte Unit Parser ─────────────────────────────────────────────────────── - -/// Parse a human-readable byte unit string into bytes. -/// -/// Supports: `"10MiB"`, `"1GB"`, `"500KB"`, `"1024"`, `"unlimited"`. -fn parse_byte_unit(s: &str) -> Option { - let s = s.trim(); - - // Binary and decimal suffixes, longest first to avoid prefix collisions - let suffixes: &[(&str, usize)] = &[ - ("GiB", 1024 * 1024 * 1024), - ("MiB", 1024 * 1024), - ("KiB", 1024), - ("GB", 1_000_000_000), - ("MB", 1_000_000), - ("KB", 1_000), - ("B", 1), - ]; - - for (suffix, multiplier) in suffixes { - if let Some(num_str) = s.strip_suffix(suffix) { - return num_str.trim().parse::().ok().map(|n| n * multiplier); - } - } - - // Plain number (bytes) - s.parse::().ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_byte_unit() { - assert_eq!(parse_byte_unit("10MiB"), Some(10 * 1024 * 1024)); - assert_eq!(parse_byte_unit("50MiB"), Some(50 * 1024 * 1024)); - assert_eq!(parse_byte_unit("1GB"), Some(1_000_000_000)); - assert_eq!(parse_byte_unit("500KB"), Some(500_000)); - assert_eq!(parse_byte_unit("1024"), Some(1024)); - assert_eq!(parse_byte_unit("0"), Some(0)); - assert_eq!(parse_byte_unit("invalid"), None); - } - - #[test] - fn test_parse_byte_unit_all_suffixes() { - assert_eq!(parse_byte_unit("1GiB"), Some(1024 * 1024 * 1024)); - assert_eq!(parse_byte_unit("2KiB"), Some(2 * 1024)); - assert_eq!(parse_byte_unit("3MB"), Some(3_000_000)); - assert_eq!(parse_byte_unit("4B"), Some(4)); - assert_eq!(parse_byte_unit(" 5MiB "), Some(5 * 1024 * 1024)); - } - - #[test] - fn test_strip_raw_prefix() { - assert_eq!(strip_raw_prefix("r#type"), "type"); - assert_eq!(strip_raw_prefix("normal"), "normal"); - } - - // ─── extract_inner_generic ────────────────────────────────────────── - - #[test] - fn test_extract_inner_generic_option() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - let inner = extract_inner_generic(&ty).unwrap(); - assert_eq!(quote!(#inner).to_string(), "String"); - } - - #[test] - fn test_extract_inner_generic_vec() { - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - let inner = extract_inner_generic(&ty).unwrap(); - assert_eq!(quote!(#inner).to_string(), "i32"); - } - - #[test] - fn test_extract_inner_generic_no_generics() { - let ty: syn::Type = syn::parse_str("String").unwrap(); - assert!(extract_inner_generic(&ty).is_none()); - } - - #[test] - fn test_extract_inner_generic_non_path() { - let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); - assert!(extract_inner_generic(&ty).is_none()); - } - - // ─── is_option_type / is_vec_type ─────────────────────────────────── - - #[test] - fn test_is_option_type() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(is_option_type(&ty)); - - let ty: syn::Type = syn::parse_str("std::option::Option").unwrap(); - assert!(is_option_type(&ty)); - - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - assert!(!is_option_type(&ty)); - - let ty: syn::Type = syn::parse_str("String").unwrap(); - assert!(!is_option_type(&ty)); - } - - #[test] - fn test_is_vec_type() { - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - assert!(is_vec_type(&ty)); - - let ty: syn::Type = syn::parse_str("std::vec::Vec").unwrap(); - assert!(is_vec_type(&ty)); - - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(!is_vec_type(&ty)); - - let ty: syn::Type = syn::parse_str("String").unwrap(); - assert!(!is_vec_type(&ty)); - } - - // ─── matches_type_name ────────────────────────────────────────────── - - #[test] - fn test_matches_type_name_simple() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(matches_type_name(&ty, &["Option"])); - assert!(!matches_type_name(&ty, &["Vec"])); - } - - #[test] - fn test_matches_type_name_qualified() { - let ty: syn::Type = syn::parse_str("std::option::Option").unwrap(); - assert!(matches_type_name(&ty, &["std::option::Option"])); - assert!(!matches_type_name(&ty, &["Option"])); // qualified doesn't match simple - } - - #[test] - fn test_matches_type_name_non_path() { - let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); - assert!(!matches_type_name(&ty, &["Option", "Vec"])); - } - - // ─── extract_form_data_field_name ─────────────────────────────────── - - fn parse_field(code: &str) -> syn::Field { - let input: syn::DeriveInput = syn::parse_str(&format!("struct T {{ {code} }}")).unwrap(); - match &input.data { - syn::Data::Struct(s) => match &s.fields { - Fields::Named(n) => n.named.first().unwrap().clone(), - _ => unreachable!(), - }, - _ => unreachable!(), - } - } - - fn parse_attrs(code: &str) -> Vec { - parse_field(code).attrs - } - - #[test] - fn test_extract_form_data_field_name_present() { - let attrs = parse_attrs(r#"#[form_data(field_name = "custom")] pub x: String"#); - assert_eq!( - extract_form_data_field_name(&attrs), - Some("custom".to_string()) - ); - } - - #[test] - fn test_extract_form_data_field_name_absent() { - let attrs = parse_attrs("pub x: String"); - assert_eq!(extract_form_data_field_name(&attrs), None); - } - - #[test] - fn test_extract_form_data_field_name_other_form_data_attr() { - let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: String"#); - assert_eq!(extract_form_data_field_name(&attrs), None); - } - - // ─── extract_strict ───────────────────────────────────────────────── - - fn parse_struct_attrs(code: &str) -> Vec { - let input: syn::DeriveInput = syn::parse_str(code).unwrap(); - input.attrs - } - - #[test] - fn test_extract_strict_present() { - let attrs = parse_struct_attrs("#[try_from_multipart(strict)] struct T { }"); - assert!(extract_strict(&attrs)); - } - - #[test] - fn test_extract_strict_absent() { - let attrs = parse_struct_attrs("struct T { }"); - assert!(!extract_strict(&attrs)); - } - - #[test] - fn test_extract_strict_other_attr() { - let attrs = - parse_struct_attrs("#[try_from_multipart(rename_all = \"camelCase\")] struct T { }"); - assert!(!extract_strict(&attrs)); - } - - // ─── extract_form_data_default ────────────────────────────────────── - - #[test] - fn test_extract_form_data_default_present() { - let attrs = parse_attrs("#[form_data(default)] pub x: i32"); - assert!(extract_form_data_default(&attrs)); - } - - #[test] - fn test_extract_form_data_default_absent() { - let attrs = parse_attrs("pub x: i32"); - assert!(!extract_form_data_default(&attrs)); - } - - #[test] - fn test_extract_form_data_default_other_form_data() { - let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: i32"#); - assert!(!extract_form_data_default(&attrs)); - } - - // ─── extract_struct_default ───────────────────────────────────────── - - #[test] - fn test_extract_struct_default_present() { - let attrs = parse_struct_attrs("#[serde(default)] struct T { }"); - assert!(extract_struct_default(&attrs)); - } - - #[test] - fn test_extract_struct_default_absent() { - let attrs = parse_struct_attrs("struct T { }"); - assert!(!extract_struct_default(&attrs)); - } - - // ─── resolve_default_kind ─────────────────────────────────────────── - - #[test] - fn test_resolve_default_kind_none() { - let attrs = parse_attrs("pub x: i32"); - assert!(matches!( - resolve_default_kind(&attrs, false), - DefaultKind::None - )); - } - - #[test] - fn test_resolve_default_kind_serde_default() { - let attrs = parse_attrs("#[serde(default)] pub x: i32"); - assert!(matches!( - resolve_default_kind(&attrs, false), - DefaultKind::Trait - )); - } - - #[test] - fn test_resolve_default_kind_serde_default_fn() { - let attrs = parse_attrs(r#"#[serde(default = "my_fn")] pub x: i32"#); - assert!( - matches!(resolve_default_kind(&attrs, false), DefaultKind::Function(ref f) if f == "my_fn") - ); - } - - #[test] - fn test_resolve_default_kind_form_data_default() { - let attrs = parse_attrs("#[form_data(default)] pub x: i32"); - assert!(matches!( - resolve_default_kind(&attrs, false), - DefaultKind::Trait - )); - } - - #[test] - fn test_resolve_default_kind_struct_level() { - let attrs = parse_attrs("pub x: i32"); - assert!(matches!( - resolve_default_kind(&attrs, true), - DefaultKind::Trait - )); - } - - #[test] - fn test_resolve_default_kind_form_data_overrides_struct_default() { - // form_data(default) takes priority, but result is the same (Trait) - let attrs = parse_attrs("#[form_data(default)] pub x: i32"); - assert!(matches!( - resolve_default_kind(&attrs, true), - DefaultKind::Trait - )); - } - - // ─── resolve_field_name ───────────────────────────────────────────── - - #[test] - fn test_resolve_field_name_plain() { - let field = parse_field("pub my_field: String"); - let name = resolve_field_name(field.ident.as_ref().unwrap(), &field.attrs, None); - assert_eq!(name, "my_field"); - } - - #[test] - fn test_resolve_field_name_rename_all() { - let field = parse_field("pub my_field: String"); - let name = resolve_field_name( - field.ident.as_ref().unwrap(), - &field.attrs, - Some("camelCase"), - ); - assert_eq!(name, "myField"); - } - - #[test] - fn test_resolve_field_name_serde_rename() { - let field = parse_field(r#"#[serde(rename = "custom")] pub my_field: String"#); - let name = resolve_field_name( - field.ident.as_ref().unwrap(), - &field.attrs, - Some("camelCase"), - ); - assert_eq!(name, "custom"); // explicit rename beats rename_all - } - - #[test] - fn test_resolve_field_name_form_data_field_name() { - let field = parse_field( - r#"#[form_data(field_name = "override")] #[serde(rename = "serde_name")] pub my_field: String"#, - ); - let name = resolve_field_name( - field.ident.as_ref().unwrap(), - &field.attrs, - Some("camelCase"), - ); - assert_eq!(name, "override"); // form_data field_name beats everything - } - - // ─── extract_limit_tokens ─────────────────────────────────────────── - - #[test] - fn test_extract_limit_tokens_none() { - let attrs = parse_attrs("pub x: String"); - let tokens = extract_limit_tokens(&attrs); - assert_eq!(tokens.to_string(), "std :: option :: Option :: None"); - } - - #[test] - fn test_extract_limit_tokens_with_value() { - let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: String"#); - let tokens = extract_limit_tokens(&attrs); - assert_eq!( - tokens.to_string(), - "std :: option :: Option :: Some (100usize)" - ); - } - - #[test] - fn test_extract_limit_tokens_unlimited() { - let attrs = parse_attrs(r#"#[form_data(limit = "unlimited")] pub x: String"#); - let tokens = extract_limit_tokens(&attrs); - assert_eq!(tokens.to_string(), "std :: option :: Option :: None"); - } - - #[test] - fn test_extract_limit_tokens_mib() { - let attrs = parse_attrs(r#"#[form_data(limit = "10MiB")] pub x: String"#); - let tokens = extract_limit_tokens(&attrs); - let expected = 10 * 1024 * 1024; - assert_eq!( - tokens.to_string(), - format!("std :: option :: Option :: Some ({expected}usize)") - ); - } - - // ─── process_derive ───────────────────────────────────────────────── - - #[test] - fn test_process_derive_basic_struct() { - let input: syn::DeriveInput = - syn::parse_str("struct MyForm { pub name: String, pub age: i32 }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("TryFromMultipartWithState"), - "should generate trait impl" - ); - assert!(code.contains("MyForm"), "should reference the struct name"); - assert!(code.contains("\"name\""), "should reference field name"); - assert!(code.contains("\"age\""), "should reference field name"); - } - - #[test] - fn test_process_derive_with_option_field() { - let input: syn::DeriveInput = - syn::parse_str("struct MyForm { pub name: String, pub bio: Option }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!(code.contains("TryFromMultipartWithState")); - // Option fields get initialized to None, no MissingField check - assert!(code.contains("Option :: None")); - } - - #[test] - fn test_process_derive_with_vec_field() { - let input: syn::DeriveInput = - syn::parse_str("struct MyForm { pub name: String, pub tags: Vec }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("Vec :: new"), - "Vec fields should be initialized with Vec::new()" - ); - assert!(code.contains("push"), "Vec fields should use push()"); - } - - #[test] - fn test_process_derive_strict_mode() { - let input: syn::DeriveInput = - syn::parse_str("#[try_from_multipart(strict)] struct MyForm { pub name: String }") - .unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("DuplicateField"), - "strict mode should check for duplicates" - ); - assert!( - code.contains("UnknownField"), - "strict mode should reject unknown fields" - ); - assert!( - code.contains("NamelessField"), - "strict mode should reject nameless fields" - ); - } - - #[test] - fn test_process_derive_with_rename_all() { - let input: syn::DeriveInput = syn::parse_str( - r#"#[serde(rename_all = "camelCase")] struct MyForm { pub user_name: String }"#, - ) - .unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("\"userName\""), - "rename_all should convert to camelCase" - ); - } - - #[test] - fn test_process_derive_with_serde_default() { - let input: syn::DeriveInput = - syn::parse_str("#[serde(default)] struct MyForm { pub count: i32 }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("unwrap_or_default"), - "struct-level default should use unwrap_or_default" - ); - } - - #[test] - fn test_process_derive_with_field_default_fn() { - let input: syn::DeriveInput = - syn::parse_str(r#"struct MyForm { #[serde(default = "my_default")] pub val: String }"#) - .unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("unwrap_or_else"), - "field default fn should use unwrap_or_else" - ); - assert!( - code.contains("my_default"), - "should reference the default function" - ); - } - - #[test] - fn test_process_derive_non_struct_errors() { - let input: syn::DeriveInput = syn::parse_str("enum Foo { A, B }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("compile_error"), - "enums should produce compile error" - ); - } - - #[test] - fn test_process_derive_tuple_struct_errors() { - let input: syn::DeriveInput = syn::parse_str("struct Foo(String, i32);").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("compile_error"), - "tuple structs should produce compile error" - ); - } - - #[test] - fn test_process_derive_form_data_field_name() { - let input: syn::DeriveInput = syn::parse_str( - r#"struct MyForm { #[form_data(field_name = "custom")] pub data: String }"#, - ) - .unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("\"custom\""), - "form_data field_name should be used" - ); - } - - #[test] - fn test_process_derive_form_data_default() { - let input: syn::DeriveInput = - syn::parse_str("struct MyForm { #[form_data(default)] pub count: i32 }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - code.contains("unwrap_or_default"), - "form_data(default) should use unwrap_or_default" - ); - } - - #[test] - fn test_process_derive_non_strict_no_duplicate_check() { - let input: syn::DeriveInput = syn::parse_str("struct MyForm { pub name: String }").unwrap(); - let tokens = process_derive(&input); - let code = tokens.to_string(); - assert!( - !code.contains("DuplicateField"), - "non-strict should not check for duplicates" - ); - assert!( - !code.contains("UnknownField"), - "non-strict should not check for unknown fields" - ); - } - - // ─── process_fields direct tests ──────────────────────────────────── - // - // Exercise process_fields directly to ensure quote! token construction - // for each branch (parse_value, strict assignment, field matching) is - // fully traced by the coverage tool. - - fn parse_fields_from(code: &str) -> syn::DeriveInput { - syn::parse_str(code).unwrap() - } - - fn get_named_fields( - input: &syn::DeriveInput, - ) -> &syn::punctuated::Punctuated { - match &input.data { - syn::Data::Struct(s) => match &s.fields { - Fields::Named(n) => &n.named, - _ => panic!("expected named fields"), - }, - _ => panic!("expected struct"), - } - } - - #[test] - fn test_process_fields_required_field_generates_parse_value() { - let input = parse_fields_from("struct T { pub name: String }"); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, false, false); - - // parse_value is interpolated into each assignment - let assignment_code = cg - .assignments - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - assignment_code.contains("TryFromFieldWithState"), - "parse_value should contain turbofish call" - ); - assert!( - assignment_code.contains("try_from_field_with_state"), - "should call try_from_field_with_state" - ); - assert!( - assignment_code.contains("\"name\""), - "should match on field name" - ); - - // post_loop should have MissingField check for required fields - let post_code = cg - .post_loop - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - post_code.contains("MissingField"), - "required field should have MissingField check" - ); - } - - #[test] - fn test_process_fields_strict_required_field_generates_duplicate_check() { - let input = parse_fields_from("struct T { pub name: String, pub age: i32 }"); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, true, false); - - // strict mode: assignments should contain is_none + DuplicateField check - let assignment_code = cg - .assignments - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - assignment_code.contains("is_none"), - "strict assignment should check is_none" - ); - assert!( - assignment_code.contains("DuplicateField"), - "strict assignment should have DuplicateField" - ); - assert!( - assignment_code.contains("\"name\""), - "should match name field" - ); - assert!( - assignment_code.contains("\"age\""), - "should match age field" - ); - - // Both fields should have parse_value with turbofish - assert!( - assignment_code.contains("TryFromFieldWithState"), - "should contain turbofish" - ); - } - - #[test] - fn test_process_fields_vec_field_generates_push() { - let input = parse_fields_from("struct T { pub tags: Vec }"); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, false, false); - - let decl_code = cg - .declarations - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - decl_code.contains("Vec :: new"), - "Vec field should initialize with Vec::new()" - ); - - let assignment_code = cg - .assignments - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - assignment_code.contains("push"), - "Vec field assignment should use push" - ); - - // Vec fields should NOT have post_loop (no MissingField check) - assert!( - cg.post_loop.is_empty(), - "Vec fields should not have post-loop checks" - ); - } - - #[test] - fn test_process_fields_option_field_no_missing_check() { - let input = parse_fields_from("struct T { pub bio: Option }"); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, false, false); - - let decl_code = cg - .declarations - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - decl_code.contains("Option :: None"), - "Option field should initialize to None" - ); - - // Option fields should NOT have post_loop - assert!( - cg.post_loop.is_empty(), - "Option fields should not have post-loop checks" - ); - } - - #[test] - fn test_process_fields_strict_vec_field_uses_push_not_duplicate() { - let input = parse_fields_from("struct T { pub tags: Vec }"); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, true, false); - - // Even in strict mode, Vec fields use push (not duplicate check) - let assignment_code = cg - .assignments - .iter() - .map(ToString::to_string) - .collect::>() - .join(" "); - assert!( - assignment_code.contains("push"), - "Vec in strict mode should still use push" - ); - assert!( - !assignment_code.contains("DuplicateField"), - "Vec should not have duplicate check" - ); - } - - #[test] - fn test_process_fields_mixed_types() { - let input = parse_fields_from( - "struct T { pub name: String, pub tags: Vec, pub bio: Option }", - ); - let fields = get_named_fields(&input); - let cg = process_fields(fields.iter(), None, false, false); - - assert_eq!(cg.idents.len(), 3, "should have 3 fields"); - assert_eq!(cg.declarations.len(), 3, "should have 3 declarations"); - assert_eq!(cg.assignments.len(), 3, "should have 3 assignments"); - // Only 'name' is required (not Option, not Vec), so 1 post_loop - assert_eq!( - cg.post_loop.len(), - 1, - "only required field should have post-loop" - ); - } -} diff --git a/crates/vespera_macro/src/multipart_impl/attrs.rs b/crates/vespera_macro/src/multipart_impl/attrs.rs new file mode 100644 index 00000000..88078dd4 --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/attrs.rs @@ -0,0 +1,450 @@ +use proc_macro2::TokenStream; +use quote::quote; + +use super::fields::DefaultKind; +use super::types::{parse_byte_unit, strip_raw_prefix}; +use crate::parser::{extract_default, extract_field_rename, rename_field}; + +/// Resolve the multipart field name using serde + form_data attributes. +/// +/// Priority: +/// 1. `#[form_data(field_name = "...")]` +/// 2. `#[serde(rename = "...")]` +/// 3. struct-level `rename_all` applied to Rust field name +/// 4. Rust field name as-is +pub(super) fn resolve_field_name( + ident: &syn::Ident, + attrs: &[syn::Attribute], + rename_all: Option<&str>, +) -> String { + if let Some(name) = extract_form_data_field_name(attrs) { + return name; + } + if let Some(name) = extract_field_rename(attrs) { + return name; + } + let rust_name = strip_raw_prefix(&ident.to_string()); + rename_field(&rust_name, rename_all) +} + +/// Extract `field_name` from `#[form_data(field_name = "...")]`. +fn extract_form_data_field_name(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("form_data") { + let mut found = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("field_name") + && let Ok(value) = meta.value() + && let Ok(lit) = value.parse::() + { + found = Some(lit.value()); + } + Ok(()) + }); + if found.is_some() { + return found; + } + } + } + None +} + +/// Extract `strict` flag from `#[try_from_multipart(strict)]`. +pub(super) fn extract_strict(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("try_from_multipart") { + let mut strict = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("strict") { + strict = true; + } + Ok(()) + }); + if strict { + return true; + } + } + } + false +} + +/// Extract `limit` from `#[form_data(limit = "10MiB")]` and emit as `Option` tokens. +pub(super) fn extract_limit_tokens(attrs: &[syn::Attribute]) -> TokenStream { + for attr in attrs { + if attr.path().is_ident("form_data") { + let mut limit_str = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("limit") + && let Ok(value) = meta.value() + && let Ok(lit) = value.parse::() + { + limit_str = Some(lit.value()); + } + Ok(()) + }); + if let Some(s) = limit_str { + if s == "unlimited" { + // `usize::MAX` is the explicit unbounded sentinel: every + // limit check (`total > limit`) is byte-for-byte + // equivalent to the former `None` (never triggers), but + // it is DISTINGUISHABLE from an ABSENT attribute (which + // stays `None` below). That lets the runtime apply a + // default cap to unannotated text fields (`String`) while + // an explicit `limit = "unlimited"` opt-out stays + // genuinely unbounded. + return quote! { std::option::Option::Some(usize::MAX) }; + } + if let Some(bytes) = parse_byte_unit(&s) { + return quote! { std::option::Option::Some(#bytes) }; + } + } + } + } + quote! { std::option::Option::None } +} + +/// Whether the field carries an explicit, VALID `#[form_data(limit = ...)]` +/// — either `"unlimited"` or a parseable byte size (e.g. `"10MiB"`). +/// +/// An absent attribute, a non-`limit` `form_data` key, or an unparseable +/// value all return `false`. The `Multipart` derive treats that as a +/// missing limit on a file field and emits a compile error, so an unbounded +/// upload is never accepted silently. +#[cfg(test)] +pub(super) fn has_explicit_limit(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("form_data") { + let mut valid = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("limit") + && let Ok(value) = meta.value() + && let Ok(lit) = value.parse::() + { + let s = lit.value(); + valid = s == "unlimited" || parse_byte_unit(&s).is_some(); + } + Ok(()) + }); + if valid { + return true; + } + } + } + false +} + +/// Resolve the default behavior for a field. +/// +/// Priority: +/// 1. `#[form_data(default)]` — explicit form_data override (bare default) +/// 2. `#[serde(default)]` — bare default via `Default::default()` +/// 3. `#[serde(default = "fn_path")]` — custom default function +/// 4. Struct-level `#[serde(default)]` — all fields get `Default::default()` +/// 5. No default — field is required +pub(super) fn resolve_default_kind(attrs: &[syn::Attribute], struct_default: bool) -> DefaultKind { + if extract_form_data_default(attrs) { + return DefaultKind::Trait; + } + if let Some(serde_default) = extract_default(attrs) { + return serde_default.map_or(DefaultKind::Trait, DefaultKind::Function); + } + if struct_default { + return DefaultKind::Trait; + } + DefaultKind::None +} + +/// Extract `default` flag from `#[form_data(default)]`. +fn extract_form_data_default(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("form_data") { + let mut has_default = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("default") { + has_default = true; + } + Ok(()) + }); + if has_default { + return true; + } + } + } + false +} + +/// Check if the struct has `#[serde(default)]` at the struct level. +pub(super) fn extract_struct_default(attrs: &[syn::Attribute]) -> bool { + extract_default(attrs).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::Fields; + + fn parse_field(code: &str) -> syn::Field { + let input: syn::DeriveInput = syn::parse_str(&format!("struct T {{ {code} }}")).unwrap(); + match &input.data { + syn::Data::Struct(s) => match &s.fields { + Fields::Named(n) => n.named.first().unwrap().clone(), + _ => unreachable!(), + }, + _ => unreachable!(), + } + } + + fn parse_attrs(code: &str) -> Vec { + parse_field(code).attrs + } + + fn parse_struct_attrs(code: &str) -> Vec { + let input: syn::DeriveInput = syn::parse_str(code).unwrap(); + input.attrs + } + + #[test] + fn test_extract_form_data_field_name_present() { + let attrs = parse_attrs(r#"#[form_data(field_name = "custom")] pub x: String"#); + assert_eq!( + extract_form_data_field_name(&attrs), + Some("custom".to_string()) + ); + } + + #[test] + fn test_extract_form_data_field_name_absent() { + assert_eq!( + extract_form_data_field_name(&parse_attrs("pub x: String")), + None + ); + } + + #[test] + fn test_extract_form_data_field_name_other_form_data_attr() { + let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: String"#); + assert_eq!(extract_form_data_field_name(&attrs), None); + } + + #[test] + fn test_extract_strict_present() { + let attrs = parse_struct_attrs("#[try_from_multipart(strict)] struct T { }"); + assert!(extract_strict(&attrs)); + } + + #[test] + fn test_extract_strict_absent() { + let attrs = parse_struct_attrs("struct T { }"); + assert!(!extract_strict(&attrs)); + } + + #[test] + fn test_extract_strict_other_attr() { + let attrs = + parse_struct_attrs("#[try_from_multipart(rename_all = \"camelCase\")] struct T { }"); + assert!(!extract_strict(&attrs)); + } + + #[test] + fn test_extract_form_data_default_present() { + assert!(extract_form_data_default(&parse_attrs( + "#[form_data(default)] pub x: i32" + ))); + } + + #[test] + fn test_extract_form_data_default_absent() { + assert!(!extract_form_data_default(&parse_attrs("pub x: i32"))); + } + + #[test] + fn test_extract_form_data_default_other_form_data() { + let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: i32"#); + assert!(!extract_form_data_default(&attrs)); + } + + #[test] + fn test_extract_struct_default_present() { + let attrs = parse_struct_attrs("#[serde(default)] struct T { }"); + assert!(extract_struct_default(&attrs)); + } + + #[test] + fn test_extract_struct_default_absent() { + let attrs = parse_struct_attrs("struct T { }"); + assert!(!extract_struct_default(&attrs)); + } + + #[test] + fn test_resolve_default_kind_none() { + let attrs = parse_attrs("pub x: i32"); + assert!(matches!( + resolve_default_kind(&attrs, false), + DefaultKind::None + )); + } + + #[test] + fn test_resolve_default_kind_serde_default() { + let attrs = parse_attrs("#[serde(default)] pub x: i32"); + assert!(matches!( + resolve_default_kind(&attrs, false), + DefaultKind::Trait + )); + } + + #[test] + fn test_resolve_default_kind_serde_default_fn() { + let attrs = parse_attrs(r#"#[serde(default = "my_fn")] pub x: i32"#); + assert!( + matches!(resolve_default_kind(&attrs, false), DefaultKind::Function(ref f) if f == "my_fn") + ); + } + + #[test] + fn test_resolve_default_kind_form_data_default() { + let attrs = parse_attrs("#[form_data(default)] pub x: i32"); + assert!(matches!( + resolve_default_kind(&attrs, false), + DefaultKind::Trait + )); + } + + #[test] + fn test_resolve_default_kind_struct_level() { + let attrs = parse_attrs("pub x: i32"); + assert!(matches!( + resolve_default_kind(&attrs, true), + DefaultKind::Trait + )); + } + + #[test] + fn test_resolve_default_kind_form_data_overrides_struct_default() { + let attrs = parse_attrs("#[form_data(default)] pub x: i32"); + assert!(matches!( + resolve_default_kind(&attrs, true), + DefaultKind::Trait + )); + } + + #[test] + fn test_resolve_field_name_plain() { + let field = parse_field("pub my_field: String"); + let name = resolve_field_name(field.ident.as_ref().unwrap(), &field.attrs, None); + assert_eq!(name, "my_field"); + } + + #[test] + fn test_resolve_field_name_rename_all() { + let field = parse_field("pub my_field: String"); + let name = resolve_field_name( + field.ident.as_ref().unwrap(), + &field.attrs, + Some("camelCase"), + ); + assert_eq!(name, "myField"); + } + + #[test] + fn test_resolve_field_name_serde_rename() { + let field = parse_field(r#"#[serde(rename = "custom")] pub my_field: String"#); + let name = resolve_field_name( + field.ident.as_ref().unwrap(), + &field.attrs, + Some("camelCase"), + ); + assert_eq!(name, "custom"); + } + + #[test] + fn test_resolve_field_name_form_data_field_name() { + let field = parse_field( + r#"#[form_data(field_name = "override")] #[serde(rename = "serde_name")] pub my_field: String"#, + ); + let name = resolve_field_name( + field.ident.as_ref().unwrap(), + &field.attrs, + Some("camelCase"), + ); + assert_eq!(name, "override"); + } + + #[test] + fn test_extract_limit_tokens_none() { + assert_eq!( + extract_limit_tokens(&parse_attrs("pub x: String")).to_string(), + "std :: option :: Option :: None" + ); + } + + #[test] + fn test_extract_limit_tokens_with_value() { + let attrs = parse_attrs(r#"#[form_data(limit = "100")] pub x: String"#); + assert_eq!( + extract_limit_tokens(&attrs).to_string(), + "std :: option :: Option :: Some (100usize)" + ); + } + + #[test] + fn test_extract_limit_tokens_unlimited() { + // `"unlimited"` now emits the `usize::MAX` unbounded sentinel (not + // `None`) so the runtime can tell an explicit opt-out apart from an + // absent attribute and still apply a default cap to the latter. + let attrs = parse_attrs(r#"#[form_data(limit = "unlimited")] pub x: String"#); + assert_eq!( + extract_limit_tokens(&attrs).to_string(), + "std :: option :: Option :: Some (usize :: MAX)" + ); + } + + #[test] + fn test_extract_limit_tokens_mib() { + let attrs = parse_attrs(r#"#[form_data(limit = "10MiB")] pub x: String"#); + let expected = 10 * 1024 * 1024; + assert_eq!( + extract_limit_tokens(&attrs).to_string(), + format!("std :: option :: Option :: Some ({expected}usize)") + ); + } + + #[test] + fn test_has_explicit_limit_size() { + assert!(has_explicit_limit(&parse_attrs( + r#"#[form_data(limit = "10MiB")] pub x: String"# + ))); + assert!(has_explicit_limit(&parse_attrs( + r#"#[form_data(limit = "100")] pub x: String"# + ))); + } + + #[test] + fn test_has_explicit_limit_unlimited() { + assert!(has_explicit_limit(&parse_attrs( + r#"#[form_data(limit = "unlimited")] pub x: String"# + ))); + } + + #[test] + fn test_has_explicit_limit_absent() { + assert!(!has_explicit_limit(&parse_attrs("pub x: String"))); + } + + #[test] + fn test_has_explicit_limit_invalid_value() { + // An unparseable size is NOT a valid limit — treated as missing so a + // file field with `limit = "garbage"` still fails the derive check. + assert!(!has_explicit_limit(&parse_attrs( + r#"#[form_data(limit = "garbage")] pub x: String"# + ))); + } + + #[test] + fn test_has_explicit_limit_other_form_data_key() { + // A `form_data` attr without a `limit` key does not count. + assert!(!has_explicit_limit(&parse_attrs( + r#"#[form_data(field_name = "x")] pub x: String"# + ))); + } +} diff --git a/crates/vespera_macro/src/multipart_impl/fields.rs b/crates/vespera_macro/src/multipart_impl/fields.rs new file mode 100644 index 00000000..15694ae1 --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/fields.rs @@ -0,0 +1,316 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Type; + +use super::attrs::{extract_limit_tokens, resolve_default_kind, resolve_field_name}; +use super::types::{extract_inner_generic, is_option_type, is_vec_type}; + +/// Collected codegen fragments for each struct field. +pub(super) struct FieldCodegen<'a> { + pub(super) declarations: Vec, + pub(super) assignments: Vec, + pub(super) post_loop: Vec, + pub(super) idents: Vec<&'a syn::Ident>, +} + +/// How a missing field should be handled. +pub(super) enum DefaultKind { + /// No default — field is required; emit `MissingField` error. + None, + /// Use `Default::default()` — from `#[serde(default)]` or `#[form_data(default)]`. + Trait, + /// Call a custom function — from `#[serde(default = "path::to::fn")]`. + Function(String), +} + +/// Process all named fields into codegen fragments. +pub(super) fn process_fields<'a>( + fields: impl Iterator, + rename_all: Option<&str>, + strict: bool, + struct_default: bool, +) -> FieldCodegen<'a> { + let mut cg = FieldCodegen { + declarations: Vec::new(), + assignments: Vec::new(), + post_loop: Vec::new(), + idents: Vec::new(), + }; + + for field in fields { + let ident = field.ident.as_ref().unwrap(); + let ty = &field.ty; + let is_vec = is_vec_type(ty); + let is_option = is_option_type(ty); + let field_name = resolve_field_name(ident, &field.attrs, rename_all); + let limit_tokens = extract_limit_tokens(&field.attrs); + let default_kind = resolve_default_kind(&field.attrs, struct_default); + + let parse_ty = if is_option || is_vec { + extract_inner_generic(ty).unwrap_or_else(|| ty.clone()) + } else { + ty.clone() + }; + + push_declaration(&mut cg, ident, ty, is_vec, is_option); + push_assignment( + &mut cg, + ident, + &parse_ty, + &field_name, + &limit_tokens, + is_vec, + strict, + ); + push_post_loop( + &mut cg, + ident, + ty, + &field_name, + &default_kind, + is_option, + is_vec, + ); + cg.idents.push(ident); + } + + cg +} + +fn push_declaration<'a>( + cg: &mut FieldCodegen<'a>, + ident: &'a syn::Ident, + ty: &Type, + is_vec: bool, + is_option: bool, +) { + if is_vec { + cg.declarations + .push(quote! { let mut #ident: #ty = std::vec::Vec::new(); }); + } else if is_option { + cg.declarations + .push(quote! { let mut #ident: #ty = std::option::Option::None; }); + } else { + cg.declarations + .push(quote! { let mut #ident: std::option::Option<#ty> = std::option::Option::None; }); + } +} + +fn push_assignment<'a>( + cg: &mut FieldCodegen<'a>, + ident: &'a syn::Ident, + parse_ty: &Type, + field_name: &str, + limit_tokens: &TokenStream, + is_vec: bool, + strict: bool, +) { + // Explicit turbofish types are required because RPITIT opaque return types + // prevent the compiler from inferring `TryFromFieldWithState::Self` through `.await`. + let try_from_call = quote! { <#parse_ty as vespera::multipart::TryFromFieldWithState<__VesperaS__>>::try_from_field_with_state }; + let parse_value = quote! { #try_from_call(__field__, #limit_tokens, __state__).await? }; + + let assignment = if is_vec { + quote! { #ident.push(#parse_value); } + } else if strict { + let set_value = quote! { #ident = std::option::Option::Some(#parse_value) }; + let dup_err = quote! { return std::result::Result::Err(vespera::multipart::TypedMultipartError::DuplicateField { field_name: std::string::String::from(#field_name) }) }; + quote! { if #ident.is_none() { #set_value ; } else { #dup_err ; } } + } else { + quote! { #ident = std::option::Option::Some(#parse_value); } + }; + + cg.assignments + .push(quote! { #field_name => { #assignment } }); +} + +fn push_post_loop<'a>( + cg: &mut FieldCodegen<'a>, + ident: &'a syn::Ident, + ty: &Type, + field_name: &str, + default_kind: &DefaultKind, + is_option: bool, + is_vec: bool, +) { + if is_option || is_vec { + return; + } + + match default_kind { + DefaultKind::Trait => { + cg.post_loop + .push(quote! { let #ident: #ty = #ident.unwrap_or_default(); }); + } + DefaultKind::Function(fn_path) => { + // A malformed user-supplied `default = "..."` path must surface as a + // clean, span-attached compile error at the field — not an internal + // macro panic (`.expect`) that prints no source location and aborts + // expansion of the whole derive. + match syn::parse_str::(fn_path) { + Ok(path) => cg + .post_loop + .push(quote! { let #ident: #ty = #ident.unwrap_or_else(#path); }), + Err(err) => { + let compile_err = syn::Error::new_spanned( + ident, + format!("invalid `default` function path `{fn_path}`: {err}"), + ) + .to_compile_error(); + // Emit the diagnostic and still bind `#ident` (unreachable + // fallback) so the malformed default does not cascade into + // spurious "cannot find value" errors downstream. + cg.post_loop.push(quote! { + #compile_err + let #ident: #ty = #ident.unwrap_or_else(|| ::core::unreachable!()); + }); + } + } + } + DefaultKind::None => { + cg.post_loop.push(quote! { + let #ident = #ident.ok_or( + vespera::multipart::TypedMultipartError::MissingField { + field_name: std::string::String::from(#field_name) + } + )?; + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::Fields; + + fn parse_fields_from(code: &str) -> syn::DeriveInput { + syn::parse_str(code).unwrap() + } + + fn get_named_fields( + input: &syn::DeriveInput, + ) -> &syn::punctuated::Punctuated { + match &input.data { + syn::Data::Struct(s) => match &s.fields { + Fields::Named(n) => &n.named, + _ => panic!("expected named fields"), + }, + _ => panic!("expected struct"), + } + } + + #[test] + fn test_process_fields_required_field_generates_parse_value() { + let input = parse_fields_from("struct T { pub name: String }"); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, false, false); + + let assignment_code = cg + .assignments + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(assignment_code.contains("TryFromFieldWithState")); + assert!(assignment_code.contains("try_from_field_with_state")); + assert!(assignment_code.contains("\"name\"")); + + let post_code = cg + .post_loop + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(post_code.contains("MissingField")); + } + + #[test] + fn test_process_fields_strict_required_field_generates_duplicate_check() { + let input = parse_fields_from("struct T { pub name: String, pub age: i32 }"); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, true, false); + + let assignment_code = cg + .assignments + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(assignment_code.contains("is_none")); + assert!(assignment_code.contains("DuplicateField")); + assert!(assignment_code.contains("\"name\"")); + assert!(assignment_code.contains("\"age\"")); + assert!(assignment_code.contains("TryFromFieldWithState")); + } + + #[test] + fn test_process_fields_vec_field_generates_push() { + let input = parse_fields_from("struct T { pub tags: Vec }"); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, false, false); + + let decl_code = cg + .declarations + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(decl_code.contains("Vec :: new")); + + let assignment_code = cg + .assignments + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(assignment_code.contains("push")); + assert!(cg.post_loop.is_empty()); + } + + #[test] + fn test_process_fields_option_field_no_missing_check() { + let input = parse_fields_from("struct T { pub bio: Option }"); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, false, false); + + let decl_code = cg + .declarations + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(decl_code.contains("Option :: None")); + assert!(cg.post_loop.is_empty()); + } + + #[test] + fn test_process_fields_strict_vec_field_uses_push_not_duplicate() { + let input = parse_fields_from("struct T { pub tags: Vec }"); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, true, false); + + let assignment_code = cg + .assignments + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + assert!(assignment_code.contains("push")); + assert!(!assignment_code.contains("DuplicateField")); + } + + #[test] + fn test_process_fields_mixed_types() { + let input = parse_fields_from( + "struct T { pub name: String, pub tags: Vec, pub bio: Option }", + ); + let fields = get_named_fields(&input); + let cg = process_fields(fields.iter(), None, false, false); + + assert_eq!(cg.idents.len(), 3); + assert_eq!(cg.declarations.len(), 3); + assert_eq!(cg.assignments.len(), 3); + assert_eq!(cg.post_loop.len(), 1); + } +} diff --git a/crates/vespera_macro/src/multipart_impl/mod.rs b/crates/vespera_macro/src/multipart_impl/mod.rs new file mode 100644 index 00000000..282ed487 --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/mod.rs @@ -0,0 +1,333 @@ +//! Vespera's `Multipart` derive macro implementation. +//! +//! This is a re-implementation of `axum_typed_multipart`'s derive macro that +//! natively supports `#[serde(rename_all)]` and `#[serde(rename)]` attributes +//! for field name resolution in multipart form data. +//! +//! ## Why? +//! +//! `axum_typed_multipart`'s derive macro only reads `#[try_from_multipart(rename_all)]` +//! and ignores `#[serde(rename_all)]`. This causes a mismatch: the OpenAPI spec +//! (generated by `Schema` derive) shows camelCase field names, but the runtime +//! multipart parser expects snake_case Rust field names. +//! +//! ## Field Name Resolution Priority +//! +//! 1. `#[form_data(field_name = "...")]` — explicit override (highest priority) +//! 2. `#[serde(rename = "...")]` — serde field rename +//! 3. `#[serde(rename_all = "...")]` or `#[try_from_multipart(rename_all = "...")]` applied to Rust name +//! 4. Rust field name as-is (lowest priority) + +mod attrs; +mod fields; +mod types; + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{DeriveInput, Fields}; + +use self::attrs::{extract_strict, extract_struct_default}; +use self::fields::{FieldCodegen, process_fields}; + +/// Process the `#[derive(TryFromMultipart)]` macro input. +pub fn process_derive(input: &DeriveInput) -> TokenStream { + let struct_name = &input.ident; + let rename_all = crate::parser::extract_rename_all(&input.attrs); + let strict = extract_strict(&input.attrs); + let struct_default = extract_struct_default(&input.attrs); + + let fields = match &input.data { + syn::Data::Struct(data) => match &data.fields { + Fields::Named(named) => &named.named, + _ => { + return syn::Error::new_spanned( + &input.ident, + "Multipart only supports structs with named fields", + ) + .to_compile_error(); + } + }, + _ => { + return syn::Error::new_spanned( + &input.ident, + "Multipart can only be derived for structs", + ) + .to_compile_error(); + } + }; + + let cg = process_fields(fields.iter(), rename_all.as_deref(), strict, struct_default); + + // Wildcard arm of the field-dispatch `match`: strict mode rejects an + // unknown field name; non-strict ignores it. Replaces the trailing + // `else { ... }` of the previous `if __field_name__ == "..." else if` + // chain. Cold path: the owned name is allocated only on rejection. + let unknown_field_arm = if strict { + quote! { + _ => { + return std::result::Result::Err( + vespera::multipart::TypedMultipartError::UnknownField { + field_name: std::string::String::from(__field_name__) + } + ); + } + } + } else { + quote! { _ => {} } + }; + + let missing_name_fallback = if strict { + quote! { + return std::result::Result::Err( + vespera::multipart::TypedMultipartError::NamelessField + ) + } + } else { + quote! { continue } + }; + + let FieldCodegen { + declarations, + assignments, + post_loop, + idents, + } = &cg; + + quote! { + impl<__VesperaS__: Send + Sync> vespera::multipart::TryFromMultipartWithState<__VesperaS__> for #struct_name { + async fn try_from_multipart_with_state( + __multipart__: &mut vespera::axum::extract::Multipart, + __state__: &__VesperaS__, + ) -> std::result::Result { + #(#declarations)* + + while let std::option::Option::Some(__field__) = __multipart__ + .next_field().await + .map_err(vespera::multipart::TypedMultipartError::from)? { + // Count EVERY wire part against the request-wide `max_fields` + // cap up front — before name resolution / dispatch — so + // unknown parts (the `_ => {}` non-strict arm) and nameless + // parts cannot slip past the limit the per-field parsers + // formerly enforced only for known fields. + vespera::multipart::register_multipart_part()?; + // Wrap the raw axum field so EVERY byte a parser reads is + // metered against the request-wide `max_total_bytes` cap — + // even a hand-written custom `TryFromFieldWithState` cannot + // bypass the aggregate limit (it reads through MeteredField). + let __field__ = + vespera::multipart::MeteredField::__from_field(__field__); + // Borrowed `&str` — NLL ends the borrow on each match + // arm before `__field__` is consumed by the parser, so + // no per-field `String` allocation is needed. + let __field_name__ = match __field__.name() { + | std::option::Option::Some("") + | std::option::Option::None => #missing_name_fallback, + | std::option::Option::Some(__name__) => __name__, + }; + + // Dispatch by resolved field name — a `match` over the + // field-name string literals instead of an + // `if __field_name__ == "..." else if ...` chain. + match __field_name__ { + #(#assignments)* + #unknown_field_arm + } + } + + #(#post_loop)* + + std::result::Result::Ok(Self { #(#idents),* }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_process_derive_basic_struct() { + let input: syn::DeriveInput = + syn::parse_str("struct MyForm { pub name: String, pub age: i32 }").unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("TryFromMultipartWithState")); + assert!(code.contains("MyForm")); + assert!(code.contains("\"name\"")); + assert!(code.contains("\"age\"")); + } + + #[test] + fn test_process_derive_with_option_field() { + let input: syn::DeriveInput = + syn::parse_str("struct MyForm { pub name: String, pub bio: Option }").unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("TryFromMultipartWithState")); + assert!(code.contains("Option :: None")); + } + + #[test] + fn test_process_derive_with_vec_field() { + let input: syn::DeriveInput = + syn::parse_str("struct MyForm { pub name: String, pub tags: Vec }").unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("Vec :: new")); + assert!(code.contains("push")); + } + + #[test] + fn test_process_derive_strict_mode() { + let input: syn::DeriveInput = + syn::parse_str("#[try_from_multipart(strict)] struct MyForm { pub name: String }") + .unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("DuplicateField")); + assert!(code.contains("UnknownField")); + assert!(code.contains("NamelessField")); + } + + #[test] + fn test_process_derive_with_rename_all() { + let input: syn::DeriveInput = syn::parse_str( + r#"#[serde(rename_all = "camelCase")] struct MyForm { pub user_name: String }"#, + ) + .unwrap(); + assert!(process_derive(&input).to_string().contains("\"userName\"")); + } + + #[test] + fn test_process_derive_with_serde_default() { + let input: syn::DeriveInput = + syn::parse_str("#[serde(default)] struct MyForm { pub count: i32 }").unwrap(); + assert!( + process_derive(&input) + .to_string() + .contains("unwrap_or_default") + ); + } + + #[test] + fn test_process_derive_with_field_default_fn() { + let input: syn::DeriveInput = + syn::parse_str(r#"struct MyForm { #[serde(default = "my_default")] pub val: String }"#) + .unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("unwrap_or_else")); + assert!(code.contains("my_default")); + } + + #[test] + fn test_process_derive_with_invalid_field_default_fn_emits_compile_error() { + // A malformed `#[serde(default = "...")]` function path must surface as + // a clean span-attached compile_error, NOT panic the macro. The test + // running to completion proves the former `.expect(...)` panic is gone. + let input: syn::DeriveInput = syn::parse_str( + r#"struct MyForm { #[serde(default = "1 not a path")] pub val: String }"#, + ) + .unwrap(); + let code = process_derive(&input).to_string(); + assert!(code.contains("compile_error")); + assert!(code.contains("function path")); + } + + #[test] + fn test_process_derive_non_struct_errors() { + let input: syn::DeriveInput = syn::parse_str("enum Foo { A, B }").unwrap(); + assert!(process_derive(&input).to_string().contains("compile_error")); + } + + #[test] + fn test_process_derive_tuple_struct_errors() { + let input: syn::DeriveInput = syn::parse_str("struct Foo(String, i32);").unwrap(); + assert!(process_derive(&input).to_string().contains("compile_error")); + } + + #[test] + fn test_process_derive_form_data_field_name() { + let input: syn::DeriveInput = syn::parse_str( + r#"struct MyForm { #[form_data(field_name = "custom")] pub data: String }"#, + ) + .unwrap(); + assert!(process_derive(&input).to_string().contains("\"custom\"")); + } + + #[test] + fn test_process_derive_form_data_default() { + let input: syn::DeriveInput = + syn::parse_str("struct MyForm { #[form_data(default)] pub count: i32 }").unwrap(); + assert!( + process_derive(&input) + .to_string() + .contains("unwrap_or_default") + ); + } + + #[test] + fn test_process_derive_non_strict_no_duplicate_check() { + let input: syn::DeriveInput = syn::parse_str("struct MyForm { pub name: String }").unwrap(); + let code = process_derive(&input).to_string(); + assert!(!code.contains("DuplicateField")); + assert!(!code.contains("UnknownField")); + } + + // ── File-field upload-limit defaults (runtime) ─────────── + + #[test] + fn test_process_derive_file_field_without_limit_uses_runtime_default() { + let input: syn::DeriveInput = + syn::parse_str("struct Up { pub file: FieldData }").unwrap(); + let code = process_derive(&input).to_string(); + assert!( + code.contains("Option :: None"), + "a file field without a limit should pass None to runtime default cap: {code}" + ); + } + + #[test] + fn test_process_derive_optional_file_field_without_limit_uses_runtime_default() { + let input: syn::DeriveInput = + syn::parse_str("struct Up { pub file: Option> }").unwrap(); + assert!( + process_derive(&input) + .to_string() + .contains("Option :: None"), + "an Option-wrapped file field without a limit should use runtime default cap" + ); + } + + #[test] + fn test_process_derive_file_field_with_limit_ok() { + let input: syn::DeriveInput = syn::parse_str( + r#"struct Up { #[form_data(limit = "10MiB")] pub file: FieldData }"#, + ) + .unwrap(); + let code = process_derive(&input).to_string(); + assert!( + !code.contains("compile_error"), + "a file field with an explicit size limit must compile: {code}" + ); + } + + #[test] + fn test_process_derive_file_field_with_unlimited_ok() { + let input: syn::DeriveInput = syn::parse_str( + r#"struct Up { #[form_data(limit = "unlimited")] pub file: FieldData }"#, + ) + .unwrap(); + assert!( + !process_derive(&input).to_string().contains("compile_error"), + "an explicit `unlimited` opt-out must compile" + ); + } + + #[test] + fn test_process_derive_non_file_field_without_limit_ok() { + // Non-file fields keep their prior behaviour — no limit required. + let input: syn::DeriveInput = + syn::parse_str("struct Up { pub name: String, pub tags: Option }").unwrap(); + assert!( + !process_derive(&input).to_string().contains("compile_error"), + "non-file fields must not require a limit" + ); + } +} diff --git a/crates/vespera_macro/src/multipart_impl/types.rs b/crates/vespera_macro/src/multipart_impl/types.rs new file mode 100644 index 00000000..1902ddcc --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/types.rs @@ -0,0 +1,231 @@ +use syn::Type; + +/// Extract the first generic type argument from a type like `Option` or `Vec`. +pub(super) fn extract_inner_generic(ty: &Type) -> Option { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner)) = args.args.first() + { + return Some(inner.clone()); + } + None +} + +/// Check if a type matches `Option`. +pub(super) fn is_option_type(ty: &Type) -> bool { + matches_type_name( + ty, + &["Option", "std::option::Option", "core::option::Option"], + ) +} + +/// Check if a type matches `Vec`. +pub(super) fn is_vec_type(ty: &Type) -> bool { + matches_type_name(ty, &["Vec", "std::vec::Vec"]) +} + +/// Whether `ty` is — or wraps via one `Option<_>` / `Vec<_>` layer — a +/// multipart file field (`FieldData`). +/// +/// File uploads are the unbounded-memory risk that multipart limits guard, +/// so the `Multipart` derive requires an explicit `#[form_data(limit = ...)]` +/// on them. +#[cfg(test)] +pub(super) fn is_file_field_type(ty: &Type) -> bool { + let inner = if is_option_type(ty) || is_vec_type(ty) { + extract_inner_generic(ty) + } else { + None + }; + let target = inner.as_ref().unwrap_or(ty); + matches_type_name( + target, + &[ + "FieldData", + "multipart::FieldData", + "vespera::multipart::FieldData", + ], + ) +} + +/// Check if a type's path matches any of the given names. +fn matches_type_name(ty: &Type, names: &[&str]) -> bool { + let path = match ty { + Type::Path(type_path) if type_path.qself.is_none() => &type_path.path, + _ => return false, + }; + // Compare each candidate's `::`-split components against the path's + // segments directly — avoids building a `Vec` and joining it + // just to run a string compare. + names.iter().any(|name| { + let mut expected = name.split("::"); + let mut actual = path.segments.iter(); + loop { + match (expected.next(), actual.next()) { + (Some(e), Some(a)) if a.ident == e => {} + (None, None) => break true, + _ => break false, + } + } + }) +} + +/// Strip leading `r#` from raw identifiers. +pub(super) fn strip_raw_prefix(s: &str) -> String { + s.strip_prefix("r#").unwrap_or(s).to_string() +} + +/// Parse a human-readable byte unit string into bytes. +/// +/// Supports: `"10MiB"`, `"1GB"`, `"500KB"`, `"1024"`, `"unlimited"`. +pub(super) fn parse_byte_unit(s: &str) -> Option { + let s = s.trim(); + + // Binary and decimal suffixes, longest first to avoid prefix collisions + let suffixes: &[(&str, usize)] = &[ + ("GiB", 1024 * 1024 * 1024), + ("MiB", 1024 * 1024), + ("KiB", 1024), + ("GB", 1_000_000_000), + ("MB", 1_000_000), + ("KB", 1_000), + ("B", 1), + ]; + + for (suffix, multiplier) in suffixes { + if let Some(num_str) = s.strip_suffix(suffix) { + return num_str.trim().parse::().ok().map(|n| n * multiplier); + } + } + + // Plain number (bytes) + s.parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use quote::quote; + + #[test] + fn test_parse_byte_unit() { + assert_eq!(parse_byte_unit("10MiB"), Some(10 * 1024 * 1024)); + assert_eq!(parse_byte_unit("50MiB"), Some(50 * 1024 * 1024)); + assert_eq!(parse_byte_unit("1GB"), Some(1_000_000_000)); + assert_eq!(parse_byte_unit("500KB"), Some(500_000)); + assert_eq!(parse_byte_unit("1024"), Some(1024)); + assert_eq!(parse_byte_unit("0"), Some(0)); + assert_eq!(parse_byte_unit("invalid"), None); + } + + #[test] + fn test_parse_byte_unit_all_suffixes() { + assert_eq!(parse_byte_unit("1GiB"), Some(1024 * 1024 * 1024)); + assert_eq!(parse_byte_unit("2KiB"), Some(2 * 1024)); + assert_eq!(parse_byte_unit("3MB"), Some(3_000_000)); + assert_eq!(parse_byte_unit("4B"), Some(4)); + assert_eq!(parse_byte_unit(" 5MiB "), Some(5 * 1024 * 1024)); + } + + #[test] + fn test_strip_raw_prefix() { + assert_eq!(strip_raw_prefix("r#type"), "type"); + assert_eq!(strip_raw_prefix("normal"), "normal"); + } + + #[test] + fn test_extract_inner_generic_option() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + let inner = extract_inner_generic(&ty).unwrap(); + assert_eq!(quote!(#inner).to_string(), "String"); + } + + #[test] + fn test_extract_inner_generic_vec() { + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + let inner = extract_inner_generic(&ty).unwrap(); + assert_eq!(quote!(#inner).to_string(), "i32"); + } + + #[test] + fn test_extract_inner_generic_no_generics() { + let ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(extract_inner_generic(&ty).is_none()); + } + + #[test] + fn test_extract_inner_generic_non_path() { + let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); + assert!(extract_inner_generic(&ty).is_none()); + } + + #[test] + fn test_is_option_type() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(is_option_type(&ty)); + let ty: syn::Type = syn::parse_str("std::option::Option").unwrap(); + assert!(is_option_type(&ty)); + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + assert!(!is_option_type(&ty)); + let ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(!is_option_type(&ty)); + } + + #[test] + fn test_is_vec_type() { + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + assert!(is_vec_type(&ty)); + let ty: syn::Type = syn::parse_str("std::vec::Vec").unwrap(); + assert!(is_vec_type(&ty)); + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(!is_vec_type(&ty)); + let ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(!is_vec_type(&ty)); + } + + #[test] + fn test_is_file_field_type() { + // Bare, Option-wrapped, Vec-wrapped, and fully-qualified FieldData. + for src in [ + "FieldData", + "Option>", + "Vec>", + "vespera::multipart::FieldData", + "Option>", + ] { + let ty: syn::Type = syn::parse_str(src).unwrap(); + assert!(is_file_field_type(&ty), "should be a file field: {src}"); + } + // Non-file fields must NOT trigger the limit requirement. + for src in ["String", "Option", "Vec", "i32", "Vec"] { + let ty: syn::Type = syn::parse_str(src).unwrap(); + assert!( + !is_file_field_type(&ty), + "should not be a file field: {src}" + ); + } + } + + #[test] + fn test_matches_type_name_simple() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(matches_type_name(&ty, &["Option"])); + assert!(!matches_type_name(&ty, &["Vec"])); + } + + #[test] + fn test_matches_type_name_qualified() { + let ty: syn::Type = syn::parse_str("std::option::Option").unwrap(); + assert!(matches_type_name(&ty, &["std::option::Option"])); + assert!(!matches_type_name(&ty, &["Option"])); + } + + #[test] + fn test_matches_type_name_non_path() { + let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); + assert!(!matches_type_name(&ty, &["Option", "Vec"])); + } +} diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 5311faa7..56d4ecd8 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -1,57 +1,107 @@ //! `OpenAPI` document generator -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::path::Path; +use std::collections::{BTreeMap, HashMap}; use vespera_core::{ openapi::{Info, OpenApi, OpenApiVersion, Server, Tag}, - route::{HttpMethod, PathItem}, - schema::Components, + schema::{Components, SecurityScheme}, }; -use crate::{ - metadata::CollectedMetadata, - parser::{ - build_operation_from_function, extract_default, extract_field_rename, extract_rename_all, - parse_enum_to_schema, parse_struct_to_schema, rename_field, strip_raw_prefix_owned, - }, - route_impl::StoredRouteInfo, - schema_macro::type_utils::get_type_default as utils_get_type_default, +use crate::{metadata::CollectedMetadata, route_impl::StoredRouteInfo}; + +mod component_schemas; +mod defaults; +mod paths; + +use component_schemas::{ + build_file_cache, build_schema_lookups, build_struct_file_index, parse_component_schemas, }; +pub use defaults::extract_default_value_from_function; +#[cfg(test)] +pub use defaults::find_function_in_file; +use paths::build_path_items; + +/// OpenAPI security data parsed from the `vespera!` macro. +#[derive(Default)] +pub struct OpenApiSecurity { + pub security_schemes: Option>, + pub security: Option>>>, + pub tag_descriptions: Option>, +} /// Generate `OpenAPI` document from collected metadata. /// /// When `file_cache` is provided (from collector), skips file I/O entirely. /// When `None`, falls back to reading files from disk (used in tests). +#[cfg(test)] pub fn generate_openapi_doc_with_metadata( title: Option, version: Option, servers: Option>, + security_config: Option, metadata: &CollectedMetadata, file_cache: Option>, route_storage: &[StoredRouteInfo], ) -> OpenApi { + try_generate_openapi_doc_with_metadata( + title, + version, + servers, + security_config, + metadata, + file_cache, + route_storage, + ) + .expect("vespera: OpenAPI generation failed") +} + +/// Fallible OpenAPI document generation used by proc-macro entry points so +/// worker diagnostics become compile errors instead of panics. +pub fn try_generate_openapi_doc_with_metadata( + title: Option, + version: Option, + servers: Option>, + security_config: Option, + metadata: &CollectedMetadata, + file_cache: Option>, + route_storage: &[StoredRouteInfo], +) -> syn::Result { + let profiling = std::env::var("VESPERA_PROFILE").is_ok(); + let mut stage_start = std::time::Instant::now(); + let mut stage = |name: &str| { + if profiling { + eprintln!( + "[vespera-profile] openapi {name}: {:?}", + stage_start.elapsed() + ); + stage_start = std::time::Instant::now(); + } + }; + let (known_schema_names, struct_definitions) = build_schema_lookups(metadata); let file_cache = file_cache.unwrap_or_else(|| build_file_cache(metadata)); let struct_file_index = build_struct_file_index(&file_cache); - let parsed_definitions = build_parsed_definitions(metadata); + stage("lookups + file index"); let schemas = parse_component_schemas( metadata, &known_schema_names, &struct_definitions, - &parsed_definitions, &file_cache, &struct_file_index, - ); + )?; + stage("component schemas"); let (paths, all_tags) = build_path_items( metadata, &known_schema_names, &struct_definitions, &file_cache, route_storage, - ); + )?; + stage("path items"); + let security_config = security_config.unwrap_or_default(); + let tags = build_tags(all_tags, security_config.tag_descriptions.as_ref()); - OpenApi { + Ok(OpenApi { openapi: OpenApiVersion::V3_1_0, info: Info { title: title.unwrap_or_else(|| "API".to_string()), @@ -77,501 +127,49 @@ pub fn generate_openapi_doc_with_metadata( examples: None, request_bodies: None, headers: None, - security_schemes: None, + security_schemes: security_config.security_schemes, }), - security: None, - tags: if all_tags.is_empty() { - None - } else { - Some( - all_tags - .into_iter() - .map(|name| Tag { - name, - description: None, - external_docs: None, - }) - .collect(), - ) - }, + security: security_config.security, + tags, external_docs: None, - } -} - -/// Build schema name and definition lookup maps from metadata. -/// -/// Registers ALL structs (including `include_in_openapi: false`) so that -/// `schema_type!` generated types can reference them. -fn build_schema_lookups( - metadata: &CollectedMetadata, -) -> (HashSet, HashMap) { - let mut known_schema_names = HashSet::with_capacity(metadata.structs.len()); - let mut struct_definitions = HashMap::with_capacity(metadata.structs.len()); - - for struct_meta in &metadata.structs { - struct_definitions.insert(struct_meta.name.clone(), struct_meta.definition.clone()); - known_schema_names.insert(struct_meta.name.clone()); - } - - (known_schema_names, struct_definitions) -} - -/// Build file AST cache — parse each unique route file exactly once. -/// -/// Deduplicates file paths first, then parses each file a single time. -/// This eliminates redundant file I/O when multiple routes share a source file. -fn build_file_cache(metadata: &CollectedMetadata) -> HashMap { - let unique_paths: BTreeSet<&str> = metadata - .routes - .iter() - .map(|r| r.file_path.as_str()) - .collect(); - let mut cache = HashMap::with_capacity(unique_paths.len()); - for path in unique_paths { - if let Some(ast) = crate::schema_macro::file_cache::get_parsed_file(Path::new(path)) { - cache.insert(path.to_string(), ast); - } - } - cache -} - -/// Build struct name → file path index from cached file ASTs. -/// -/// Enables O(1) lookup of which file contains a given struct definition, -/// replacing the previous O(routes × file_read) linear scan. -fn build_struct_file_index(file_cache: &HashMap) -> HashMap { - let mut index = HashMap::with_capacity(file_cache.len() * 4); - for (path, ast) in file_cache { - for item in &ast.items { - if let syn::Item::Struct(s) = item { - index.insert(s.ident.to_string(), path.as_str()); - } - } - } - index -} - -/// Pre-parse all struct/enum definitions into `syn::Item` for reuse. -/// -/// Avoids calling `syn::parse_str` per-struct inside `parse_component_schemas()` -/// and other consumers that need the parsed AST. -fn build_parsed_definitions(metadata: &CollectedMetadata) -> HashMap { - let mut parsed = HashMap::with_capacity(metadata.structs.len()); - for struct_meta in &metadata.structs { - if let Ok(item) = syn::parse_str::(&struct_meta.definition) { - parsed.insert(struct_meta.name.clone(), item); - } - } - parsed -} - -/// Parse struct and enum definitions into `OpenAPI` component schemas. -/// -/// Only includes structs where `include_in_openapi` is true -/// (i.e., from `#[derive(Schema)]`, not from cross-file lookup). -/// Also processes `#[serde(default)]` attributes to extract default values. -/// -/// Uses pre-built `file_cache` and `struct_file_index` for O(1) file lookups -/// instead of scanning all route files per struct. -fn parse_component_schemas( - metadata: &CollectedMetadata, - known_schema_names: &HashSet, - struct_definitions: &HashMap, - parsed_definitions: &HashMap, - file_cache: &HashMap, - struct_file_index: &HashMap, -) -> BTreeMap { - let mut schemas = BTreeMap::new(); - - for struct_meta in metadata.structs.iter().filter(|s| s.include_in_openapi) { - let Some(parsed) = parsed_definitions.get(&struct_meta.name) else { - continue; - }; - let mut schema = match parsed { - syn::Item::Struct(struct_item) => { - parse_struct_to_schema(struct_item, known_schema_names, struct_definitions) - } - syn::Item::Enum(enum_item) => { - parse_enum_to_schema(enum_item, known_schema_names, struct_definitions) - } - _ => continue, - }; - - // Process default values using cached file ASTs (O(1) lookup) - if let syn::Item::Struct(struct_item) = parsed { - let file_ast = struct_file_index - .get(&struct_meta.name) - .and_then(|path| file_cache.get(*path)) - .or_else(|| { - metadata - .routes - .first() - .and_then(|r| file_cache.get(&r.file_path)) - }); - - if let Some(ast) = file_ast { - process_default_functions( - struct_item, - ast, - &mut schema, - &struct_meta.field_defaults, - ); - } - } - - schemas.insert(struct_meta.name.clone(), schema); - } - - schemas -} - -/// Build path items and collect tags from route metadata. -/// -/// Uses `route_storage` (from `#[route]` macro) as the primary source for function -/// signatures. Falls back to pre-built `file_cache` when ROUTE_STORAGE doesn't -/// have an entry (e.g., during tests or for routes added without the attribute). -fn build_path_items( - metadata: &CollectedMetadata, - known_schema_names: &HashSet, - struct_definitions: &HashMap, - file_cache: &HashMap, - route_storage: &[StoredRouteInfo], -) -> (BTreeMap, BTreeSet) { - let mut paths = BTreeMap::new(); - let mut all_tags = BTreeSet::new(); - - // Build the file-AST function index FIRST so the storage-parse step - // below can skip any function whose AST is already reachable through - // `file_cache`. `collector::collect_metadata` has already walked - // these files via `syn::parse_file`, so re-parsing `fn_item_str` - // from ROUTE_STORAGE for the same function is pure duplicated work. - let fn_index: HashMap<&str, HashMap> = file_cache - .iter() - .map(|(path, ast)| { - let fns: HashMap = ast - .items - .iter() - .filter_map(|item| { - if let syn::Item::Fn(fn_item) = item { - Some((fn_item.sig.ident.to_string(), fn_item)) - } else { - None - } - }) - .collect(); - (path.as_str(), fns) - }) - .collect(); - - // Primary source: parse function items from ROUTE_STORAGE only when - // the function is *not* already covered by `fn_index`. Routes whose - // owning file is in `file_cache` short-circuit through `fn_index` in - // the loop below, so the parse is wasted work. The lookup order in - // the loop preserves the original ROUTE_STORAGE-first priority for - // any route that does end up in this cache (e.g. routes registered - // via `#[route]` from files outside the scanned routes folder). - let route_fn_cache: HashMap<&str, syn::ItemFn> = route_storage - .iter() - .filter_map(|s| { - let already_in_ast = s - .file_path - .as_deref() - .and_then(|fp| fn_index.get(fp)) - .is_some_and(|fns| fns.contains_key(&s.fn_name)); - if already_in_ast { - return None; - } - syn::parse_str::(&s.fn_item_str) - .ok() - .map(|item| (s.fn_name.as_str(), item)) - }) - .collect(); - - for route_meta in &metadata.routes { - // Try ROUTE_STORAGE first (avoids file_cache dependency for known routes) - let fn_sig = if let Some(cached_fn) = route_fn_cache.get(route_meta.function_name.as_str()) - { - &cached_fn.sig - } else if let Some(fns) = fn_index.get(route_meta.file_path.as_str()) - && let Some(fn_item) = fns.get(&route_meta.function_name) - { - &fn_item.sig - } else { - continue; - }; - - let Ok(method) = HttpMethod::try_from(route_meta.method.as_str()) else { - eprintln!( - "vespera: skipping route '{}' \u{2014} unknown HTTP method '{}'", - route_meta.path, route_meta.method - ); - continue; - }; - - if let Some(tags) = &route_meta.tags { - for tag in tags { - all_tags.insert(tag.clone()); - } - } - - let mut operation = build_operation_from_function( - fn_sig, - &route_meta.path, - known_schema_names, - struct_definitions, - route_meta.error_status.as_deref(), - route_meta.tags.as_deref(), - ); - operation.description.clone_from(&route_meta.description); - - let path_item = paths - .entry(route_meta.path.clone()) - .or_insert_with(PathItem::default); - - path_item.set_operation(method, operation); - } - - (paths, all_tags) -} - -/// Set the default value on an inline property schema, if not already set. -/// -/// Looks up `field_name` in the properties map. If found as an inline schema -/// and the schema has no existing default, sets `value` as the default. -fn set_property_default( - properties: &mut BTreeMap, - field_name: &str, - value: serde_json::Value, -) { - use vespera_core::schema::SchemaRef; - - if let Some(SchemaRef::Inline(prop_schema)) = properties.get_mut(field_name) - && prop_schema.default.is_none() - { - prop_schema.default = Some(value); - } -} - -/// Process default functions for struct fields -/// This function extracts default values from: -/// 1. `#[schema(default = "value")]` attributes (generated by `schema_type!` from `sea_orm(default_value)`) -/// 2. `#[serde(default = "function_name")]` by finding the function in the file AST -/// 3. `#[serde(default)]` by using type-specific defaults -fn process_default_functions( - struct_item: &syn::ItemStruct, - file_ast: &syn::File, - schema: &mut vespera_core::schema::Schema, - stored_defaults: &BTreeMap, -) { - use syn::Fields; - - // Extract rename_all from struct level - let struct_rename_all = extract_rename_all(&struct_item.attrs); - - // Get properties from schema - let Some(properties) = &mut schema.properties else { - return; - }; - - // Process each field in the struct - if let Fields::Named(fields_named) = &struct_item.fields { - for field in &fields_named.named { - let rust_field_name = field.ident.as_ref().map_or_else( - || "unknown".to_string(), - |i| strip_raw_prefix_owned(i.to_string()), - ); - let field_name = extract_field_rename(&field.attrs) - .unwrap_or_else(|| rename_field(&rust_field_name, struct_rename_all.as_deref())); - - // Priority 0: Pre-extracted defaults from SCHEMA_STORAGE (populated by #[derive(Schema)]) - if let Some(value) = stored_defaults.get(&rust_field_name) { - set_property_default(properties, &field_name, value.clone()); - continue; - } - - // Priority 1: #[schema(default = "value")] from schema_type! macro - if let Some(default_str) = extract_schema_default_attr(&field.attrs) { - let value = parse_default_string_to_json_value(&default_str); - set_property_default(properties, &field_name, value); - continue; - } - - // Priority 2: #[serde(default)] / #[serde(default = "fn")] - let default_info = match extract_default(&field.attrs) { - Some(Some(func_name)) => func_name, // default = "function_name" - Some(None) => { - // Simple default (no function) - we can set type-specific defaults - if let Some(default_value) = utils_get_type_default(&field.ty) { - set_property_default(properties, &field_name, default_value); - } - continue; - } - None => continue, // No default attribute - }; - - // Find the function in the file AST and extract default value - if let Some(func_item) = find_function_in_file(file_ast, &default_info) - && let Some(default_value) = extract_default_value_from_function(func_item) - { - set_property_default(properties, &field_name, default_value); - } - } - } -} - -/// Extract `default` value from `#[schema(default = "...")]` field attribute. -/// -/// This attribute is generated by `schema_type!` when converting `sea_orm(default_value)`. -/// It carries the raw default value string for OpenAPI schema generation. -fn extract_schema_default_attr(attrs: &[syn::Attribute]) -> Option { - attrs - .iter() - .filter(|attr| attr.path().is_ident("schema")) - .find_map(|attr| { - let mut default_value = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("default") { - let value = meta.value()?; - let lit: syn::LitStr = value.parse()?; - default_value = Some(lit.value()); - } - Ok(()) - }); - default_value - }) -} - -/// Parse a default value string into the appropriate `serde_json::Value`. -/// -/// Tries to infer the JSON type: integer → number → bool → string (fallback). -fn parse_default_string_to_json_value(value: &str) -> serde_json::Value { - // Try integer first - if let Ok(n) = value.parse::() { - return serde_json::Value::Number(n.into()); - } - // Try float - if let Ok(f) = value.parse::() - && let Some(n) = serde_json::Number::from_f64(f) - { - return serde_json::Value::Number(n); - } - // Try bool - if let Ok(b) = value.parse::() { - return serde_json::Value::Bool(b); - } - // Fallback to string - serde_json::Value::String(value.to_string()) -} - -/// Find a function by name in the file AST -pub fn find_function_in_file<'a>( - file_ast: &'a syn::File, - function_name: &str, -) -> Option<&'a syn::ItemFn> { - file_ast.items.iter().find_map(|item| match item { - syn::Item::Fn(fn_item) if fn_item.sig.ident == function_name => Some(fn_item), - _ => None, }) } -/// Extract default value from function body -/// This tries to extract literal values from common patterns like: -/// - "`value".to_string()` -> "value" -/// - 42 -> 42 -/// - true -> true -/// - vec![] -> [] -pub fn extract_default_value_from_function(func: &syn::ItemFn) -> Option { - // Try to find return statement or expression - for stmt in &func.block.stmts { - if let syn::Stmt::Expr(expr, _) = stmt { - // Direct expression (like "value".to_string()) - if let Some(value) = extract_value_from_expr(expr) { - return Some(value); - } - // Or return statement - if let syn::Expr::Return(ret) = expr - && let Some(expr) = &ret.expr - && let Some(value) = extract_value_from_expr(expr) - { - return Some(value); - } - } - } - - None -} - -/// Extract value from expression -pub fn extract_value_from_expr(expr: &syn::Expr) -> Option { - use syn::{Expr, ExprLit, ExprMacro, Lit}; - - match expr { - // Literal values - Expr::Lit(ExprLit { lit, .. }) => match lit { - Lit::Str(s) => Some(serde_json::Value::String(s.value())), - Lit::Int(i) => i - .base10_parse::() - .ok() - .map(|v| serde_json::Value::Number(v.into())), - Lit::Float(f) => f - .base10_parse::() - .ok() - .and_then(serde_json::Number::from_f64) - .map(serde_json::Value::Number), - Lit::Bool(b) => Some(serde_json::Value::Bool(b.value)), - _ => None, - }, - // Method calls like "value".to_string() - Expr::MethodCall(method_call) => { - if method_call.method == "to_string" { - // Get the receiver (the string literal) - // Try direct match first - if let Expr::Lit(ExprLit { - lit: Lit::Str(s), .. - }) = method_call.receiver.as_ref() - { - return Some(serde_json::Value::String(s.value())); - } - // Try to extract from nested expressions (e.g., if the receiver is wrapped) - if let Some(value) = extract_value_from_expr(method_call.receiver.as_ref()) { - return Some(value); - } - } - None - } - // Macro calls like vec![] - Expr::Macro(ExprMacro { mac, .. }) => { - if mac.path.is_ident("vec") { - // Try to parse vec![] as empty array - return Some(serde_json::Value::Array(vec![])); - } - None - } - _ => None, - } +fn build_tags( + mut all_tags: std::collections::BTreeSet, + tag_descriptions: Option<&HashMap>, +) -> Option> { + if let Some(descriptions) = tag_descriptions { + all_tags.extend(descriptions.keys().cloned()); + } + (!all_tags.is_empty()).then(|| { + all_tags + .into_iter() + .map(|name| Tag { + description: tag_descriptions + .and_then(|descriptions| descriptions.get(&name).cloned()), + name, + external_docs: None, + }) + .collect() + }) } #[cfg(test)] mod tests { - use std::{fs, path::PathBuf}; + use std::collections::HashMap; use rstest::rstest; - use tempfile::TempDir; + use vespera_core::schema::{SecurityScheme, SecuritySchemeType}; use super::*; use crate::metadata::{CollectedMetadata, RouteMetadata, StructMetadata}; - fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> PathBuf { - let file_path = dir.path().join(filename); - fs::write(&file_path, content).expect("Failed to write temp file"); - file_path - } - #[test] - fn test_generate_openapi_empty_metadata() { + fn empty_metadata_uses_openapi_defaults() { let metadata = CollectedMetadata::new(); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); assert_eq!(doc.openapi, OpenApiVersion::V3_1_0); assert_eq!(doc.info.title, "API"); @@ -586,11 +184,16 @@ mod tests { } #[rstest] - #[case(None, None, "API", "1.0.0")] - #[case(Some("My API".to_string()), None, "My API", "1.0.0")] - #[case(None, Some("2.0.0".to_string()), "API", "2.0.0")] - #[case(Some("Test API".to_string()), Some("3.0.0".to_string()), "Test API", "3.0.0")] - fn test_generate_openapi_title_version( + #[case::defaults(None, None, "API", "1.0.0")] + #[case::custom_title(Some("My API".to_string()), None, "My API", "1.0.0")] + #[case::custom_version(None, Some("2.0.0".to_string()), "API", "2.0.0")] + #[case::custom_both( + Some("Test API".to_string()), + Some("3.0.0".to_string()), + "Test API", + "3.0.0", + )] + fn title_version_cases( #[case] title: Option, #[case] version: Option, #[case] expected_title: &str, @@ -598,1328 +201,448 @@ mod tests { ) { let metadata = CollectedMetadata::new(); - let doc = generate_openapi_doc_with_metadata(title, version, None, &metadata, None, &[]); + let doc = + generate_openapi_doc_with_metadata(title, version, None, None, &metadata, None, &[]); assert_eq!(doc.info.title, expected_title); assert_eq!(doc.info.version, expected_version); } #[test] - fn test_generate_openapi_with_route() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create a test route file - let route_content = r#" -pub fn get_users() -> String { - "users".to_string() -} -"#; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); + fn explicit_servers_replace_default_server() { + let metadata = CollectedMetadata::new(); + let servers = vec![ + Server { + url: "https://api.example.com".to_string(), + description: Some("Production".to_string()), + variables: None, + }, + Server { + url: "http://localhost:3000".to_string(), + description: Some("Development".to_string()), + variables: None, + }, + ]; - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + let doc = generate_openapi_doc_with_metadata( + None, + None, + Some(servers), + None, + &metadata, + None, + &[], + ); - assert!(doc.paths.contains_key("/users")); - let path_item = doc.paths.get("/users").unwrap(); - assert!(path_item.get.is_some()); - let operation = path_item.get.as_ref().unwrap(); - assert_eq!(operation.operation_id, Some("get_users".to_string())); + let doc_servers = doc.servers.expect("servers present"); + assert_eq!(doc_servers.len(), 2); + assert_eq!(doc_servers[0].url, "https://api.example.com"); + assert_eq!(doc_servers[1].url, "http://localhost:3000"); } #[test] - fn test_generate_openapi_route_storage_dedup_skips_already_in_ast() { - // When a route's `fn_item_str` was already discovered by parsing - // the source file via `file_cache`, the storage-parse step must - // skip re-parsing it — exercises the `already_in_ast → return None` - // branch inside `route_fn_cache` construction. - use crate::route_impl::StoredRouteInfo; - - let route_file_path = "/virtual/users.rs".to_string(); - let route_src = "pub fn get_users() -> String { \"users\".to_string() }"; - let parsed: syn::File = syn::parse_str(route_src).expect("route src parses"); - let mut file_cache: HashMap = HashMap::new(); - file_cache.insert(route_file_path.clone(), parsed); - + fn security_schemes_and_route_security_snapshot() { let mut metadata = CollectedMetadata::new(); metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file_path.clone(), - signature: "fn get_users() -> String".to_string(), + method: "get".to_string(), + path: "/secure".to_string(), + function_name: "secure_route".to_string(), + module_path: "routes::secure".to_string(), + file_path: "virtual/secure.rs".to_string(), error_status: None, - tags: None, - description: None, + typed_responses: None, + tags: Some(vec!["secure".to_string()]), + security: Some(vec!["bearerAuth".to_string()]), + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: Some("A secured route".to_string()), }); - // The route is registered in BOTH file_cache (via AST) and - // ROUTE_STORAGE — the storage-parse step must short-circuit. + let security_schemes = BTreeMap::from([( + "bearerAuth".to_string(), + SecurityScheme { + r#type: SecuritySchemeType::Http, + description: Some("JWT bearer token".to_string()), + name: None, + r#in: None, + scheme: Some("bearer".to_string()), + bearer_format: Some("JWT".to_string()), + flows: None, + open_id_connect_url: None, + }, + )]); + let global_security = Some(vec![BTreeMap::from([( + "bearerAuth".to_string(), + Vec::new(), + )])]); let route_storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), + fn_name: "secure_route".to_string(), method: Some("get".to_string()), - custom_path: None, + custom_path: Some("/secure".to_string()), error_status: None, - tags: None, - description: None, - file_path: Some(route_file_path), - fn_item_str: route_src.to_string(), + typed_responses: None, + tags: Some(vec!["secure".to_string()]), + security: Some(vec!["bearerAuth".to_string()]), + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: Some("A secured route".to_string()), + file_path: None, + fn_sig_str: "async fn secure_route() -> &'static str".to_string(), }]; let doc = generate_openapi_doc_with_metadata( + Some("Security API".to_string()), + Some("1.0.0".to_string()), None, - None, - None, + Some(OpenApiSecurity { + security_schemes: Some(security_schemes), + security: global_security, + tag_descriptions: None, + }), &metadata, - Some(file_cache), + None, &route_storage, ); - // The route should still be picked up via the file_cache AST - // path — proves dedup didn't break route discovery. - assert!(doc.paths.contains_key("/users")); - let op = doc - .paths - .get("/users") - .unwrap() - .get - .as_ref() - .expect("GET op"); - assert_eq!(op.operation_id, Some("get_users".to_string())); + insta::assert_snapshot!( + "openapi_security_schemes_and_route_security", + serde_json::to_string_pretty(&doc).unwrap() + ); } #[test] - fn test_generate_openapi_with_struct() { - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "User".to_string(), - definition: "struct User { id: i32, name: String }".to_string(), - ..Default::default() - }); + fn multiple_security_schemes_are_serialized_in_sorted_order_snapshot() { + let metadata = CollectedMetadata::new(); + let security_schemes = BTreeMap::from([ + ( + "zBearer".to_string(), + SecurityScheme { + r#type: SecuritySchemeType::Http, + description: None, + name: None, + r#in: None, + scheme: Some("bearer".to_string()), + bearer_format: Some("JWT".to_string()), + flows: None, + open_id_connect_url: None, + }, + ), + ( + "apiKey".to_string(), + SecurityScheme { + r#type: SecuritySchemeType::ApiKey, + description: Some("API key".to_string()), + name: Some("X-API-Key".to_string()), + r#in: Some("header".to_string()), + scheme: None, + bearer_format: None, + flows: None, + open_id_connect_url: None, + }, + ), + ( + "basicAuth".to_string(), + SecurityScheme { + r#type: SecuritySchemeType::Http, + description: None, + name: None, + r#in: None, + scheme: Some("basic".to_string()), + bearer_format: None, + flows: None, + open_id_connect_url: None, + }, + ), + ]); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + let doc = generate_openapi_doc_with_metadata( + Some("Security API".to_string()), + Some("1.0.0".to_string()), + None, + Some(OpenApiSecurity { + security_schemes: Some(security_schemes), + security: None, + tag_descriptions: None, + }), + &metadata, + None, + &[], + ); - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); + insta::assert_snapshot!( + "openapi_security_schemes_sorted_order", + serde_json::to_string_pretty(&doc).unwrap() + ); } #[test] - fn test_generate_openapi_with_enum() { + fn route_operation_metadata_snapshot() { let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Status".to_string(), - definition: "enum Status { Active, Inactive, Pending }".to_string(), - ..Default::default() + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users/{id}".to_string(), + function_name: "get_user".to_string(), + module_path: "routes::users".to_string(), + file_path: "virtual/users.rs".to_string(), + error_status: None, + typed_responses: None, + tags: Some(vec!["users".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: Some("getUser".to_string()), + summary: Some("Get a user".to_string()), + request_example: None, + response_example: None, + deprecated: true, + description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("Status")); - } - - #[test] - fn test_generate_openapi_with_enum_with_data() { - // Test enum with data (tuple and struct variants) to ensure full coverage - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Message".to_string(), - definition: "enum Message { Text(String), User { id: i32, name: String } }".to_string(), - ..Default::default() - }); + let route_storage = vec![StoredRouteInfo { + fn_name: "get_user".to_string(), + method: Some("get".to_string()), + custom_path: Some("/users/{id}".to_string()), + error_status: None, + typed_responses: None, + tags: Some(vec!["users".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: Some("getUser".to_string()), + summary: Some("Get a user".to_string()), + request_example: None, + response_example: None, + deprecated: true, + description: None, + file_path: None, + fn_sig_str: "async fn get_user() -> &'static str".to_string(), + }]; - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + let doc = generate_openapi_doc_with_metadata( + Some("Operation Metadata API".to_string()), + Some("1.0.0".to_string()), + None, + None, + &metadata, + None, + &route_storage, + ); - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("Message")); + insta::assert_snapshot!( + "openapi_route_operation_metadata", + serde_json::to_string_pretty(&doc).unwrap() + ); } #[test] - fn test_generate_openapi_with_enum_and_route() { - // Test enum used in route to ensure enum parsing is called in route context - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = r" -pub fn get_status() -> Status { - Status::Active -} -"; - let route_file = create_temp_file(&temp_dir, "status_route.rs", route_content); - + fn typed_route_responses_snapshot() { let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Status".to_string(), - definition: "enum Status { Active, Inactive }".to_string(), - ..Default::default() - }); + metadata.structs.push(StructMetadata::new( + "NotFoundError".to_string(), + "pub struct NotFoundError { pub message: String }".to_string(), + )); metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/status".to_string(), - function_name: "get_status".to_string(), - module_path: "test::status_route".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_status() -> Status".to_string(), - error_status: None, - tags: None, + method: "get".to_string(), + path: "/users/{id}".to_string(), + function_name: "get_user".to_string(), + module_path: "routes::users".to_string(), + file_path: "virtual/users.rs".to_string(), + error_status: Some(vec![404, 500]), + typed_responses: Some(vec![(404, "NotFoundError".to_string())]), + tags: Some(vec!["users".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Check enum schema - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("Status")); - - // Check route - assert!(doc.paths.contains_key("/status")); - } + let route_storage = vec![StoredRouteInfo { + fn_name: "get_user".to_string(), + method: Some("get".to_string()), + custom_path: Some("/users/{id}".to_string()), + error_status: Some(vec![404, 500]), + typed_responses: Some(vec![(404, "NotFoundError".to_string())]), + tags: Some(vec!["users".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + file_path: None, + fn_sig_str: "async fn get_user() -> &'static str".to_string(), + }]; - #[test] - fn test_generate_openapi_with_fallback_item() { - // Test fallback case for non-struct, non-enum items - // Use a const item which will be parsed as syn::Item::Const first - // This triggers the fallback case (_ branch) which now gracefully skips - // items that cannot be parsed as structs (defensive error handling) - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Config".to_string(), - // This will be parsed as syn::Item::Const, triggering the fallback case - // which now safely skips this item instead of panicking - definition: "const CONFIG: i32 = 42;".to_string(), - include_in_openapi: true, - field_defaults: BTreeMap::new(), - }); + let doc = generate_openapi_doc_with_metadata( + Some("Typed Responses API".to_string()), + Some("1.0.0".to_string()), + None, + None, + &metadata, + None, + &route_storage, + ); - // This should gracefully handle the invalid item (skip it) instead of panicking - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - // The invalid struct definition should be skipped, resulting in no schemas - assert!(doc.components.is_none() || doc.components.as_ref().unwrap().schemas.is_none()); + insta::assert_snapshot!( + "openapi_typed_route_responses", + serde_json::to_string_pretty(&doc).unwrap() + ); } #[test] - fn test_generate_openapi_with_route_and_struct() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = r#" -use crate::user::User; - -pub fn get_user() -> User { - User { id: 1, name: "Alice".to_string() } -} -"#; - let route_file = create_temp_file(&temp_dir, "user_route.rs", route_content); - + fn route_headers_and_examples_snapshot() { let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "User".to_string(), - definition: "struct User { id: i32, name: String }".to_string(), - ..Default::default() - }); + metadata.structs.push(StructMetadata::new( + "User".to_string(), + "pub struct User { pub name: String }".to_string(), + )); metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/user".to_string(), - function_name: "get_user".to_string(), - module_path: "test::user_route".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_user() -> User".to_string(), + method: "post".to_string(), + path: "/users".to_string(), + function_name: "create_user".to_string(), + module_path: "routes::users".to_string(), + file_path: "virtual/users.rs".to_string(), error_status: None, - tags: None, + typed_responses: None, + tags: Some(vec!["users".to_string()]), + security: None, + success_status: None, + headers: vec![ + crate::metadata::HeaderParam { + name: "Authorization".to_string(), + required: true, + description: Some("Bearer token".to_string()), + }, + crate::metadata::HeaderParam { + name: "X-Trace-Id".to_string(), + required: false, + description: None, + }, + ], + operation_id: None, + summary: None, + request_example: Some(serde_json::json!({ "name": "Alice" })), + response_example: Some(serde_json::json!({ "name": "Alice" })), + deprecated: false, description: None, }); + let route_storage = vec![StoredRouteInfo { + fn_name: "create_user".to_string(), + method: Some("post".to_string()), + custom_path: Some("/users".to_string()), + error_status: None, + typed_responses: None, + tags: Some(vec!["users".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + file_path: None, + fn_sig_str: "async fn create_user(vespera::axum::Json(user): vespera::axum::Json) -> vespera::axum::Json".to_string(), + }]; + let doc = generate_openapi_doc_with_metadata( - Some("Test API".to_string()), + Some("Headers API".to_string()), Some("1.0.0".to_string()), None, + None, &metadata, None, - &[], + &route_storage, ); - // Check struct schema - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); - - // Check route - assert!(doc.paths.contains_key("/user")); - let path_item = doc.paths.get("/user").unwrap(); - assert!(path_item.get.is_some()); + insta::assert_snapshot!( + "openapi_route_headers_and_examples", + serde_json::to_string_pretty(&doc).unwrap() + ); } #[test] - fn test_generate_openapi_multiple_routes() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - let route1_content = r#" -pub fn get_users() -> String { - "users".to_string() -} -"#; - let route1_file = create_temp_file(&temp_dir, "users.rs", route1_content); - - let route2_content = r#" -pub fn create_user() -> String { - "created".to_string() -} -"#; - let route2_file = create_temp_file(&temp_dir, "create_user.rs", route2_content); - + fn tag_descriptions_snapshot() { let mut metadata = CollectedMetadata::new(); metadata.routes.push(RouteMetadata { - method: "GET".to_string(), + method: "get".to_string(), path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route1_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), + function_name: "list_users".to_string(), + module_path: "routes::users".to_string(), + file_path: "virtual/users.rs".to_string(), error_status: None, - tags: None, + typed_responses: None, + tags: Some(vec!["users".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, description: None, }); - metadata.routes.push(RouteMetadata { - method: "POST".to_string(), - path: "/users".to_string(), - function_name: "create_user".to_string(), - module_path: "test::create_user".to_string(), - file_path: route2_file.to_string_lossy().to_string(), - signature: "fn create_user() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - assert_eq!(doc.paths.len(), 1); // Same path, different methods - let path_item = doc.paths.get("/users").unwrap(); - assert!(path_item.get.is_some()); - assert!(path_item.post.is_some()); - } - - #[rstest] - // Test file read failures - #[case::route_file_read_failure( - None, - Some(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: "/nonexistent/route.rs".to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }), - false, // struct should not be added - false, // route should not be added - )] - #[case::route_file_parse_failure( - None, - Some(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: String::new(), // Will be set to temp file with invalid syntax - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }), - false, // struct should not be added - false, // route should not be added - )] - fn test_generate_openapi_file_errors( - #[case] struct_meta: Option, - #[case] route_meta: Option, - #[case] expect_struct: bool, - #[case] expect_route: bool, - ) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let mut metadata = CollectedMetadata::new(); - - // Handle struct metadata - if let Some(struct_m) = struct_meta { - // If file_path is empty, create invalid syntax file - metadata.structs.push(struct_m); - } - - // Handle route metadata - if let Some(mut route_m) = route_meta { - // If file_path is empty, create invalid syntax file - if route_m.file_path.is_empty() { - let invalid_file = - create_temp_file(&temp_dir, "invalid_route.rs", "invalid rust syntax {"); - route_m.file_path = invalid_file.to_string_lossy().to_string(); - } - metadata.routes.push(route_m); - } - - // Should not panic, just skip invalid files - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Check struct - if expect_struct { - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); - } else if let Some(schemas) = doc.components.as_ref().unwrap().schemas.as_ref() { - assert!(!schemas.contains_key("User")); - } - - // Check route - if expect_route { - assert!(doc.paths.contains_key("/users")); - } else { - assert!(!doc.paths.contains_key("/users")); - } - - // Ensure TempDir is properly closed - drop(temp_dir); - } - - #[test] - fn test_generate_openapi_with_tags_and_description() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = r#" -pub fn get_users() -> String { - "users".to_string() -} -"#; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: Some(vec![404]), - tags: Some(vec!["users".to_string(), "admin".to_string()]), - description: Some("Get all users".to_string()), - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Check route has description - let path_item = doc.paths.get("/users").unwrap(); - let operation = path_item.get.as_ref().unwrap(); - assert_eq!(operation.description, Some("Get all users".to_string())); - - // Check tags are collected - assert!(doc.tags.is_some()); - let tags = doc.tags.as_ref().unwrap(); - assert!(tags.iter().any(|t| t.name == "users")); - assert!(tags.iter().any(|t| t.name == "admin")); - } - - #[test] - fn test_generate_openapi_with_servers() { - let metadata = CollectedMetadata::new(); - let servers = vec![ - Server { - url: "https://api.example.com".to_string(), - description: Some("Production".to_string()), - variables: None, - }, - Server { - url: "http://localhost:3000".to_string(), - description: Some("Development".to_string()), - variables: None, - }, - ]; - - let doc = - generate_openapi_doc_with_metadata(None, None, Some(servers), &metadata, None, &[]); - - assert!(doc.servers.is_some()); - let doc_servers = doc.servers.unwrap(); - assert_eq!(doc_servers.len(), 2); - assert_eq!(doc_servers[0].url, "https://api.example.com"); - assert_eq!(doc_servers[1].url, "http://localhost:3000"); - } - - #[test] - fn test_extract_value_from_expr_int() { - let expr: syn::Expr = syn::parse_str("42").unwrap(); - let value = extract_value_from_expr(&expr); - assert_eq!(value, Some(serde_json::Value::Number(42.into()))); - } - - #[test] - fn test_extract_value_from_expr_float() { - let expr: syn::Expr = syn::parse_str("12.34").unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_some()); - if let Some(serde_json::Value::Number(n)) = value { - assert!((n.as_f64().unwrap() - 12.34).abs() < 0.001); - } - } - - #[test] - fn test_extract_value_from_expr_bool() { - let expr_true: syn::Expr = syn::parse_str("true").unwrap(); - let expr_false: syn::Expr = syn::parse_str("false").unwrap(); - assert_eq!( - extract_value_from_expr(&expr_true), - Some(serde_json::Value::Bool(true)) - ); - assert_eq!( - extract_value_from_expr(&expr_false), - Some(serde_json::Value::Bool(false)) - ); - } - - #[test] - fn test_extract_value_from_expr_string() { - let expr: syn::Expr = syn::parse_str(r#""hello""#).unwrap(); - let value = extract_value_from_expr(&expr); - assert_eq!(value, Some(serde_json::Value::String("hello".to_string()))); - } - - #[test] - fn test_extract_value_from_expr_to_string() { - let expr: syn::Expr = syn::parse_str(r#""hello".to_string()"#).unwrap(); - let value = extract_value_from_expr(&expr); - assert_eq!(value, Some(serde_json::Value::String("hello".to_string()))); - } - - #[test] - fn test_extract_value_from_expr_vec_macro() { - let expr: syn::Expr = syn::parse_str("vec![]").unwrap(); - let value = extract_value_from_expr(&expr); - assert_eq!(value, Some(serde_json::Value::Array(vec![]))); - } - - #[test] - fn test_extract_value_from_expr_unsupported() { - // Binary expression is not supported - let expr: syn::Expr = syn::parse_str("1 + 2").unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_method_call_non_to_string() { - // Method call that's not to_string() - let expr: syn::Expr = syn::parse_str(r#""hello".len()"#).unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_unsupported_literal() { - // Byte literal is not directly supported - let expr: syn::Expr = syn::parse_str("b'a'").unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_non_vec_macro() { - // Other macros like println! are not supported - let expr: syn::Expr = syn::parse_str(r#"println!("test")"#).unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_none()); - } - - #[test] - fn test_get_type_default_string() { - let ty: syn::Type = syn::parse_str("String").unwrap(); - let value = utils_get_type_default(&ty); - assert_eq!(value, Some(serde_json::Value::String(String::new()))); - } - - #[test] - fn test_get_type_default_integers() { - for type_name in &["i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64"] { - let ty: syn::Type = syn::parse_str(type_name).unwrap(); - let value = utils_get_type_default(&ty); - assert_eq!( - value, - Some(serde_json::Value::Number(0.into())), - "Failed for type {type_name}" - ); - } - } - - #[test] - fn test_get_type_default_floats() { - for type_name in &["f32", "f64"] { - let ty: syn::Type = syn::parse_str(type_name).unwrap(); - let value = utils_get_type_default(&ty); - assert!(value.is_some(), "Failed for type {type_name}"); - } - } - - #[test] - fn test_get_type_default_bool() { - let ty: syn::Type = syn::parse_str("bool").unwrap(); - let value = utils_get_type_default(&ty); - assert_eq!(value, Some(serde_json::Value::Bool(false))); - } - - #[test] - fn test_get_type_default_unknown() { - let ty: syn::Type = syn::parse_str("CustomType").unwrap(); - let value = utils_get_type_default(&ty); - assert!(value.is_none()); - } - - #[test] - fn test_get_type_default_non_path() { - // Reference type is not a path type - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let value = utils_get_type_default(&ty); - assert!(value.is_none()); - } - - #[test] - fn test_find_function_in_file() { - let file_content = r" -fn foo() {} -fn bar() -> i32 { 42 } -fn baz(x: i32) -> i32 { x } -"; - let file_ast: syn::File = syn::parse_str(file_content).unwrap(); - - assert!(find_function_in_file(&file_ast, "foo").is_some()); - assert!(find_function_in_file(&file_ast, "bar").is_some()); - assert!(find_function_in_file(&file_ast, "baz").is_some()); - assert!(find_function_in_file(&file_ast, "nonexistent").is_none()); - } - - #[test] - fn test_extract_default_value_from_function() { - // Test direct expression return - let func: syn::ItemFn = syn::parse_str( - r" - fn default_value() -> i32 { - 42 - } - ", - ) - .unwrap(); - let value = extract_default_value_from_function(&func); - assert_eq!(value, Some(serde_json::Value::Number(42.into()))); - } - - #[test] - fn test_extract_default_value_from_function_with_return() { - // Test explicit return statement - let func: syn::ItemFn = syn::parse_str( - r#" - fn default_value() -> String { - return "hello".to_string() - } - "#, - ) - .unwrap(); - let value = extract_default_value_from_function(&func); - assert_eq!(value, Some(serde_json::Value::String("hello".to_string()))); - } - - #[test] - fn test_extract_default_value_from_function_empty() { - // Test function with no extractable value - let func: syn::ItemFn = syn::parse_str( - r" - fn default_value() { - let x = 1; - } - ", - ) - .unwrap(); - let value = extract_default_value_from_function(&func); - assert!(value.is_none()); - } - - #[test] - fn test_generate_openapi_with_default_functions() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create a file with struct that has default function - let route_content = r#" -fn default_name() -> String { - "John".to_string() -} - -struct User { - #[serde(default = "default_name")] - name: String, -} - -pub fn get_user() -> User { - User { name: "Alice".to_string() } -} -"#; - let route_file = create_temp_file(&temp_dir, "user.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "User".to_string(), - definition: r#"struct User { #[serde(default = "default_name")] name: String }"# - .to_string(), - ..Default::default() - }); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/user".to_string(), - function_name: "get_user".to_string(), - module_path: "test::user".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_user() -> User".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Struct should be present - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); - } - - #[test] - fn test_generate_openapi_with_simple_default() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - let route_content = r" -struct Config { - #[serde(default)] - enabled: bool, - #[serde(default)] - count: i32, -} - -pub fn get_config() -> Config { - Config { enabled: true, count: 0 } -} -"; - let route_file = create_temp_file(&temp_dir, "config.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Config".to_string(), - definition: - r"struct Config { #[serde(default)] enabled: bool, #[serde(default)] count: i32 }" - .to_string(), - ..Default::default() - }); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/config".to_string(), - function_name: "get_config".to_string(), - module_path: "test::config".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_config() -> Config".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("Config")); - } - - // ======== Tests for uncovered lines ======== - - #[test] - fn test_fallback_struct_finding_in_route_files() { - // Test line 65: fallback loop that finds struct in any route file when direct search fails - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create TWO route files - struct is in second file, route references it from first - let route1_content = r" -pub fn get_users() -> Vec { - vec![] -} -"; - let route1_file = create_temp_file(&temp_dir, "users.rs", route1_content); - - let route2_content = r#" -fn default_name() -> String { - "Guest".to_string() -} - -struct User { - #[serde(default = "default_name")] - name: String, -} - -pub fn get_user() -> User { - User { name: "Alice".to_string() } -} -"#; - let route2_file = create_temp_file(&temp_dir, "user.rs", route2_content); - - let mut metadata = CollectedMetadata::new(); - // Add struct but point to route1 (which doesn't contain the struct) - // This forces the fallback loop to search other route files - metadata.structs.push(StructMetadata { - name: "User".to_string(), - definition: r#"struct User { #[serde(default = "default_name")] name: String }"# - .to_string(), - ..Default::default() - }); - // Add BOTH routes - the first doesn't contain User struct, so fallback searches the second - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route1_file.to_string_lossy().to_string(), - signature: "fn get_users() -> Vec".to_string(), - error_status: None, - tags: None, - description: None, - }); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/user".to_string(), - function_name: "get_user".to_string(), - module_path: "test::user".to_string(), - file_path: route2_file.to_string_lossy().to_string(), - signature: "fn get_user() -> User".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Struct should be found via fallback and processed - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - assert!(schemas.contains_key("User")); - } - - #[test] - fn test_process_default_functions_with_no_properties() { - // Test line 152: early return when schema.properties is None - // This happens when a struct has no named fields (unit struct or tuple struct) - use vespera_core::schema::Schema; - - let struct_item: syn::ItemStruct = syn::parse_str("struct Empty;").unwrap(); - let file_ast: syn::File = syn::parse_str("fn foo() {}").unwrap(); - let mut schema = Schema::object(); - schema.properties = None; // Explicitly set to None - - // This should return early without panic - process_default_functions(&struct_item, &file_ast, &mut schema, &BTreeMap::new()); - - // Schema should remain unchanged - assert!(schema.properties.is_none()); - } - - #[test] - fn test_extract_value_from_expr_int_parse_failure() { - // Test line 253: int parse failure (overflow) - // Create an integer literal that's too large to parse as i64 - // Use a literal that syn will parse but i64::parse will fail on - let expr: syn::Expr = syn::parse_str("999999999999999999999999999999").unwrap(); - let value = extract_value_from_expr(&expr); - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_float_parse_failure() { - // Test line 260: float parse failure - // Create a float literal that's too large/invalid - let expr: syn::Expr = syn::parse_str("1e999999").unwrap(); - let value = extract_value_from_expr(&expr); - // This may parse successfully to infinity or fail - either way should handle it - // The important thing is no panic - let _ = value; - } - - #[test] - fn test_extract_value_from_expr_method_call_with_nested_receiver() { - // Test lines 275-276: recursive extraction from method call receiver - // When receiver is not a direct string literal, it tries to extract recursively - // But the recursive call also won't find a Lit, so it returns None - // This test verifies the recursive path is exercised (line 275-276) - let expr: syn::Expr = syn::parse_str(r#"("hello").to_string()"#).unwrap(); - let value = extract_value_from_expr(&expr); - // The receiver is a Paren expression - recursive call is made but returns None - // because Paren is not handled in the match - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_method_call_with_non_literal_receiver() { - // Test lines 275-276: recursive extraction fails for non-literal - let expr: syn::Expr = syn::parse_str(r"some_var.to_string()").unwrap(); - let value = extract_value_from_expr(&expr); - // Cannot extract value from a variable - assert!(value.is_none()); - } - - #[test] - fn test_extract_value_from_expr_method_call_chained_to_string() { - // Test lines 275-276: another case where recursive extraction is attempted - // Chained method calls: 42.to_string() has int literal as receiver - let expr: syn::Expr = syn::parse_str(r"42.to_string()").unwrap(); - let value = extract_value_from_expr(&expr); - // Line 275 recursive call extracts 42 as Number, then line 276 returns it - assert_eq!(value, Some(serde_json::Value::Number(42.into()))); - } - - #[test] - fn test_get_type_default_empty_path_segments() { - // Test empty path segments returns None - // Create a type with empty path segments - - // Use parse to create a valid type, then we verify the normal path works - let ty: syn::Type = syn::parse_str("::String").unwrap(); - // This has segments, so it should work - let value = utils_get_type_default(&ty); - // Global path ::String still has "String" as last segment - assert!(value.is_some()); - - // Test reference type (non-path type) - let ref_ty: syn::Type = syn::parse_str("&str").unwrap(); - let ref_value = utils_get_type_default(&ref_ty); - // Reference is not a Path type, so returns None - assert!(ref_value.is_none()); - } - - #[test] - fn test_get_type_default_tuple_type() { - // Test non-Path type returns None - let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); - let value = utils_get_type_default(&ty); - assert!(value.is_none()); - } - - #[test] - fn test_get_type_default_array_type() { - // Test array type returns None - let ty: syn::Type = syn::parse_str("[i32; 3]").unwrap(); - let value = utils_get_type_default(&ty); - assert!(value.is_none()); - } - - #[test] - fn test_build_path_items_unknown_http_method() { - // Test lines 131-134: route with unknown HTTP method is skipped - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - let route_content = r#" -pub fn get_users() -> String { - "users".to_string() -} -"#; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "INVALID".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Route with unknown HTTP method should be skipped entirely - assert!( - doc.paths.is_empty(), - "Route with unknown HTTP method should be skipped" - ); - } - - #[test] - fn test_build_path_items_unknown_method_skipped_valid_kept() { - // Test that unknown methods are skipped while valid routes are kept - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - let route_content = r#" -pub fn get_users() -> String { - "users".to_string() -} - -pub fn create_users() -> String { - "created".to_string() -} -"#; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - let file_path = route_file.to_string_lossy().to_string(); - - let mut metadata = CollectedMetadata::new(); - // Invalid method route - metadata.routes.push(RouteMetadata { - method: "CONNECT".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: file_path.clone(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - // Valid method route - metadata.routes.push(RouteMetadata { - method: "POST".to_string(), - path: "/users".to_string(), - function_name: "create_users".to_string(), - module_path: "test::users".to_string(), - file_path, - signature: "fn create_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Only the valid POST route should appear - assert_eq!(doc.paths.len(), 1); - let path_item = doc.paths.get("/users").unwrap(); - assert!( - path_item.post.is_some(), - "Valid POST route should be present" - ); - assert!( - path_item.get.is_none(), - "Invalid method route should be skipped" - ); - } - - #[test] - fn test_generate_openapi_with_unparseable_definition() { - // Test line 42: syn::parse_str fails with invalid Rust syntax - // This triggers the `continue` branch when parsing fails - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Invalid".to_string(), - // Invalid Rust syntax - cannot be parsed by syn - definition: "struct { invalid syntax {{{{".to_string(), - include_in_openapi: true, - field_defaults: BTreeMap::new(), - }); - - // Should gracefully skip unparseable definitions - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - // The unparseable definition should be skipped - assert!(doc.components.is_none() || doc.components.as_ref().unwrap().schemas.is_none()); - } - - // ======== Tests for set_property_default helper ======== - - #[test] - fn test_set_property_default_on_inline_schema() { - use vespera_core::schema::{Schema, SchemaRef}; - - let mut properties = BTreeMap::new(); - let mut schema = Schema::object(); - schema.default = None; - properties.insert("name".to_string(), SchemaRef::Inline(Box::new(schema))); - - set_property_default( - &mut properties, - "name", - serde_json::Value::String("Alice".to_string()), - ); - - if let Some(SchemaRef::Inline(prop)) = properties.get("name") { - assert_eq!( - prop.default, - Some(serde_json::Value::String("Alice".to_string())) - ); - } else { - panic!("Expected Inline schema"); - } - } - - #[test] - fn test_set_property_default_does_not_overwrite_existing() { - use vespera_core::schema::{Schema, SchemaRef}; - - let mut properties = BTreeMap::new(); - let mut schema = Schema::object(); - schema.default = Some(serde_json::Value::String("existing".to_string())); - properties.insert("name".to_string(), SchemaRef::Inline(Box::new(schema))); - - set_property_default( - &mut properties, - "name", - serde_json::Value::String("new".to_string()), - ); - - if let Some(SchemaRef::Inline(prop)) = properties.get("name") { - assert_eq!( - prop.default, - Some(serde_json::Value::String("existing".to_string())), - "Should NOT overwrite existing default" - ); - } else { - panic!("Expected Inline schema"); - } - } - - #[test] - fn test_set_property_default_skips_ref_schema() { - use vespera_core::schema::{Reference, SchemaRef}; - - let mut properties = BTreeMap::new(); - properties.insert( - "user".to_string(), - SchemaRef::Ref(Reference::schema("User")), - ); - - // Should silently no-op (Ref variants have no default field) - set_property_default( - &mut properties, - "user", - serde_json::Value::String("ignored".to_string()), - ); - - assert!( - matches!(properties.get("user"), Some(SchemaRef::Ref(_))), - "Should remain a Ref variant" - ); - } - - #[test] - fn test_set_property_default_skips_missing_property() { - let mut properties = BTreeMap::new(); - - // Should silently no-op (property doesn't exist) - set_property_default( - &mut properties, - "nonexistent", - serde_json::Value::Number(42.into()), - ); - - assert!(properties.is_empty(), "Should not insert new properties"); - } - - #[test] - fn test_extract_schema_default_attr_with_value() { - let attrs: Vec = vec![syn::parse_quote!(#[schema(default = "42")])]; - let result = extract_schema_default_attr(&attrs); - assert_eq!(result, Some("42".to_string())); - } - - #[test] - fn test_extract_schema_default_attr_no_default() { - let attrs: Vec = vec![syn::parse_quote!(#[schema(rename = "foo")])]; - let result = extract_schema_default_attr(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_schema_default_attr_non_schema() { - let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; - let result = extract_schema_default_attr(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_parse_default_string_to_json_value_integer() { - let result = parse_default_string_to_json_value("42"); - assert_eq!(result, serde_json::Value::Number(42.into())); - } - - #[test] - fn test_parse_default_string_to_json_value_float() { - let result = parse_default_string_to_json_value("2.72"); - assert_eq!(result, serde_json::json!(2.72)); - } - - #[test] - fn test_parse_default_string_to_json_value_bool() { - let result = parse_default_string_to_json_value("true"); - assert_eq!(result, serde_json::Value::Bool(true)); - } - - #[test] - fn test_parse_default_string_to_json_value_string_fallback() { - let result = parse_default_string_to_json_value("hello world"); - assert_eq!(result, serde_json::Value::String("hello world".to_string())); - } - - #[test] - fn test_process_default_functions_with_schema_default_attr() { - use vespera_core::schema::{Schema, SchemaRef}; - - let file_ast: syn::File = syn::parse_str("").unwrap(); - let struct_item: syn::ItemStruct = - syn::parse_str(r#"pub struct Test { #[schema(default = "100")] pub count: i32 }"#) - .unwrap(); - let mut schema = Schema::object(); - let props = schema.properties.get_or_insert_with(BTreeMap::new); - props.insert( - "count".to_string(), - SchemaRef::Inline(Box::new(Schema::integer())), - ); - process_default_functions(&struct_item, &file_ast, &mut schema, &BTreeMap::new()); - if let Some(SchemaRef::Inline(prop_schema)) = - schema.properties.as_ref().unwrap().get("count") - { - assert_eq!(prop_schema.default, Some(serde_json::json!(100))); - } else { - panic!("Expected inline schema with default"); - } - } - - #[test] - fn test_generate_openapi_route_function_not_in_ast() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = "pub fn get_items() -> String { \"items\".to_string() }\n"; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - assert!( - doc.paths.is_empty(), - "Route with non-matching function should be skipped" - ); - } - - #[test] - fn test_generate_openapi_with_route_storage_fast_path() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = r#" -pub fn get_users() -> String { - "users".to_string() -} -"#; - let route_file = create_temp_file(&temp_dir, "users.rs", route_content); - - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "test::users".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - // Provide route_storage with matching fn_name -> exercises fast path (line 155) let route_storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), + fn_name: "list_users".to_string(), method: Some("get".to_string()), - custom_path: None, + custom_path: Some("/users".to_string()), error_status: None, - tags: None, + typed_responses: None, + tags: Some(vec!["users".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, description: None, - fn_item_str: "pub fn get_users() -> String { \"users\".to_string() }".to_string(), file_path: None, + fn_sig_str: "async fn list_users() -> &'static str".to_string(), }]; - let doc = - generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &route_storage); - - assert!(doc.paths.contains_key("/users")); - let path_item = doc.paths.get("/users").unwrap(); - assert!(path_item.get.is_some()); - let operation = path_item.get.as_ref().unwrap(); - assert_eq!(operation.operation_id, Some("get_users".to_string())); - } - - #[test] - fn test_generate_openapi_with_stored_field_defaults() { - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Config".to_string(), - definition: "struct Config { count: i32, name: String }".to_string(), - include_in_openapi: true, - field_defaults: BTreeMap::from([ - ("count".to_string(), serde_json::json!(42)), - ("name".to_string(), serde_json::json!("default_name")), - ]), - }); - - // Need a route so the file_cache has at least one entry for the fallback in parse_component_schemas - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let route_content = r" -struct Config { count: i32, name: String } -pub fn get_config() -> Config { Config { count: 0, name: String::new() } } -"; - let route_file = create_temp_file(&temp_dir, "config.rs", route_content); - metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/config".to_string(), - function_name: "get_config".to_string(), - module_path: "test::config".to_string(), - file_path: route_file.to_string_lossy().to_string(), - signature: "fn get_config() -> Config".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); - - // Verify schema exists - assert!(doc.components.as_ref().unwrap().schemas.is_some()); - let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); - let config_schema = schemas.get("Config").expect("Config schema should exist"); + let doc = generate_openapi_doc_with_metadata( + Some("Tags API".to_string()), + Some("1.0.0".to_string()), + None, + Some(OpenApiSecurity { + security_schemes: None, + security: None, + tag_descriptions: Some(HashMap::from([ + ("admin".to_string(), "Admin operations".to_string()), + ("users".to_string(), "User operations".to_string()), + ])), + }), + &metadata, + None, + &route_storage, + ); - // Verify default values were set from stored_defaults (Priority 0 path) - if let Some(props) = &config_schema.properties { - if let Some(vespera_core::schema::SchemaRef::Inline(count_schema)) = props.get("count") - { - assert_eq!( - count_schema.default, - Some(serde_json::json!(42)), - "count should have default 42 from stored_defaults" - ); - } - if let Some(vespera_core::schema::SchemaRef::Inline(name_schema)) = props.get("name") { - assert_eq!( - name_schema.default, - Some(serde_json::json!("default_name")), - "name should have default from stored_defaults" - ); - } - } + insta::assert_snapshot!( + "openapi_tag_descriptions", + serde_json::to_string_pretty(&doc).unwrap() + ); } } diff --git a/crates/vespera_macro/src/openapi_generator/component_schemas.rs b/crates/vespera_macro/src/openapi_generator/component_schemas.rs new file mode 100644 index 00000000..f9effb3b --- /dev/null +++ b/crates/vespera_macro/src/openapi_generator/component_schemas.rs @@ -0,0 +1,485 @@ +//! Component schema lookup, file-cache indexing, and schema parsing. + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + path::Path, +}; + +use crate::{ + metadata::CollectedMetadata, + openapi_generator::{defaults::process_default_functions, paths::parallel_filter_map}, + parser::{parse_enum_to_schema, parse_struct_to_schema}, +}; + +/// Build schema name and definition lookup maps from metadata. +/// +/// Registers ALL structs (including `include_in_openapi: false`) so that +/// `schema_type!` generated types can reference them. +pub(super) fn build_schema_lookups( + metadata: &CollectedMetadata, +) -> (HashSet<&str>, HashMap<&str, &str>) { + let mut known_schema_names = HashSet::with_capacity(metadata.structs.len()); + let mut struct_definitions = HashMap::with_capacity(metadata.structs.len()); + + for struct_meta in &metadata.structs { + struct_definitions.insert(struct_meta.name.as_str(), struct_meta.definition.as_str()); + known_schema_names.insert(struct_meta.name.as_str()); + } + + (known_schema_names, struct_definitions) +} + +/// Build file AST cache — parse each unique route file exactly once. +/// +/// Deduplicates file paths first, then parses each file a single time. +/// This eliminates redundant file I/O when multiple routes share a source file. +pub(super) fn build_file_cache(metadata: &CollectedMetadata) -> HashMap { + let unique_paths: BTreeSet<&str> = metadata + .routes + .iter() + .map(|r| r.file_path.as_str()) + .collect(); + let mut cache = HashMap::with_capacity(unique_paths.len()); + for path in unique_paths { + if let Some(ast) = crate::schema_macro::file_cache::get_parsed_file(Path::new(path)) { + cache.insert(path.to_string(), ast); + } + } + cache +} + +/// Build struct name → file path index from cached file ASTs. +/// +/// Enables O(1) lookup of which file contains a given struct definition, +/// replacing the previous O(routes × file_read) linear scan. +pub(super) fn build_struct_file_index( + file_cache: &HashMap, +) -> HashMap { + let mut index = HashMap::with_capacity(file_cache.len() * 4); + for (path, ast) in file_cache { + for item in &ast.items { + if let syn::Item::Struct(s) = item { + index.insert(s.ident.to_string(), path.as_str()); + } + } + } + index +} + +/// Parse struct and enum definitions into `OpenAPI` component schemas. +/// +/// Only includes structs where `include_in_openapi` is true +/// (i.e., from `#[derive(Schema)]`, not from cross-file lookup). +/// Also processes `#[serde(default)]` attributes to extract default values. +/// +/// Uses pre-built `file_cache` and `struct_file_index` for O(1) file lookups +/// instead of scanning all route files per struct. +pub(super) fn parse_component_schemas( + metadata: &CollectedMetadata, + known_schema_names: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, + file_cache: &HashMap, + struct_file_index: &HashMap, +) -> syn::Result> { + // Parse a definition string and build its schema, applying the + // default-value pipeline. `file_ast` is only needed for the + // `#[serde(default = "fn_name")]` fallback (Priority 2) — the + // pre-extracted SCHEMA_STORAGE defaults, `#[schema(default)]` + // attributes, and type defaults apply even without an AST (the + // collector fast path skips parsing, leaving `file_cache` empty). + let build_one = |struct_meta: &crate::metadata::StructMetadata, + file_ast: Option<&syn::File>| + -> syn::Result> { + let parsed = syn::parse_str::(&struct_meta.definition).map_err(|err| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "failed to parse component schema `{}` metadata: {err}", + struct_meta.name + ), + ) + })?; + let mut schema = match &parsed { + syn::Item::Struct(struct_item) => { + parse_struct_to_schema(struct_item, known_schema_names, struct_definitions) + } + syn::Item::Enum(enum_item) => { + parse_enum_to_schema(enum_item, known_schema_names, struct_definitions) + } + _ => return Ok(None), + }; + if let syn::Item::Struct(struct_item) = &parsed { + process_default_functions( + struct_item, + file_ast, + &mut schema, + &struct_meta.field_defaults, + ); + } + Ok(Some((struct_meta.name.clone(), schema))) + }; + + // Partition: structs whose file AST is reachable need the + // (non-`Send`) AST for Priority-2 default extraction and run on + // this thread; everything else parses + builds on workers + // returning plain `Schema` data. + let mut ast_backed: Vec<(&crate::metadata::StructMetadata, &syn::File)> = Vec::new(); + let mut parallel_jobs: Vec<&crate::metadata::StructMetadata> = Vec::new(); + for struct_meta in metadata.structs.iter().filter(|s| s.include_in_openapi) { + // Use ONLY the struct's own indexed file AST for Priority-2 + // (`#[serde(default = "fn")]`) default extraction. The former + // fallback to `metadata.routes.first()`'s AST could resolve a + // same-named default fn from an UNRELATED route file and emit a + // wrong OpenAPI default; a struct whose file is not indexed now + // simply forgoes Priority-2 extraction (other default sources still + // apply) rather than risking an incorrect value. + let file_ast = struct_file_index + .get(&struct_meta.name) + .and_then(|path| file_cache.get(*path)); + match file_ast { + Some(ast) => ast_backed.push((struct_meta, ast)), + None => parallel_jobs.push(struct_meta), + } + } + + let mut schemas = BTreeMap::new(); + for (name, schema) in parallel_filter_map( + ¶llel_jobs, + &|meta: &&crate::metadata::StructMetadata| build_one(meta, None), + )? { + schemas.insert(name, schema); + } + for (struct_meta, ast) in ast_backed { + if let Some((name, schema)) = build_one(struct_meta, Some(ast))? { + schemas.insert(name, schema); + } + } + + Ok(schemas) +} + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, fs, path::PathBuf}; + + use rstest::rstest; + use serde_json::{Value, json}; + use tempfile::TempDir; + use vespera_core::schema::SchemaRef; + + use super::*; + use crate::{ + metadata::{CollectedMetadata, RouteMetadata, StructMetadata}, + openapi_generator::{ + generate_openapi_doc_with_metadata, try_generate_openapi_doc_with_metadata, + }, + }; + + fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> PathBuf { + let file_path = dir.path().join(filename); + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path + } + + fn route_meta(path: &str, fn_name: &str, file_path: &str) -> RouteMetadata { + RouteMetadata { + method: "GET".to_string(), + path: path.to_string(), + function_name: fn_name.to_string(), + module_path: format!("test::{fn_name}"), + file_path: file_path.to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + } + } + + fn struct_meta(name: &str, definition: &str) -> StructMetadata { + StructMetadata { + name: name.to_string(), + definition: definition.to_string(), + ..Default::default() + } + } + + fn schemas( + doc: &vespera_core::openapi::OpenApi, + ) -> &BTreeMap { + doc.components + .as_ref() + .and_then(|c| c.schemas.as_ref()) + .expect("schemas present") + } + + fn property_default<'a>( + schema: &'a vespera_core::schema::Schema, + field_name: &str, + ) -> Option<&'a Value> { + let SchemaRef::Inline(prop_schema) = schema.properties.as_ref()?.get(field_name)? else { + return None; + }; + prop_schema.default.as_ref() + } + + #[test] + fn schema_lookups_include_hidden_structs_for_references() { + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Hidden".to_string(), + definition: "struct Hidden { id: i32 }".to_string(), + include_in_openapi: false, + field_defaults: BTreeMap::new(), + source_identity: None, + }); + + let (known_schema_names, struct_definitions) = build_schema_lookups(&metadata); + + assert!(known_schema_names.contains("Hidden")); + assert_eq!( + *struct_definitions.get("Hidden").unwrap(), + "struct Hidden { id: i32 }" + ); + } + + #[rstest] + #[case::struct_schema("User", "struct User { id: i32, name: String }")] + #[case::enum_schema("Status", "enum Status { Active, Inactive, Pending }")] + #[case::enum_with_data( + "Message", + "enum Message { Text(String), User { id: i32, name: String } }" + )] + fn valid_component_definitions_are_included(#[case] name: &str, #[case] definition: &str) { + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(struct_meta(name, definition)); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + + assert!(schemas(&doc).contains_key(name)); + } + + #[test] + fn non_struct_non_enum_component_definitions_are_skipped() { + let mut metadata = CollectedMetadata::new(); + metadata + .structs + .push(struct_meta("Config", "const CONFIG: i32 = 42;")); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + + assert!(doc.components.is_none() || doc.components.as_ref().unwrap().schemas.is_none()); + } + + #[test] + fn unparseable_component_definition_surfaces_error() { + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Invalid".to_string(), + definition: "struct { invalid syntax {{{{".to_string(), + include_in_openapi: true, + field_defaults: BTreeMap::new(), + source_identity: None, + }); + + let err = + try_generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]) + .expect_err("invalid component metadata must surface as an error"); + + assert!( + err.to_string() + .contains("failed to parse component schema `Invalid` metadata"), + "unexpected error: {err}" + ); + } + + #[test] + fn enum_schema_and_route_are_generated_together() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_file = create_temp_file( + &temp_dir, + "status_route.rs", + "pub fn get_status() -> Status { Status::Active }", + ); + + let mut metadata = CollectedMetadata::new(); + metadata + .structs + .push(struct_meta("Status", "enum Status { Active, Inactive }")); + metadata.routes.push(route_meta( + "/status", + "get_status", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + + assert!(schemas(&doc).contains_key("Status")); + assert!(doc.paths.contains_key("/status")); + } + + #[test] + fn serde_default_function_sets_property_default() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_file = create_temp_file( + &temp_dir, + "user.rs", + r#" +fn default_name() -> String { "John".to_string() } + +struct User { + #[serde(default = "default_name")] + name: String, +} + +pub fn get_user() -> User { User { name: "Alice".to_string() } } +"#, + ); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(struct_meta( + "User", + r#"struct User { #[serde(default = "default_name")] name: String }"#, + )); + metadata.routes.push(route_meta( + "/user", + "get_user", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + let user_schema = schemas(&doc).get("User").expect("User schema"); + + assert_eq!(property_default(user_schema, "name"), Some(&json!("John"))); + } + + #[test] + fn serde_simple_default_uses_type_defaults() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_file = create_temp_file( + &temp_dir, + "config.rs", + r" +struct Config { + #[serde(default)] + enabled: bool, + #[serde(default)] + count: i32, +} + +pub fn get_config() -> Config { Config { enabled: true, count: 0 } } +", + ); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(struct_meta( + "Config", + r"struct Config { #[serde(default)] enabled: bool, #[serde(default)] count: i32 }", + )); + metadata.routes.push(route_meta( + "/config", + "get_config", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + let config_schema = schemas(&doc).get("Config").expect("Config schema"); + + assert_eq!( + property_default(config_schema, "enabled"), + Some(&json!(false)) + ); + assert_eq!(property_default(config_schema, "count"), Some(&json!(0))); + } + + #[test] + fn struct_file_index_finds_struct_in_another_route_file() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route1_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> Vec { vec![] }", + ); + let route2_file = create_temp_file( + &temp_dir, + "user.rs", + r#" +fn default_name() -> String { "Guest".to_string() } + +struct User { + #[serde(default = "default_name")] + name: String, +} + +pub fn get_user() -> User { User { name: "Alice".to_string() } } +"#, + ); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(struct_meta( + "User", + r#"struct User { #[serde(default = "default_name")] name: String }"#, + )); + metadata.routes.push(route_meta( + "/users", + "get_users", + &route1_file.to_string_lossy(), + )); + metadata.routes.push(route_meta( + "/user", + "get_user", + &route2_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + let user_schema = schemas(&doc).get("User").expect("User schema"); + + assert_eq!(property_default(user_schema, "name"), Some(&json!("Guest"))); + } + + #[test] + fn stored_field_defaults_have_highest_priority() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_file = create_temp_file( + &temp_dir, + "config.rs", + r" +struct Config { count: i32, name: String } +pub fn get_config() -> Config { Config { count: 0, name: String::new() } } +", + ); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Config".to_string(), + definition: "struct Config { count: i32, name: String }".to_string(), + include_in_openapi: true, + field_defaults: BTreeMap::from([ + ("count".to_string(), json!(42)), + ("name".to_string(), json!("default_name")), + ]), + source_identity: None, + }); + metadata.routes.push(route_meta( + "/config", + "get_config", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + let config_schema = schemas(&doc).get("Config").expect("Config schema"); + + assert_eq!(property_default(config_schema, "count"), Some(&json!(42))); + assert_eq!( + property_default(config_schema, "name"), + Some(&json!("default_name")) + ); + } +} diff --git a/crates/vespera_macro/src/openapi_generator/defaults.rs b/crates/vespera_macro/src/openapi_generator/defaults.rs new file mode 100644 index 00000000..663529f1 --- /dev/null +++ b/crates/vespera_macro/src/openapi_generator/defaults.rs @@ -0,0 +1,828 @@ +//! Default-value extraction for OpenAPI schema generation. +//! +//! Handles the three sources of struct field defaults: +//! 1. Pre-extracted `SCHEMA_STORAGE` defaults (populated by `#[derive(Schema)]`) +//! 2. `#[schema(default = "...")]` attributes (generated by `schema_type!`) +//! 3. `#[serde(default)]` / `#[serde(default = "fn_name")]` attributes +//! (the function variant needs a parsed file AST) + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::{ + parser::{ + extract_default, extract_field_rename, extract_rename_all, rename_field, + strip_raw_prefix_owned, + }, + schema_macro::type_utils::get_type_default as utils_get_type_default, +}; + +/// Set the default value on an inline property schema, if not already set. +/// +/// Looks up `field_name` in the properties map. If found as an inline schema +/// and the schema has no existing default, sets `value` as the default. +pub(super) fn set_property_default( + properties: &mut BTreeMap, + field_name: &str, + value: serde_json::Value, +) { + use vespera_core::schema::{SchemaRef, SchemaType}; + + if let Some(SchemaRef::Inline(prop_schema)) = properties.get_mut(field_name) + && prop_schema.default.is_none() + { + // A default on a string-typed property must itself be a string — e.g. a + // `Decimal` field (string wire type) carrying a numeric DB default value. + let value = if prop_schema.schema_type == Some(SchemaType::String) { + match value { + serde_json::Value::Number(n) => serde_json::Value::String(n.to_string()), + serde_json::Value::Bool(b) => serde_json::Value::String(b.to_string()), + other => other, + } + } else { + value + }; + prop_schema.default = Some(value); + } +} + +/// Process default functions for struct fields +/// This function extracts default values from: +/// 1. `#[schema(default = "value")]` attributes (generated by `schema_type!` from `sea_orm(default_value)`) +/// 2. `#[serde(default = "function_name")]` by finding the function in the file AST +/// 3. `#[serde(default)]` by using type-specific defaults +pub(super) fn process_default_functions( + struct_item: &syn::ItemStruct, + file_ast: Option<&syn::File>, + schema: &mut vespera_core::schema::Schema, + stored_defaults: &BTreeMap, +) { + use syn::Fields; + + // Extract rename_all from struct level + let struct_rename_all = extract_rename_all(&struct_item.attrs); + + // Locate the object schema that actually holds the fields. Flatten structs + // are `allOf`-shaped: their own (non-flattened) fields live in the first + // inline `allOf` member, not a top-level `properties` map — defaults (and + // the `required` demotion below) must apply there too. + let Some(target) = field_bearing_schema_mut(schema) else { + return; + }; + + // Fields carrying a `#[serde(default)]` whose default value cannot be + // resolved at compile time. A non-`Option` such field would otherwise be + // `required` with no `default` — impossible for a client to satisfy — so it + // is demoted to optional after the field walk. + let mut unresolved_default_fields: BTreeSet = BTreeSet::new(); + + // Process each field in the struct + if let Some(properties) = target.properties.as_mut() + && let Fields::Named(fields_named) = &struct_item.fields + { + for field in &fields_named.named { + let rust_field_name = field.ident.as_ref().map_or_else( + || "unknown".to_string(), + |i| strip_raw_prefix_owned(i.to_string()), + ); + let field_name = extract_field_rename(&field.attrs) + .unwrap_or_else(|| rename_field(&rust_field_name, struct_rename_all.as_deref())); + + // Priority 0: Pre-extracted defaults from SCHEMA_STORAGE (populated by #[derive(Schema)]) + if let Some(value) = stored_defaults.get(&rust_field_name) { + set_property_default(properties, &field_name, value.clone()); + continue; + } + + // Priority 1: #[schema(default = "value")] from schema_type! macro. + // The attribute value is a string literal; for a string-typed field + // use it VERBATIM so lexical form is preserved (e.g. a leading-zero + // id `"00123"` must NOT normalise to `"123"`), otherwise infer the + // JSON type from the string. + if let Some(default_str) = extract_schema_default_attr(&field.attrs) { + let is_string_field = matches!( + properties.get(&field_name), + Some(vespera_core::schema::SchemaRef::Inline(s)) + if s.schema_type == Some(vespera_core::schema::SchemaType::String) + ); + let value = if is_string_field { + serde_json::Value::String(default_str) + } else { + parse_default_string_to_json_value(&default_str) + }; + set_property_default(properties, &field_name, value); + continue; + } + + // Priority 2: #[serde(default)] / #[serde(default = "fn")] + let default_info = match extract_default(&field.attrs) { + Some(Some(func_name)) => func_name, // default = "function_name" + Some(None) => { + // Simple default (no function): use the type-specific default + // when known; otherwise serde fills a value we cannot + // express, so demote the field from `required`. + if let Some(default_value) = utils_get_type_default(&field.ty) { + set_property_default(properties, &field_name, default_value); + } else { + unresolved_default_fields.insert(field_name); + } + continue; + } + None => continue, // No default attribute + }; + + // Priority 2 (function form) is the only step that needs the AST, so + // it degrades gracefully when none is available. When the value + // cannot be extracted (function missing or non-literal body), the + // field has a serde default we cannot express → demote it. + let resolved = file_ast + .and_then(|ast| find_function_in_file(ast, &default_info)) + .and_then(extract_default_value_from_function); + if let Some(default_value) = resolved { + set_property_default(properties, &field_name, default_value); + } else { + unresolved_default_fields.insert(field_name); + } + } + } + + // Demote fields with an unexpressible serde default from `required` so the + // spec never advertises a required field a client cannot provide. + if !unresolved_default_fields.is_empty() { + if let Some(required) = target.required.as_mut() { + required.retain(|name| !unresolved_default_fields.contains(name)); + } + if target.required.as_ref().is_some_and(Vec::is_empty) { + target.required = None; + } + } +} + +/// Return the object schema that actually carries the struct's fields: the +/// schema itself, or — for flatten/`allOf`-shaped structs — the first inline +/// `allOf` member (where the non-flattened fields live). +fn field_bearing_schema_mut( + schema: &mut vespera_core::schema::Schema, +) -> Option<&mut vespera_core::schema::Schema> { + if schema.properties.is_some() { + return Some(schema); + } + schema + .all_of + .as_mut()? + .iter_mut() + .find_map(|member| match member { + vespera_core::schema::SchemaRef::Inline(inline) => Some(inline.as_mut()), + vespera_core::schema::SchemaRef::Ref(_) => None, + }) +} + +/// Extract `default` value from `#[schema(default = "...")]` field attribute. +/// +/// This attribute is generated by `schema_type!` when converting `sea_orm(default_value)`. +/// It carries the raw default value string for OpenAPI schema generation. +pub(super) fn extract_schema_default_attr(attrs: &[syn::Attribute]) -> Option { + attrs + .iter() + .filter(|attr| attr.path().is_ident("schema")) + .find_map(|attr| { + let mut default_value = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("default") { + let value = meta.value()?; + let lit: syn::LitStr = value.parse()?; + default_value = Some(lit.value()); + } + Ok(()) + }); + default_value + }) +} + +/// Parse a default value string into the appropriate `serde_json::Value`. +/// +/// Tries to infer the JSON type: integer → number → bool → string (fallback). +pub(super) fn parse_default_string_to_json_value(value: &str) -> serde_json::Value { + // Try integer first + if let Ok(n) = value.parse::() { + return serde_json::Value::Number(n.into()); + } + // Try float + if let Ok(f) = value.parse::() + && let Some(n) = serde_json::Number::from_f64(f) + { + return serde_json::Value::Number(n); + } + // Try bool + if let Ok(b) = value.parse::() { + return serde_json::Value::Bool(b); + } + // Fallback to string + serde_json::Value::String(value.to_string()) +} + +/// Find a function by name in the file AST +pub fn find_function_in_file<'a>( + file_ast: &'a syn::File, + function_name: &str, +) -> Option<&'a syn::ItemFn> { + let local_name = function_name.rsplit("::").next().unwrap_or(function_name); + file_ast.items.iter().find_map(|item| match item { + syn::Item::Fn(fn_item) if fn_item.sig.ident == local_name => Some(fn_item), + _ => None, + }) +} + +/// Extract default value from function body +/// This tries to extract literal values from common patterns like: +/// - "`value".to_string()` -> "value" +/// - 42 -> 42 +/// - true -> true +/// - vec![] -> [] +pub fn extract_default_value_from_function(func: &syn::ItemFn) -> Option { + // Try to find return statement or expression + for stmt in &func.block.stmts { + if let syn::Stmt::Expr(expr, _) = stmt { + // Direct expression (like "value".to_string()) + if let Some(value) = extract_value_from_expr(expr) { + return Some(value); + } + // Or return statement + if let syn::Expr::Return(ret) = expr + && let Some(expr) = &ret.expr + && let Some(value) = extract_value_from_expr(expr) + { + return Some(value); + } + } + } + + None +} + +/// Extract value from expression +pub(super) fn extract_value_from_expr(expr: &syn::Expr) -> Option { + use syn::{Expr, ExprLit, ExprMacro, Lit}; + + match expr { + // Literal values + Expr::Lit(ExprLit { lit, .. }) => match lit { + Lit::Str(s) => Some(serde_json::Value::String(s.value())), + Lit::Int(i) => i + .base10_parse::() + .ok() + .map(|v| serde_json::Value::Number(v.into())), + Lit::Float(f) => f + .base10_parse::() + .ok() + .and_then(serde_json::Number::from_f64) + .map(serde_json::Value::Number), + Lit::Bool(b) => Some(serde_json::Value::Bool(b.value)), + _ => None, + }, + // Method calls like "value".to_string() + Expr::MethodCall(method_call) => { + if method_call.method == "to_string" { + // Get the receiver (the string literal) + // Try direct match first + if let Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) = method_call.receiver.as_ref() + { + return Some(serde_json::Value::String(s.value())); + } + // Try to extract from nested expressions (e.g., if the receiver is wrapped) + if let Some(value) = extract_value_from_expr(method_call.receiver.as_ref()) { + return Some(value); + } + } + None + } + // Associated function calls like String::from("value") + Expr::Call(call) => { + let Expr::Path(path) = call.func.as_ref() else { + return None; + }; + let mut segments = path.path.segments.iter().rev(); + let last = segments.next()?; + let prev = segments.next()?; + if last.ident == "from" + && prev.ident == "String" + && call.args.len() == 1 + && let Some(first_arg) = call.args.first() + { + return extract_value_from_expr(first_arg).and_then(|value| match value { + serde_json::Value::String(_) => Some(value), + _ => None, + }); + } + None + } + // Macro calls like vec![...] + Expr::Macro(ExprMacro { mac, .. }) => { + if mac.path.is_ident("vec") { + // `vec![]` → empty array. `vec![a, b, ...]` → the array of its + // element values, but ONLY when every element resolves to a + // literal; otherwise the default is unrepresentable and we + // return `None` (the field is then demoted from `required`) + // rather than emitting a WRONG empty `[]` for a non-empty + // `vec!` (the prior behaviour silently dropped the elements). + if mac.tokens.is_empty() { + return Some(serde_json::Value::Array(Vec::new())); + } + return mac + .parse_body_with( + syn::punctuated::Punctuated::::parse_terminated, + ) + .ok() + .and_then(|elems| { + elems + .iter() + .map(extract_value_from_expr) + .collect::>>() + }) + .map(serde_json::Value::Array); + } + None + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use rstest::rstest; + use serde_json::{Value, json}; + use vespera_core::schema::{Reference, Schema, SchemaRef}; + + use super::*; + + fn parse_expr(src: &str) -> syn::Expr { + syn::parse_str(src).expect("expr parses") + } + + fn parse_fn(src: &str) -> syn::ItemFn { + syn::parse_str(src).expect("fn parses") + } + + fn parse_type(src: &str) -> syn::Type { + syn::parse_str(src).expect("type parses") + } + + // ---------- extract_value_from_expr ---------- + + #[rstest] + #[case::int("42", Some(Value::Number(42.into())))] + #[case::string(r#""hello""#, Some(Value::String("hello".to_string())))] + #[case::bool_true("true", Some(Value::Bool(true)))] + #[case::bool_false("false", Some(Value::Bool(false)))] + #[case::to_string(r#""hello".to_string()"#, Some(Value::String("hello".to_string())))] + #[case::string_from(r#"String::from("hello")"#, Some(Value::String("hello".to_string())))] + #[case::vec_macro("vec![]", Some(Value::Array(vec![])))] + #[case::vec_macro_nonempty("vec![1, 2, 3]", Some(json!([1, 2, 3])))] + #[case::vec_macro_strings(r#"vec!["a", "b"]"#, Some(json!(["a", "b"])))] + #[case::vec_macro_unresolvable("vec![some_var]", None)] + #[case::int_to_string("42.to_string()", Some(Value::Number(42.into())))] + #[case::binary_unsupported("1 + 2", None)] + #[case::method_call_non_to_string(r#""hello".len()"#, None)] + #[case::byte_lit_unsupported("b'a'", None)] + #[case::non_vec_macro(r#"println!("test")"#, None)] + #[case::nested_paren_receiver(r#"("hello").to_string()"#, None)] + #[case::non_literal_receiver("some_var.to_string()", None)] + #[case::int_overflow("999999999999999999999999999999", None)] + fn extract_value_from_expr_cases(#[case] src: &str, #[case] expected: Option) { + assert_eq!(extract_value_from_expr(&parse_expr(src)), expected); + } + + #[test] + fn extract_value_from_expr_float_in_range() { + // Float equality probe is separate — 12.34 round-trips but the assertion + // needs a tolerance check rather than direct equality. + let value = extract_value_from_expr(&parse_expr("12.34")); + match value { + Some(Value::Number(n)) => assert!((n.as_f64().unwrap() - 12.34).abs() < 0.001), + other => panic!("expected number, got {other:?}"), + } + } + + #[test] + fn extract_value_from_expr_float_parse_failure_does_not_panic() { + // 1e999999 may parse to infinity or fail — either way the call must not panic. + let _ = extract_value_from_expr(&parse_expr("1e999999")); + } + + // ---------- get_type_default (re-exported helper) ---------- + + #[rstest] + #[case::string("String", Some(Value::String(String::new())))] + #[case::i8("i8", Some(Value::Number(0.into())))] + #[case::i16("i16", Some(Value::Number(0.into())))] + #[case::i32("i32", Some(Value::Number(0.into())))] + #[case::i64("i64", Some(Value::Number(0.into())))] + #[case::u8("u8", Some(Value::Number(0.into())))] + #[case::u16("u16", Some(Value::Number(0.into())))] + #[case::u32("u32", Some(Value::Number(0.into())))] + #[case::u64("u64", Some(Value::Number(0.into())))] + #[case::bool("bool", Some(Value::Bool(false)))] + #[case::unknown_custom("CustomType", None)] + #[case::non_path_ref("&str", None)] + #[case::tuple("(i32, String)", None)] + #[case::array("[i32; 3]", None)] + fn get_type_default_cases(#[case] src: &str, #[case] expected: Option) { + assert_eq!(utils_get_type_default(&parse_type(src)), expected); + } + + #[rstest] + #[case::f32("f32")] + #[case::f64("f64")] + fn get_type_default_floats_present(#[case] src: &str) { + assert!(utils_get_type_default(&parse_type(src)).is_some()); + } + + #[test] + fn get_type_default_global_path_still_resolved() { + // `::String` has a leading colon-colon but the last segment is still `String`. + assert!(utils_get_type_default(&parse_type("::String")).is_some()); + } + + // ---------- find_function_in_file ---------- + + #[rstest] + #[case("foo", true)] + #[case("defaults::foo", true)] + #[case("bar", true)] + #[case("baz", true)] + #[case("nonexistent", false)] + fn find_function_in_file_cases(#[case] needle: &str, #[case] expected: bool) { + let file: syn::File = syn::parse_str( + r" + fn foo() {} + fn bar() -> i32 { 42 } + fn baz(x: i32) -> i32 { x } + ", + ) + .unwrap(); + assert_eq!(find_function_in_file(&file, needle).is_some(), expected); + } + + // ---------- extract_default_value_from_function ---------- + + #[test] + fn extract_default_value_from_function_direct_expr() { + let func = parse_fn("fn default_value() -> i32 { 42 }"); + assert_eq!( + extract_default_value_from_function(&func), + Some(Value::Number(42.into())) + ); + } + + #[test] + fn extract_default_value_from_function_explicit_return() { + let func = parse_fn(r#"fn default_value() -> String { return "hello".to_string() }"#); + assert_eq!( + extract_default_value_from_function(&func), + Some(Value::String("hello".to_string())) + ); + } + + #[test] + fn process_default_functions_applies_string_default_fn_value() { + let file_ast: syn::File = syn::parse_str( + r#" + fn default_sort() -> String { "asc".to_string() } + fn default_direction() -> String { String::from("desc") } + "#, + ) + .unwrap(); + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + pub struct Test { + #[serde(default = "default_sort")] + pub sort: String, + #[serde(default = "default_direction")] + pub direction: String, + } + "#, + ) + .unwrap(); + let mut schema = Schema::object(); + let props = schema.properties.get_or_insert_with(BTreeMap::new); + props.insert( + "sort".to_string(), + SchemaRef::Inline(Box::new(Schema::string())), + ); + props.insert( + "direction".to_string(), + SchemaRef::Inline(Box::new(Schema::string())), + ); + + process_default_functions(&struct_item, Some(&file_ast), &mut schema, &BTreeMap::new()); + + let properties = schema.properties.as_ref().unwrap(); + assert_inline_default(properties, "sort", &json!("asc")); + assert_inline_default(properties, "direction", &json!("desc")); + } + + #[test] + fn process_default_functions_preserves_lexical_string_default() { + // A `#[schema(default = "...")]` on a string field must keep the literal + // verbatim — a numeric-looking default like a zero-padded id must NOT be + // parsed to a number and back (which dropped leading zeroes: + // "00123" -> "123"). + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + pub struct Test { + #[schema(default = "00123")] + pub zip: String, + } + "#, + ) + .unwrap(); + let mut schema = Schema::object(); + let props = schema.properties.get_or_insert_with(BTreeMap::new); + props.insert( + "zip".to_string(), + SchemaRef::Inline(Box::new(Schema::string())), + ); + + process_default_functions(&struct_item, None, &mut schema, &BTreeMap::new()); + + let properties = schema.properties.as_ref().unwrap(); + assert_inline_default(properties, "zip", &json!("00123")); + } + + #[test] + fn extract_default_value_from_function_no_value() { + let func = parse_fn("fn default_value() { let x = 1; }"); + assert!(extract_default_value_from_function(&func).is_none()); + } + + // ---------- extract_schema_default_attr ---------- + + #[rstest] + #[case::with_value( + syn::parse_quote!(#[schema(default = "42")]), + Some("42".to_string()), + )] + #[case::no_default(syn::parse_quote!(#[schema(rename = "foo")]), None)] + #[case::non_schema(syn::parse_quote!(#[serde(default)]), None)] + fn extract_schema_default_attr_cases( + #[case] attr: syn::Attribute, + #[case] expected: Option, + ) { + assert_eq!(extract_schema_default_attr(&[attr]), expected); + } + + // ---------- parse_default_string_to_json_value ---------- + + #[rstest] + #[case::integer("42", json!(42))] + #[case::float("2.72", json!(2.72))] + #[case::bool("true", json!(true))] + #[case::string_fallback("hello world", json!("hello world"))] + fn parse_default_string_to_json_value_cases(#[case] input: &str, #[case] expected: Value) { + assert_eq!(parse_default_string_to_json_value(input), expected); + } + + // ---------- set_property_default ---------- + + fn inline_prop(default: Option) -> SchemaRef { + let mut schema = Schema::object(); + schema.default = default; + SchemaRef::Inline(Box::new(schema)) + } + + fn assert_inline_default( + properties: &BTreeMap, + key: &str, + expected: &Value, + ) { + let SchemaRef::Inline(prop) = properties.get(key).expect("property present") else { + panic!("expected inline schema for {key}"); + }; + assert_eq!(prop.default.as_ref(), Some(expected)); + } + + #[test] + fn set_property_default_sets_value_on_inline_schema_with_no_default() { + let mut properties = BTreeMap::new(); + properties.insert("name".to_string(), inline_prop(None)); + + set_property_default(&mut properties, "name", json!("Alice")); + + assert_inline_default(&properties, "name", &json!("Alice")); + } + + #[test] + fn set_property_default_does_not_overwrite_existing() { + let mut properties = BTreeMap::new(); + properties.insert("name".to_string(), inline_prop(Some(json!("existing")))); + + set_property_default(&mut properties, "name", json!("new")); + + assert_inline_default(&properties, "name", &json!("existing")); + } + + #[test] + fn set_property_default_skips_ref_schema() { + let mut properties = BTreeMap::new(); + properties.insert( + "user".to_string(), + SchemaRef::Ref(Reference::schema("User")), + ); + + set_property_default(&mut properties, "user", json!("ignored")); + + assert!(matches!(properties.get("user"), Some(SchemaRef::Ref(_)))); + } + + #[test] + fn set_property_default_skips_missing_property() { + let mut properties = BTreeMap::new(); + + set_property_default(&mut properties, "nonexistent", json!(42)); + + assert!(properties.is_empty()); + } + + // ---------- process_default_functions ---------- + + #[test] + fn process_default_functions_early_returns_when_properties_none() { + let struct_item: syn::ItemStruct = syn::parse_str("struct Empty;").unwrap(); + let file_ast: syn::File = syn::parse_str("fn foo() {}").unwrap(); + let mut schema = Schema::object(); + schema.properties = None; + + process_default_functions(&struct_item, Some(&file_ast), &mut schema, &BTreeMap::new()); + + assert!(schema.properties.is_none()); + } + + #[test] + fn process_default_functions_applies_schema_default_attr() { + let file_ast: syn::File = syn::parse_str("").unwrap(); + let struct_item: syn::ItemStruct = + syn::parse_str(r#"pub struct Test { #[schema(default = "100")] pub count: i32 }"#) + .unwrap(); + let mut schema = Schema::object(); + let props = schema.properties.get_or_insert_with(BTreeMap::new); + props.insert( + "count".to_string(), + SchemaRef::Inline(Box::new(Schema::integer())), + ); + + process_default_functions(&struct_item, Some(&file_ast), &mut schema, &BTreeMap::new()); + + assert_inline_default(schema.properties.as_ref().unwrap(), "count", &json!(100)); + } + + #[test] + fn process_default_functions_applies_default_into_flatten_allof_member() { + // Flatten struct: the own field `sort` (defaulted) lives in the inline + // `allOf[0]` member, `pagination` is flattened to a `$ref`. The default + // must still land on `sort` even though there is no top-level + // `properties` map. + let file_ast: syn::File = + syn::parse_str(r#"fn default_sort() -> String { "asc".to_string() }"#).unwrap(); + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + pub struct UserListRequest { + #[serde(default = "default_sort")] + pub sort: String, + #[serde(flatten)] + pub pagination: Pagination, + } + "#, + ) + .unwrap(); + + let mut inline = Schema::object(); + inline.properties.get_or_insert_with(BTreeMap::new).insert( + "sort".to_string(), + SchemaRef::Inline(Box::new(Schema::string())), + ); + let mut schema = Schema::default(); + schema.all_of = Some(vec![ + SchemaRef::Inline(Box::new(inline)), + SchemaRef::Ref(Reference::schema("Pagination")), + ]); + + process_default_functions(&struct_item, Some(&file_ast), &mut schema, &BTreeMap::new()); + + let all_of = schema.all_of.as_ref().expect("allOf present"); + let SchemaRef::Inline(inline) = &all_of[0] else { + panic!("expected inline allOf member"); + }; + assert_inline_default(inline.properties.as_ref().unwrap(), "sort", &json!("asc")); + } + + #[test] + fn process_default_functions_demotes_unresolvable_fn_default_from_required() { + // `#[serde(default = "fn")]` whose body is not a simple literal: no value + // can be extracted at compile time, so the field must drop out of + // `required` (a required field with no default is unsatisfiable). + let file_ast: syn::File = + syn::parse_str("fn complex() -> Vec { compute_tags() }").unwrap(); + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + pub struct Req { + pub name: String, + #[serde(default = "complex")] + pub tags: Vec, + } + "#, + ) + .unwrap(); + let mut schema = Schema::object(); + let props = schema.properties.get_or_insert_with(BTreeMap::new); + props.insert( + "name".to_string(), + SchemaRef::Inline(Box::new(Schema::string())), + ); + props.insert( + "tags".to_string(), + SchemaRef::Inline(Box::new(Schema::object())), + ); + schema.required = Some(vec!["name".to_string(), "tags".to_string()]); + + process_default_functions(&struct_item, Some(&file_ast), &mut schema, &BTreeMap::new()); + + let required = schema.required.as_ref().expect("required present"); + assert!( + required.contains(&"name".to_string()), + "name stays required" + ); + assert!( + !required.contains(&"tags".to_string()), + "tags must be demoted: its serde default cannot be expressed" + ); + } + + #[test] + fn process_default_functions_demotes_simple_default_without_type_default() { + // `#[serde(default)]` on `Vec`: `get_type_default` yields no value for + // Vec, so the field is demoted from `required`. + let struct_item: syn::ItemStruct = syn::parse_str( + r" + pub struct Req { + pub name: String, + #[serde(default)] + pub tags: Vec, + } + ", + ) + .unwrap(); + let mut schema = Schema::object(); + let props = schema.properties.get_or_insert_with(BTreeMap::new); + props.insert( + "name".to_string(), + SchemaRef::Inline(Box::new(Schema::string())), + ); + props.insert( + "tags".to_string(), + SchemaRef::Inline(Box::new(Schema::object())), + ); + schema.required = Some(vec!["name".to_string(), "tags".to_string()]); + + process_default_functions(&struct_item, None, &mut schema, &BTreeMap::new()); + + let required = schema.required.as_ref().expect("required present"); + assert!(required.contains(&"name".to_string())); + assert!( + !required.contains(&"tags".to_string()), + "Vec serde default demoted" + ); + } + + #[test] + fn process_default_functions_keeps_required_when_default_resolvable() { + // A resolvable default keeps the field `required` AND sets `default` + // (the user's required+default strategy is preserved). + let file_ast: syn::File = + syn::parse_str(r#"fn default_sort() -> String { "asc".to_string() }"#).unwrap(); + let struct_item: syn::ItemStruct = syn::parse_str( + r#"pub struct Req { #[serde(default = "default_sort")] pub sort: String }"#, + ) + .unwrap(); + let mut schema = Schema::object(); + schema.properties.get_or_insert_with(BTreeMap::new).insert( + "sort".to_string(), + SchemaRef::Inline(Box::new(Schema::string())), + ); + schema.required = Some(vec!["sort".to_string()]); + + process_default_functions(&struct_item, Some(&file_ast), &mut schema, &BTreeMap::new()); + + assert!( + schema + .required + .as_ref() + .unwrap() + .contains(&"sort".to_string()), + "resolvable default keeps the field required" + ); + assert_inline_default(schema.properties.as_ref().unwrap(), "sort", &json!("asc")); + } +} diff --git a/crates/vespera_macro/src/openapi_generator/paths.rs b/crates/vespera_macro/src/openapi_generator/paths.rs new file mode 100644 index 00000000..f24937d9 --- /dev/null +++ b/crates/vespera_macro/src/openapi_generator/paths.rs @@ -0,0 +1,370 @@ +//! Build `PathItem`s from collected route metadata. +//! +//! This module owns the parallel fan-out infrastructure used during +//! OpenAPI generation: +//! +//! * [`PARALLEL_THRESHOLD`] / [`parallel_filter_map`] — `filter_map` +//! across worker threads, with a sequential fast-path below +//! `PARALLEL_THRESHOLD`. +//! * [`FallbackGuard`] — forces proc-macro2's thread-safe fallback +//! implementation while workers parse `syn` source strings. +//! * [`run_route_jobs_parallel`] — convenience wrapper around +//! `parallel_filter_map` for [`RouteJob`] → [`BuiltOperation`]. +//! +//! Both `build_path_items` (route signatures) and +//! `parse_component_schemas` (struct definitions) drive worker pools +//! through `parallel_filter_map`. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use vespera_core::route::{HttpMethod, PathItem}; + +use crate::{ + collector::normalize_path_key, + metadata::CollectedMetadata, + parser::{OperationRouteConfig, build_operation_from_function}, + route_impl::StoredRouteInfo, +}; + +type FnIndex<'a> = HashMap>; +type StorageFnSigs<'a> = HashMap<(Option, &'a str), Option<&'a str>>; + +/// Build path items and collect tags from route metadata. +/// +/// Uses `route_storage` (from `#[route]` macro) as the primary source for function +/// signatures. Falls back to pre-built `file_cache` when ROUTE_STORAGE doesn't +/// have an entry (e.g., during tests or for routes added without the attribute). +pub(super) fn build_path_items( + metadata: &CollectedMetadata, + known_schema_names: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, + file_cache: &HashMap, + route_storage: &[StoredRouteInfo], +) -> syn::Result<(BTreeMap, BTreeSet)> { + let mut paths = BTreeMap::new(); + let mut all_tags = BTreeSet::new(); + + // Compute once: `cwd` anchors every path normalization below so the + // three path sources — `file_cache` keys (collector), route metadata + // spans, and ROUTE_STORAGE `#[route]` spans — compare in one canonical + // space (separator/relativity/case can differ, especially on Windows). + let cwd = std::env::current_dir().unwrap_or_default(); + + // Build the file-AST function index FIRST so the storage path + // below can skip any function whose AST is already reachable through + // `file_cache`. `collector::collect_metadata` has already walked + // these files via `syn::parse_file`, so re-parsing `fn_sig_str` + // from ROUTE_STORAGE for the same function is pure duplicated work. + // + // Keyed by the NORMALIZED path so the `already_in_ast` storage check + // and the main-loop AST lookup match regardless of path format — a raw + // key misses when the `#[route]` span path differs from the collector's + // `file_cache` key, needlessly re-parsing the signature on a worker. + let fn_index: FnIndex<'_> = file_cache + .iter() + .map(|(path, ast)| { + let fns: HashMap = ast + .items + .iter() + .filter_map(|item| { + if let syn::Item::Fn(fn_item) = item { + Some((fn_item.sig.ident.to_string(), fn_item)) + } else { + None + } + }) + .collect(); + (normalize_path_key(path, &cwd), fns) + }) + .collect(); + + // ROUTE_STORAGE-backed function signatures (skipped when the same + // function is already covered by `fn_index` — re-parsing would be + // duplicated work). These are plain *strings*, so the expensive + // `syn::parse_str` + operation build runs on worker threads below; + // `syn` ASTs are not `Send`, which is also why fn_index-backed + // routes stay on this thread. + let storage_fn_sigs = build_storage_fn_sigs(route_storage, &fn_index, &cwd); + + // Split routes by signature source. `idx` preserves the original + // route order so PathItem operations are applied deterministically + // regardless of which thread produced them. + let mut parallel_jobs: Vec<(usize, &crate::metadata::RouteMetadata, &str)> = Vec::new(); + let mut ast_jobs: Vec<(usize, &crate::metadata::RouteMetadata, &syn::Signature)> = Vec::new(); + for (idx, route_meta) in metadata.routes.iter().enumerate() { + // ROUTE_STORAGE first (avoids file_cache dependency for known + // routes) — same priority order as the previous sequential code. + // + // `normalize_path_key` canonicalises the path (allocates + folds + // `.`/`..` components + display-renders + Windows case-folds), so + // compute it ONCE per route and reuse the owned key: the storage + // lookup takes it by reference, and on a storage miss the `fn_index` + // fallback MOVES the same `String` out of `storage_key.0` instead of + // recomputing it. The prior code ran the full normalization twice + // per route. + let storage_key = ( + Some(normalize_path_key(&route_meta.file_path, &cwd)), + route_meta.function_name.as_str(), + ); + let legacy_storage_key = (None, route_meta.function_name.as_str()); + if let Some(fn_sig_str) = storage_fn_sigs + .get(&storage_key) + .copied() + .flatten() + .or_else(|| storage_fn_sigs.get(&legacy_storage_key).copied().flatten()) + { + parallel_jobs.push((idx, route_meta, fn_sig_str)); + } else if let Some(norm_key) = storage_key.0 + && let Some(fns) = fn_index.get(&norm_key) + && let Some(fn_item) = fns.get(&route_meta.function_name) + { + ast_jobs.push((idx, route_meta, &fn_item.sig)); + } + } + + let build_one = |route_meta: &crate::metadata::RouteMetadata, + fn_sig: &syn::Signature| + -> syn::Result> { + let Ok(method) = HttpMethod::try_from(route_meta.method.as_str()) else { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "vespera: route '{}' has unsupported HTTP method '{}'. Supported methods are GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS.", + route_meta.path, route_meta.method + ), + )); + }; + let mut operation = build_operation_from_function( + fn_sig, + &route_meta.path, + known_schema_names, + struct_definitions, + OperationRouteConfig { + error_status: route_meta.error_status.as_deref(), + typed_responses: route_meta.typed_responses.as_deref(), + success_status: route_meta.success_status, + tags: route_meta.tags.as_deref(), + security: route_meta.security.as_deref(), + headers: Some(&route_meta.headers), + operation_id: route_meta.operation_id.as_deref(), + summary: route_meta.summary.as_deref(), + request_example: route_meta.request_example.as_ref(), + response_example: route_meta.response_example.as_ref(), + deprecated: route_meta.deprecated, + }, + ); + operation.description.clone_from(&route_meta.description); + Ok(Some((method, operation))) + }; + + // Parse + build string-backed routes on worker threads. Workers + // produce only `Send` data (`Operation` is plain `vespera_core` + // data); `syn` parsing inside a worker uses proc-macro2's fallback + // implementation, which is thread-safe. + let mut results: Vec<(usize, HttpMethod, vespera_core::route::Operation)> = + run_route_jobs_parallel(¶llel_jobs, &build_one)?; + + for (idx, route_meta, fn_sig) in ast_jobs { + if let Some((method, operation)) = build_one(route_meta, fn_sig)? { + results.push((idx, method, operation)); + } + } + + // Deterministic assembly in original route order. + results.sort_unstable_by_key(|(idx, _, _)| *idx); + assemble_path_items(results, metadata, &mut paths, &mut all_tags)?; + + Ok((paths, all_tags)) +} + +/// Apply built operations to their `PathItem`s in route order, rejecting a +/// duplicate `(method, path)` with a compile error that names BOTH conflicting +/// handlers. Previously `set_operation` silently discarded the earlier +/// operation — dropping a route from the generated spec with no diagnostic. +/// axum itself panics on a duplicate method+path at runtime, so surfacing it at +/// compile time is strictly better than the silent loss. +fn assemble_path_items( + results: Vec<(usize, HttpMethod, vespera_core::route::Operation)>, + metadata: &CollectedMetadata, + paths: &mut BTreeMap, + all_tags: &mut BTreeSet, +) -> syn::Result<()> { + let mut claimed: HashMap<(String, HttpMethod), String> = HashMap::new(); + for (idx, method, operation) in results { + let route_meta = &metadata.routes[idx]; + if let Some(tags) = &route_meta.tags { + for tag in tags { + all_tags.insert(tag.clone()); + } + } + let path_item = paths.entry(route_meta.path.clone()).or_default(); + if path_item.try_set_operation(method, operation).is_some() { + let previous = claimed + .get(&(route_meta.path.clone(), method)) + .map_or("", String::as_str); + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "duplicate route: `{method} {path}` is defined by both `{previous}` and \ + `{current}` — each (method, path) pair must map to exactly one handler", + path = route_meta.path, + current = route_meta.function_name, + ), + )); + } + claimed.insert( + (route_meta.path.clone(), method), + route_meta.function_name.clone(), + ); + } + Ok(()) +} + +fn build_storage_fn_sigs<'a>( + route_storage: &'a [StoredRouteInfo], + fn_index: &FnIndex<'_>, + cwd: &std::path::Path, +) -> StorageFnSigs<'a> { + let mut storage = HashMap::with_capacity(route_storage.len()); + for s in route_storage { + // Canonicalise the stored path ONCE per route (it allocates + folds + // path components + display-renders) and reuse it for both the + // `already_in_ast` skip check (by reference) and the storage key (by + // move) — the prior code ran the full normalization twice per route. + let norm_fp = s.file_path.as_deref().map(|fp| normalize_path_key(fp, cwd)); + let already_in_ast = norm_fp + .as_ref() + .and_then(|fp| fn_index.get(fp)) + .is_some_and(|fns| fns.contains_key(&s.fn_name)); + if already_in_ast { + continue; + } + let key = (norm_fp, s.fn_name.as_str()); + storage + .entry(key) + .and_modify(|slot| *slot = None) + .or_insert(Some(s.fn_sig_str.as_str())); + } + storage +} + +/// Run string-backed route-operation builds across worker threads. +/// +/// Sequential below [`PARALLEL_THRESHOLD`] jobs — thread spawn overhead +/// dominates tiny projects. Chunked `std::thread::scope` otherwise +/// (zero new dependencies). +pub(super) const PARALLEL_THRESHOLD: usize = 16; + +/// `(original route index, route metadata, fn signature source)` job input. +pub(super) type RouteJob<'a> = (usize, &'a crate::metadata::RouteMetadata, &'a str); + +/// `(original route index, resolved method, built operation)` result. +pub(super) type BuiltOperation = (usize, HttpMethod, vespera_core::route::Operation); + +/// Builds one operation from a route's resolved fn signature. +pub(super) type OperationBuilder<'a> = dyn Fn( + &crate::metadata::RouteMetadata, + &syn::Signature, + ) -> syn::Result> + + Sync + + 'a; + +/// RAII restore for [`proc_macro2::fallback::force`] — releases the +/// forced fallback mode even when a worker panics. +struct FallbackGuard; + +impl Drop for FallbackGuard { + fn drop(&mut self) { + proc_macro2::fallback::unforce(); + } +} + +fn run_route_jobs_parallel( + jobs: &[RouteJob<'_>], + build_one: &OperationBuilder<'_>, +) -> syn::Result> { + parallel_filter_map(jobs, &|&(idx, route_meta, fn_sig_str): &RouteJob<'_>| { + let fn_sig = syn::parse_str::(fn_sig_str).map_err(|err| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "vespera: failed to parse stored signature for route '{}': {err}", + route_meta.path + ), + ) + })?; + Ok(build_one(route_meta, &fn_sig)?.map(|(m, op)| (idx, m, op))) + }) +} + +/// `filter_map` across worker threads for compile-time job fan-out. +/// +/// Sequential below [`PARALLEL_THRESHOLD`] jobs (thread spawn overhead +/// dominates tiny projects); chunked `std::thread::scope` otherwise — +/// zero new dependencies. `f` typically parses source *strings* with +/// `syn` and must return only plain `Send` data: proc-macro2 caches +/// "the compiler bridge works" in a global once it has been used on +/// the macro thread, and worker threads would then take the +/// real-bridge path and panic ("procedural macro API is used outside +/// of a procedural macro") — so the thread-safe fallback +/// implementation is forced for the duration of the parallel section. +/// Workers only ever create fallback tokens, so no compiler/fallback +/// token mixing can occur; the guard restores normal mode even if a +/// worker panics. +pub(super) fn parallel_filter_map( + jobs: &[T], + f: &(dyn Fn(&T) -> syn::Result> + Sync), +) -> syn::Result> { + let workers = std::thread::available_parallelism() + .map_or(1, std::num::NonZero::get) + .min(jobs.len().div_ceil(PARALLEL_THRESHOLD)); + if workers <= 1 || jobs.len() < PARALLEL_THRESHOLD { + return jobs.iter().filter_map(|job| f(job).transpose()).collect(); + } + + proc_macro2::fallback::force(); + let _guard = FallbackGuard; + + let chunk_size = jobs.len().div_ceil(workers); + std::thread::scope(|scope| { + let handles: Vec<_> = jobs + .chunks(chunk_size) + .map(|chunk| { + scope.spawn(move || { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + chunk.iter().filter_map(|job| f(job).transpose()).collect() + })) + }) + }) + .collect(); + let mut results: Vec = Vec::with_capacity(jobs.len()); + for handle in handles { + let worker_result = handle + .join() + .map_err(|panic| worker_panic_error(panic.as_ref()))?; + let chunk_results: syn::Result> = + worker_result.map_err(|panic| worker_panic_error(panic.as_ref()))?; + results.extend(chunk_results?); + } + Ok(results) + }) +} + +fn worker_panic_error(panic: &(dyn std::any::Any + Send)) -> syn::Error { + let message = panic.downcast_ref::<&str>().map_or_else( + || { + panic.downcast_ref::().map_or_else( + || "parallel macro worker panicked".to_string(), + std::clone::Clone::clone, + ) + }, + |message| (*message).to_string(), + ); + syn::Error::new( + proc_macro2::Span::call_site(), + format!("vespera: parallel OpenAPI worker failed: {message}"), + ) +} + +#[cfg(test)] +mod tests; diff --git a/crates/vespera_macro/src/openapi_generator/paths/tests.rs b/crates/vespera_macro/src/openapi_generator/paths/tests.rs new file mode 100644 index 00000000..0f291478 --- /dev/null +++ b/crates/vespera_macro/src/openapi_generator/paths/tests.rs @@ -0,0 +1,632 @@ +use std::{collections::HashMap, fs, path::PathBuf}; + +use rstest::rstest; +use tempfile::TempDir; + +use crate::{ + metadata::{CollectedMetadata, RouteMetadata, StructMetadata}, + openapi_generator::generate_openapi_doc_with_metadata, + route_impl::StoredRouteInfo, +}; + +fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> PathBuf { + let file_path = dir.path().join(filename); + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path +} + +/// Build a `RouteMetadata` with the boilerplate-heavy fields defaulted. +fn route_meta(method: &str, path: &str, fn_name: &str, file_path: &str) -> RouteMetadata { + RouteMetadata { + method: method.to_string(), + path: path.to_string(), + function_name: fn_name.to_string(), + module_path: format!("test::{fn_name}"), + file_path: file_path.to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + } +} + +#[test] +fn route_in_file_cache_appears_in_paths() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> String { \"users\".to_string() }", + ); + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_meta( + "GET", + "/users", + "get_users", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + + let op = doc + .paths + .get("/users") + .and_then(|p| p.get.as_ref()) + .expect("GET op"); + assert_eq!(op.operation_id.as_deref(), Some("get_users")); +} + +#[test] +fn duplicate_method_and_path_is_a_compile_error() { + // Two distinct handlers mapping to the same (GET, /dup) must be a compile + // error that names BOTH handlers — not a silent last-wins overwrite that + // drops a route from the generated spec (axum panics on this at runtime). + let route_file_path = "/virtual/dup.rs".to_string(); + let route_src = "pub fn first() -> String { String::new() }\n\ + pub fn second() -> String { String::new() }"; + let parsed: syn::File = syn::parse_str(route_src).expect("route src parses"); + let mut file_cache: HashMap = HashMap::new(); + file_cache.insert(route_file_path.clone(), parsed); + + let mut metadata = CollectedMetadata::new(); + metadata + .routes + .push(route_meta("GET", "/dup", "first", &route_file_path)); + metadata + .routes + .push(route_meta("GET", "/dup", "second", &route_file_path)); + + let err = super::build_path_items( + &metadata, + &std::collections::HashSet::new(), + &HashMap::new(), + &file_cache, + &[], + ) + .expect_err("duplicate (GET, /dup) must be rejected"); + let msg = err.to_string(); + assert!(msg.contains("duplicate route"), "unexpected message: {msg}"); + assert!( + msg.contains("first") && msg.contains("second"), + "message should name both handlers: {msg}" + ); +} + +#[test] +fn route_storage_dedup_skips_already_in_ast() { + // When a route's `fn_sig_str` was already discovered by parsing the + // source file via `file_cache`, the storage-parse step must skip + // re-parsing it — exercises the `already_in_ast → return None` + // branch inside `route_fn_cache` construction. + let route_file_path = "/virtual/users.rs".to_string(); + let route_src = "pub fn get_users() -> String { \"users\".to_string() }"; + let parsed: syn::File = syn::parse_str(route_src).expect("route src parses"); + let mut file_cache: HashMap = HashMap::new(); + file_cache.insert(route_file_path.clone(), parsed); + + let mut metadata = CollectedMetadata::new(); + metadata + .routes + .push(route_meta("GET", "/users", "get_users", &route_file_path)); + + let route_storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + file_path: Some(route_file_path), + fn_sig_str: route_src.to_string(), + }]; + + let doc = generate_openapi_doc_with_metadata( + None, + None, + None, + None, + &metadata, + Some(file_cache), + &route_storage, + ); + + let op = doc + .paths + .get("/users") + .and_then(|p| p.get.as_ref()) + .expect("GET op"); + assert_eq!(op.operation_id.as_deref(), Some("get_users")); +} + +#[test] +fn route_storage_fast_path_when_fn_not_in_file_cache() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> String { \"users\".to_string() }\n", + ); + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_meta( + "GET", + "/users", + "get_users", + &route_file.to_string_lossy(), + )); + let route_storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: "fn get_users() -> String".to_string(), + file_path: None, + }]; + + let doc = + generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &route_storage); + + let op = doc + .paths + .get("/users") + .and_then(|p| p.get.as_ref()) + .expect("GET op"); + assert_eq!(op.operation_id.as_deref(), Some("get_users")); +} + +#[test] +fn route_storage_fast_path_disambiguates_same_fn_name_by_file_path() { + let users_path = "/virtual/users.rs".to_string(); + let posts_path = "/virtual/posts.rs".to_string(); + let mut metadata = CollectedMetadata::new(); + metadata + .routes + .push(route_meta("GET", "/users", "list", &users_path)); + metadata + .routes + .push(route_meta("GET", "/posts", "list", &posts_path)); + + let route_storage = vec![ + StoredRouteInfo { + fn_name: "list".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: "fn list() -> String".to_string(), + file_path: Some(users_path), + }, + StoredRouteInfo { + fn_name: "list".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: "fn list() -> i32".to_string(), + file_path: Some(posts_path), + }, + ]; + + let doc = + generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &route_storage); + + let users_schema = doc + .paths + .get("/users") + .and_then(|path| path.get.as_ref()) + .and_then(|op| op.responses.get("200")) + .and_then(|response| response.content.as_ref()) + .and_then(|content| content.values().next()) + .and_then(|media| media.schema.as_ref()) + .expect("users response schema"); + let posts_schema = doc + .paths + .get("/posts") + .and_then(|path| path.get.as_ref()) + .and_then(|op| op.responses.get("200")) + .and_then(|response| response.content.as_ref()) + .and_then(|content| content.values().next()) + .and_then(|media| media.schema.as_ref()) + .expect("posts response schema"); + + let schema_type = |schema: &vespera_core::schema::SchemaRef| match schema { + vespera_core::schema::SchemaRef::Inline(schema) => schema.schema_type, + vespera_core::schema::SchemaRef::Ref(reference) => { + panic!("expected inline schema, got {}", reference.ref_path) + } + }; + assert_eq!( + schema_type(users_schema), + Some(vespera_core::schema::SchemaType::String) + ); + assert_eq!( + schema_type(posts_schema), + Some(vespera_core::schema::SchemaType::Integer) + ); +} + +#[test] +fn route_storage_legacy_none_file_path_is_skipped_when_ambiguous() { + let users_path = "/virtual/users.rs".to_string(); + let posts_path = "/virtual/posts.rs".to_string(); + let mut metadata = CollectedMetadata::new(); + metadata + .routes + .push(route_meta("GET", "/users", "list", &users_path)); + metadata + .routes + .push(route_meta("GET", "/posts", "list", &posts_path)); + + let mut file_cache = HashMap::new(); + file_cache.insert( + users_path.clone(), + syn::parse_str("pub fn list() -> String { String::new() }").unwrap(), + ); + file_cache.insert( + posts_path.clone(), + syn::parse_str("pub fn list() -> i32 { 1 }").unwrap(), + ); + + let route_storage = vec![ + StoredRouteInfo { + fn_name: "list".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: "fn list() -> bool".to_string(), + file_path: None, + }, + StoredRouteInfo { + fn_name: "list".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: "fn list() -> bool".to_string(), + file_path: None, + }, + ]; + + let doc = generate_openapi_doc_with_metadata( + None, + None, + None, + None, + &metadata, + Some(file_cache), + &route_storage, + ); + + let response_schema_type = |path: &str| { + let schema = doc + .paths + .get(path) + .and_then(|path| path.get.as_ref()) + .and_then(|op| op.responses.get("200")) + .and_then(|response| response.content.as_ref()) + .and_then(|content| content.values().next()) + .and_then(|media| media.schema.as_ref()) + .expect("response schema"); + match schema { + vespera_core::schema::SchemaRef::Inline(schema) => schema.schema_type, + vespera_core::schema::SchemaRef::Ref(reference) => { + panic!("expected inline schema, got {}", reference.ref_path) + } + } + }; + + assert_eq!( + response_schema_type("/users"), + Some(vespera_core::schema::SchemaType::String) + ); + assert_eq!( + response_schema_type("/posts"), + Some(vespera_core::schema::SchemaType::Integer) + ); +} + +#[test] +fn route_with_function_not_in_ast_is_skipped() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_items() -> String { \"items\".to_string() }\n", + ); + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_meta( + "GET", + "/users", + "get_users", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + + assert!( + doc.paths.is_empty(), + "Route with non-matching function should be skipped" + ); +} + +#[test] +fn route_and_struct_appear_together() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "user_route.rs", + r#" +use crate::user::User; + +pub fn get_user() -> User { +User { id: 1, name: "Alice".to_string() } +} +"#, + ); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "User".to_string(), + definition: "struct User { id: i32, name: String }".to_string(), + ..Default::default() + }); + metadata.routes.push(route_meta( + "GET", + "/user", + "get_user", + &route_file.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata( + Some("Test API".to_string()), + Some("1.0.0".to_string()), + None, + None, + &metadata, + None, + &[], + ); + + let schemas = doc + .components + .as_ref() + .and_then(|c| c.schemas.as_ref()) + .expect("schemas present"); + assert!(schemas.contains_key("User")); + assert!( + doc.paths + .get("/user") + .and_then(|p| p.get.as_ref()) + .is_some() + ); +} + +#[test] +fn multiple_methods_share_path_item() { + let temp_dir = TempDir::new().unwrap(); + let r1 = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> String { \"users\".to_string() }", + ); + let r2 = create_temp_file( + &temp_dir, + "create_user.rs", + "pub fn create_user() -> String { \"created\".to_string() }", + ); + + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_meta( + "GET", + "/users", + "get_users", + &r1.to_string_lossy(), + )); + metadata.routes.push(route_meta( + "POST", + "/users", + "create_user", + &r2.to_string_lossy(), + )); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + + assert_eq!(doc.paths.len(), 1); + let path_item = doc.paths.get("/users").unwrap(); + assert!(path_item.get.is_some()); + assert!(path_item.post.is_some()); +} + +#[test] +fn tags_and_description_propagate_to_operation() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> String { \"users\".to_string() }", + ); + + let mut metadata = CollectedMetadata::new(); + let mut rm = route_meta("GET", "/users", "get_users", &route_file.to_string_lossy()); + rm.error_status = Some(vec![404]); + rm.tags = Some(vec!["users".to_string(), "admin".to_string()]); + rm.description = Some("Get all users".to_string()); + metadata.routes.push(rm); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + + let op = doc + .paths + .get("/users") + .and_then(|p| p.get.as_ref()) + .unwrap(); + assert_eq!(op.description.as_deref(), Some("Get all users")); + let tags = doc.tags.as_ref().expect("tags present"); + assert!(tags.iter().any(|t| t.name == "users")); + assert!(tags.iter().any(|t| t.name == "admin")); +} + +/// File-read / parse failures must not produce phantom routes or schemas. +#[rstest] +#[case::route_file_read_failure("/nonexistent/route.rs", None)] +#[case::route_file_parse_failure("", Some("invalid rust syntax {"))] +fn file_errors_skip_route(#[case] file_path_template: &str, #[case] write_invalid: Option<&str>) { + let temp_dir = TempDir::new().unwrap(); + let final_file_path = write_invalid.map_or_else( + || file_path_template.to_string(), + |content| { + create_temp_file(&temp_dir, "invalid_route.rs", content) + .to_string_lossy() + .to_string() + }, + ); + + let mut metadata = CollectedMetadata::new(); + metadata + .routes + .push(route_meta("GET", "/users", "get_users", &final_file_path)); + + let doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + + assert!(!doc.paths.contains_key("/users")); + // schemas must also be empty — no struct was registered. + if let Some(schemas) = doc.components.as_ref().and_then(|c| c.schemas.as_ref()) { + assert!(!schemas.contains_key("User")); + } +} + +#[test] +fn unknown_http_method_route_is_compile_error() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + "pub fn get_users() -> String { \"users\".to_string() }", + ); + + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route_meta( + "INVALID", + "/users", + "get_users", + &route_file.to_string_lossy(), + )); + + let err = crate::openapi_generator::try_generate_openapi_doc_with_metadata( + None, + None, + None, + None, + &metadata, + None, + &[], + ) + .expect_err("unknown method should fail OpenAPI generation"); + + assert!(err.to_string().contains("unsupported HTTP method")); +} + +#[test] +fn unknown_method_fails_even_when_valid_route_exists() { + let temp_dir = TempDir::new().unwrap(); + let route_file = create_temp_file( + &temp_dir, + "users.rs", + r#" +pub fn get_users() -> String +{ "users".to_string() } + +pub fn create_users() -> String { "created".to_string() } +"#, + ); + let file_path = route_file.to_string_lossy().to_string(); + + let mut metadata = CollectedMetadata::new(); + metadata + .routes + .push(route_meta("CONNECT", "/users", "get_users", &file_path)); + metadata + .routes + .push(route_meta("POST", "/users", "create_users", &file_path)); + + let err = crate::openapi_generator::try_generate_openapi_doc_with_metadata( + None, + None, + None, + None, + &metadata, + None, + &[], + ) + .expect_err("unknown method should fail OpenAPI generation"); + + assert!(err.to_string().contains("unsupported HTTP method")); +} diff --git a/crates/vespera_macro/src/parser/extractor_validation.rs b/crates/vespera_macro/src/parser/extractor_validation.rs new file mode 100644 index 00000000..faca6219 --- /dev/null +++ b/crates/vespera_macro/src/parser/extractor_validation.rs @@ -0,0 +1,643 @@ +//! B2: compile-time validation that request/query extractors reference +//! `Schema`-backed types. +//! +//! `Query`, `Json`, `Form`, and `TypedMultipart` only appear in the +//! generated OpenAPI when `T` is known to Vespera (i.e. it derives `Schema`). +//! When `T` is a struct declared in the same route file that does **not** derive +//! `Schema`, Vespera silently drops it — `Query` yields no parameters and +//! `Json` falls back to a generic object — so the spec lies about the route. +//! +//! This pass turns that silent footgun into a hard compile error, scoped to the +//! one case the macro can prove: a struct **declared in the handler's own file** +//! that is absent from `known_schema_names`. Primitives, containers, maps, +//! external/imported types, and `Schema`-deriving structs are never flagged — +//! the macro cannot prove `Schema` for types it cannot name-resolve, and a false +//! positive there would be worse than the residual (cross-file) false negative. + +use std::collections::{HashMap, HashSet}; + +use proc_macro2::Span; +use syn::Type; + +use super::extractors::unwrap_validated_type; +use crate::metadata::CollectedMetadata; + +/// Request/query extractors whose generic argument must be a documented type. +const REQUEST_EXTRACTORS: [&str; 4] = ["Query", "Json", "Form", "TypedMultipart"]; + +/// Validate every route handler's request/query extractors against the set of +/// `Schema`-backed type names. Returns a `compile_error!`-ready `syn::Error` on +/// the first same-file non-`Schema` struct used in such an extractor. +/// +/// Only call sites with a parsed file AST (cache-miss / `export_app!`) run this; +/// a cache hit means the source is byte-identical to a build that already +/// passed, so re-validation is unnecessary. +/// Validate schema-backed extractors using an invocation-local AST cache +/// already produced by route collection. +pub fn validate_schema_backed_extractors_with_cache( + metadata: &CollectedMetadata, + file_cache: &HashMap, +) -> syn::Result<()> { + check_extractors(metadata, file_cache) +} + +fn check_extractors( + metadata: &CollectedMetadata, + file_cache: &HashMap, +) -> syn::Result<()> { + let known: HashSet<&str> = metadata.structs.iter().map(|s| s.name.as_str()).collect(); + // Map each route file's module path → its file path so an absolute + // `crate::::Type` import can be resolved back to the route file + // and checked: a path that resolves *inside* the route folder names a route + // type, while `crate::models::…` (outside the folder) is not in this map and + // stays skipped. + let route_module_files: HashMap<&str, &str> = metadata + .routes + .iter() + .map(|r| (r.module_path.as_str(), r.file_path.as_str())) + .collect(); + + // Per-file analysis cache: the local type set and the imported non-`Schema` + // route-type set depend only on the file (every route in a file shares one + // module path), so compute them ONCE per file and reuse them for every + // route in that file. The previous code recomputed both per route — scanning + // the file's items and re-resolving its imports `routes_in_file` times + // (O(routes_in_file x items_in_file) on every cache-miss build). Routes are + // still visited in declaration order, so the first reported violation is + // deterministic. + let mut file_analysis: HashMap<&str, (HashSet, HashSet)> = HashMap::new(); + + for route in &metadata.routes { + let Some(ast) = file_cache.get(&route.file_path) else { + continue; + }; + + let (local_types, imported_route_types) = &*file_analysis + .entry(route.file_path.as_str()) + .or_insert_with(|| { + // Types physically declared in this route file (structs + enums). + let local_types: HashSet = ast + .items + .iter() + .filter_map(|item| match item { + syn::Item::Struct(s) => Some(s.ident.to_string()), + syn::Item::Enum(e) => Some(e.ident.to_string()), + _ => None, + }) + .collect(); + // Non-`Schema` types imported from another route file via a + // `crate`/`self`/`super` path (resolved against this file's module). + let mut imported_route_types = HashSet::new(); + collect_imported_route_types( + ast, + &route.module_path, + &route_module_files, + file_cache, + &known, + &mut imported_route_types, + ); + (local_types, imported_route_types) + }); + + let Some(fn_item) = ast.items.iter().find_map(|item| match item { + syn::Item::Fn(f) if f.sig.ident == route.function_name => Some(f), + _ => None, + }) else { + continue; + }; + + for input in &fn_item.sig.inputs { + let syn::FnArg::Typed(syn::PatType { ty, .. }) = input else { + continue; + }; + let unwrapped = unwrap_validated_type(ty.as_ref()); + let Some((extractor, inner)) = request_extractor_inner(unwrapped) else { + continue; + }; + + let mut idents = Vec::new(); + collect_custom_type_idents(inner, &mut idents); + for ident in idents { + let local_without_schema = + local_types.contains(&ident) && !known.contains(ident.as_str()); + if local_without_schema || imported_route_types.contains(&ident) { + return Err(syn::Error::new( + Span::call_site(), + format!( + "vespera! macro: route `{fn_name}` uses `{extractor}<{ident}>`, but \ + `{ident}` does not derive `Schema`. Vespera cannot document a \ + non-`Schema` type and would silently drop it from the OpenAPI spec. \ + Add `#[derive(vespera::Schema)]` to `{ident}`.", + fn_name = route.function_name, + ), + )); + } + } + } + } + + Ok(()) +} + +/// If `ty` is one of the request/query extractors, return its name and the +/// first generic type argument. +fn request_extractor_inner(ty: &Type) -> Option<(&'static str, &Type)> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + let extractor = REQUEST_EXTRACTORS + .into_iter() + .find(|name| segment.ident == name)?; + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let syn::GenericArgument::Type(inner) = args.args.first()? else { + return None; + }; + Some((extractor, inner)) +} + +/// Collect the last path-segment identifier of `ty` and recurse through generic +/// arguments and references. Container idents (`Vec`, `Option`, ...) and +/// primitives are harmlessly collected too — they are filtered out later by the +/// `local_types` / imported-route-type membership test, so no explicit +/// allow/deny list is needed. +fn collect_custom_type_idents(ty: &Type, out: &mut Vec) { + match ty { + Type::Path(type_path) => { + if let Some(segment) = type_path.path.segments.last() { + out.push(segment.ident.to_string()); + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + for arg in &args.args { + if let syn::GenericArgument::Type(inner) = arg { + collect_custom_type_idents(inner, out); + } + } + } + } + } + Type::Reference(reference) => collect_custom_type_idents(&reference.elem, out), + _ => {} + } +} + +/// Collect the in-scope idents of every `use` import that resolves, inside the +/// route folder, to a route file declaring a non-`Schema` struct/enum — the +/// cross-file footgun. `crate`, `self`, and `super` (any depth) prefixes are +/// resolved against `current_module` (the importing file's own module path); +/// imports that climb above the crate root, land outside the route folder (not +/// in `route_module_files`), or whose *declared* type derives `Schema` are left +/// untouched — so aliasing (`as`) never produces a false positive. +/// +/// Residual: a type declared in a route-folder file that has no `#[route]` +/// handler is absent from `route_module_files`, so such an import is not flagged +/// (a safe false negative, never a false positive). +fn collect_imported_route_types( + ast: &syn::File, + current_module: &str, + route_module_files: &HashMap<&str, &str>, + file_cache: &HashMap, + known: &HashSet<&str>, + out: &mut HashSet, +) { + let current: Vec<&str> = current_module.split("::").collect(); + for item in &ast.items { + if let syn::Item::Use(item_use) = item + && let Some((mut base, rest)) = resolve_use_prefix(&item_use.tree, ¤t) + { + walk_module_path(rest, &mut base, route_module_files, file_cache, known, out); + } + } +} + +/// Resolve a use-tree's leading `crate`/`self`/`super…` prefix into the base +/// module-path segments and the remaining subtree. Returns `None` for external +/// crates, bare items, or `super` chains that climb above the crate root. +fn resolve_use_prefix<'a>( + tree: &'a syn::UseTree, + current: &[&str], +) -> Option<(Vec, &'a syn::UseTree)> { + let syn::UseTree::Path(first) = tree else { + return None; + }; + match first.ident.to_string().as_str() { + "crate" => Some((Vec::new(), first.tree.as_ref())), + "self" => Some(( + current.iter().map(|s| (*s).to_string()).collect(), + first.tree.as_ref(), + )), + "super" => { + let mut supers = 1usize; + let mut node: &syn::UseTree = first.tree.as_ref(); + while let syn::UseTree::Path(next) = node { + if next.ident == "super" { + supers += 1; + node = next.tree.as_ref(); + } else { + break; + } + } + let kept = current.len().checked_sub(supers)?; + Some(( + current[..kept].iter().map(|s| (*s).to_string()).collect(), + node, + )) + } + _ => None, + } +} + +/// Walk the post-prefix subtree, accumulating module segments, and record every +/// leaf import naming a non-`Schema` type declared in a resolved route file. +fn walk_module_path( + tree: &syn::UseTree, + module_segments: &mut Vec, + route_module_files: &HashMap<&str, &str>, + file_cache: &HashMap, + known: &HashSet<&str>, + out: &mut HashSet, +) { + match tree { + syn::UseTree::Path(path) => { + module_segments.push(path.ident.to_string()); + walk_module_path( + &path.tree, + module_segments, + route_module_files, + file_cache, + known, + out, + ); + module_segments.pop(); + } + syn::UseTree::Name(name) => { + record_route_type( + module_segments, + &name.ident, + &name.ident, + route_module_files, + file_cache, + known, + out, + ); + } + syn::UseTree::Rename(rename) => { + // The alias (`rename`) is the in-scope name used in handler + // signatures; the original (`ident`) is what the source module + // declares and what determines `Schema` status. + record_route_type( + module_segments, + &rename.ident, + &rename.rename, + route_module_files, + file_cache, + known, + out, + ); + } + syn::UseTree::Group(group) => { + for item in &group.items { + walk_module_path( + item, + module_segments, + route_module_files, + file_cache, + known, + out, + ); + } + } + syn::UseTree::Glob(_) => {} + } +} + +/// Record `bound` (the in-scope name) when `module_segments` resolves to a route +/// file that declares a struct/enum named `declared` which does not derive +/// `Schema`. The `Schema` check uses the *declared* name, so aliasing a +/// `Schema`-deriving type (`use … as X`) never produces a false positive. +fn record_route_type( + module_segments: &[String], + declared: &syn::Ident, + bound: &syn::Ident, + route_module_files: &HashMap<&str, &str>, + file_cache: &HashMap, + known: &HashSet<&str>, + out: &mut HashSet, +) { + if known.contains(declared.to_string().as_str()) { + return; + } + let module = module_segments.join("::"); + if let Some(&file_path) = route_module_files.get(module.as_str()) + && let Some(file_ast) = file_cache.get(file_path) + && file_declares_type(file_ast, declared) + { + out.insert(bound.to_string()); + } +} + +/// Whether `ast` declares a struct or enum named `ident`. +fn file_declares_type(ast: &syn::File, ident: &syn::Ident) -> bool { + ast.items.iter().any(|item| match item { + syn::Item::Struct(s) => s.ident == *ident, + syn::Item::Enum(e) => e.ident == *ident, + _ => false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metadata::{CollectedMetadata, RouteMetadata, StructMetadata}; + + fn route(function_name: &str, file_path: &str) -> RouteMetadata { + RouteMetadata { + method: "get".to_string(), + path: "/x".to_string(), + function_name: function_name.to_string(), + module_path: "routes::x".to_string(), + file_path: file_path.to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + } + } + + fn run(src: &str, fn_name: &str, structs: &[&str]) -> syn::Result<()> { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(route(fn_name, "f.rs")); + for name in structs { + metadata + .structs + .push(StructMetadata::new((*name).to_string(), String::new())); + } + let ast: syn::File = syn::parse_str(src).expect("source parses"); + let mut file_cache = HashMap::new(); + file_cache.insert("f.rs".to_string(), ast); + check_extractors(&metadata, &file_cache) + } + + #[test] + fn local_struct_without_schema_in_query_errors() { + let src = r" + pub struct Local { pub a: i32 } + pub fn handler(Query(q): Query) -> String { String::new() } + "; + let err = run(src, "handler", &[]).expect_err("should error"); + let msg = err.to_string(); + assert!(msg.contains("Local"), "got: {msg}"); + assert!(msg.contains("Query"), "got: {msg}"); + assert!(msg.contains("does not derive `Schema`"), "got: {msg}"); + } + + #[test] + fn local_struct_with_schema_is_ok() { + // `Local` present in metadata.structs ⇒ it derived Schema. + let src = r" + pub struct Local { pub a: i32 } + pub fn handler(Query(q): Query) -> String { String::new() } + "; + assert!(run(src, "handler", &["Local"]).is_ok()); + } + + #[test] + fn external_non_local_type_is_not_flagged() { + // `External` is not declared as a struct in this file ⇒ skipped. + let src = r" + pub fn handler(Query(q): Query) -> String { String::new() } + "; + assert!(run(src, "handler", &[]).is_ok()); + } + + #[test] + fn validated_json_unwraps_and_flags_inner_local_struct() { + let src = r" + pub struct Local { pub a: i32 } + pub fn handler(Validated(Json(b)): Validated>) -> String { String::new() } + "; + let err = run(src, "handler", &[]).expect_err("should error"); + assert!(err.to_string().contains("Json"), "{err}"); + } + + #[test] + fn nested_container_inner_local_struct_is_flagged() { + let src = r" + pub struct Local { pub a: i32 } + pub fn handler(Json(b): Json>) -> String { String::new() } + "; + assert!(run(src, "handler", &[]).is_err()); + } + + #[test] + fn primitive_query_is_ok() { + let src = r" + pub fn handler(Query(q): Query) -> String { String::new() } + "; + assert!(run(src, "handler", &[]).is_ok()); + } + + #[test] + fn same_file_enum_without_schema_is_flagged() { + // Same-file enums are documentable too — a non-`Schema` enum in a body + // extractor is the same footgun as a struct. + let src = r" + pub enum Kind { A, B } + pub fn handler(Json(b): Json) -> String { String::new() } + "; + let err = run(src, "handler", &[]).expect_err("should error"); + assert!(err.to_string().contains("Kind"), "{err}"); + } + + #[test] + fn relative_super_import_non_schema_type_is_flagged() { + // `use super::other::Bar` from `routes::handler` resolves to sibling route + // file `other` (`super` → `routes`); lacking Schema it must be flagged. + let err = run_with_route_sibling( + "use super::other::Bar; pub fn handler(Json(b): Json) -> String { String::new() }", + "routes::other", + "pub struct Bar { pub a: i32 }", + &[], + ) + .expect_err("should flag relative route import"); + assert!(err.to_string().contains("Bar"), "{err}"); + } + + #[test] + fn relative_super_import_schema_type_is_ok() { + // Same relative import, but `Bar` derives Schema (∈ known) ⇒ not flagged. + assert!( + run_with_route_sibling( + "use super::other::Bar; pub fn handler(Json(b): Json) -> String { String::new() }", + "routes::other", + "pub struct Bar { pub a: i32 }", + &["Bar"], + ) + .is_ok() + ); + } + + #[test] + fn absolute_crate_import_outside_routes_is_not_flagged() { + // `crate::models::…` resolves outside the route folder, so it is not in + // the route module map → conservatively skipped (no false positive). + let src = r" + use crate::models::Bar; + pub fn handler(Json(b): Json) -> String { String::new() } + "; + assert!(run(src, "handler", &[]).is_ok()); + } + + /// Two-file metadata: a `handler` route plus a sibling route module `other`. + fn run_with_route_sibling( + handler_src: &str, + sibling_module: &str, + sibling_src: &str, + known: &[&str], + ) -> syn::Result<()> { + let mut metadata = CollectedMetadata::new(); + let mut handler = route("handler", "handler.rs"); + handler.module_path = "routes::handler".to_string(); + metadata.routes.push(handler); + let sibling_file = format!("{}.rs", sibling_module.rsplit("::").next().unwrap()); + let mut sibling = route("sibling", &sibling_file); + sibling.module_path = sibling_module.to_string(); + metadata.routes.push(sibling); + for name in known { + metadata + .structs + .push(StructMetadata::new((*name).to_string(), String::new())); + } + let mut file_cache = HashMap::new(); + file_cache.insert( + "handler.rs".to_string(), + syn::parse_str(handler_src).expect("handler parses"), + ); + file_cache.insert( + sibling_file, + syn::parse_str(sibling_src).expect("sibling parses"), + ); + check_extractors(&metadata, &file_cache) + } + + #[test] + fn absolute_crate_import_into_routes_is_flagged() { + // `use crate::routes::other::Bar` resolves to the route file `other`, + // which declares a non-Schema `Bar` → flagged despite the absolute path. + let err = run_with_route_sibling( + "use crate::routes::other::Bar; pub fn handler(Json(b): Json) -> String { String::new() }", + "routes::other", + "pub struct Bar { pub a: i32 }", + &[], + ) + .expect_err("should flag absolute route import"); + assert!(err.to_string().contains("Bar"), "{err}"); + } + + #[test] + fn absolute_crate_import_into_routes_with_schema_is_ok() { + // Same absolute import, but `Bar` derives Schema (∈ known) → not flagged. + assert!( + run_with_route_sibling( + "use crate::routes::other::Bar; pub fn handler(Json(b): Json) -> String { String::new() }", + "routes::other", + "pub struct Bar { pub a: i32 }", + &["Bar"], + ) + .is_ok() + ); + } + + #[test] + fn absolute_crate_import_to_non_type_is_not_flagged() { + // The sibling route module exists but declares no `Bar` type (only a + // re-export / fn) → `file_declares_type` is false → not flagged. + assert!( + run_with_route_sibling( + "use crate::routes::other::Bar; pub fn handler(Json(b): Json) -> String { String::new() }", + "routes::other", + "pub fn helper() {}", + &[], + ) + .is_ok() + ); + } + + #[test] + fn aliased_schema_type_import_is_not_flagged() { + // Aliasing a Schema-deriving type (`use … as X`) must NOT be flagged: the + // Schema check uses the declared name, not the alias. (Regression for the + // alias false positive.) + assert!( + run_with_route_sibling( + "use crate::routes::other::Bar as B; pub fn handler(Query(q): Query) -> String { String::new() }", + "routes::other", + "pub struct Bar { pub a: i32 }", + &["Bar"], + ) + .is_ok() + ); + } + + #[test] + fn aliased_non_schema_type_import_is_flagged() { + // Aliasing a non-Schema route type is still flagged, under the alias name. + let err = run_with_route_sibling( + "use crate::routes::other::Bar as B; pub fn handler(Query(q): Query) -> String { String::new() }", + "routes::other", + "pub struct Bar { pub a: i32 }", + &[], + ) + .expect_err("should flag aliased non-Schema import"); + assert!(err.to_string().contains('B'), "{err}"); + } + + #[test] + fn multi_super_into_routes_is_flagged() { + // From a nested module, `super::super` rises to `routes`, so + // `super::super::other::Bar` resolves to the route file `other`. + let mut metadata = CollectedMetadata::new(); + let mut handler = route("handler", "stats.rs"); + handler.module_path = "routes::admin::stats".to_string(); + metadata.routes.push(handler); + let mut other = route("other_handler", "other.rs"); + other.module_path = "routes::other".to_string(); + metadata.routes.push(other); + + let mut file_cache = HashMap::new(); + file_cache.insert( + "stats.rs".to_string(), + syn::parse_str( + "use super::super::other::Bar; pub fn handler(Json(b): Json) -> String { String::new() }", + ) + .unwrap(), + ); + file_cache.insert( + "other.rs".to_string(), + syn::parse_str("pub struct Bar { pub a: i32 }").unwrap(), + ); + + assert!(check_extractors(&metadata, &file_cache).is_err()); + } + + #[test] + fn multi_super_escaping_routes_is_not_flagged() { + // `super::super` from a top-level route file rises to the crate root, so + // `super::super::models::Bar` resolves to `models` — outside the route + // folder → not flagged (no false positive). + let src = r" + use super::super::models::Bar; + pub fn handler(Json(b): Json) -> String { String::new() } + "; + assert!(run(src, "handler", &[]).is_ok()); + } +} diff --git a/crates/vespera_macro/src/parser/extractors.rs b/crates/vespera_macro/src/parser/extractors.rs new file mode 100644 index 00000000..9db4ca14 --- /dev/null +++ b/crates/vespera_macro/src/parser/extractors.rs @@ -0,0 +1,41 @@ +use syn::{GenericArgument, PathArguments, Type}; + +/// If `ty` is `Validated`, return `Inner`; otherwise return `ty`. +pub(super) fn unwrap_validated_type(ty: &Type) -> &Type { + extractor_inner_type(ty, "Validated").unwrap_or(ty) +} + +/// Return true when the type is a `Validated<...>` extractor wrapper. +pub(super) fn is_validated_type(ty: &Type) -> bool { + extractor_inner_type(ty, "Validated").is_some() +} + +/// Extract the first generic type argument from an extractor by final path segment. +pub(super) fn extractor_inner_type<'a>(ty: &'a Type, extractor: &str) -> Option<&'a Type> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if segment.ident != extractor { + return None; + } + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(GenericArgument::Type(inner_ty)) = args.args.first() else { + return None; + }; + Some(inner_ty) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unwraps_validated_inner_extractor() { + let ty: Type = syn::parse_str("vespera::Validated>").unwrap(); + let inner = unwrap_validated_type(&ty); + assert_eq!(quote::quote!(#inner).to_string(), "axum :: Json < User >"); + } +} diff --git a/crates/vespera_macro/src/parser/mod.rs b/crates/vespera_macro/src/parser/mod.rs index ae11fce1..afc476cf 100644 --- a/crates/vespera_macro/src/parser/mod.rs +++ b/crates/vespera_macro/src/parser/mod.rs @@ -1,3 +1,5 @@ +mod extractor_validation; +mod extractors; mod is_keyword_type; mod operation; mod parameters; @@ -5,9 +7,9 @@ mod path; mod request_body; mod response; pub mod schema; -pub use operation::build_operation_from_function; +pub use extractor_validation::validate_schema_backed_extractors_with_cache; +pub use operation::{OperationRouteConfig, build_operation_from_function}; pub use schema::{ - extract_default, extract_field_rename, extract_rename_all, extract_skip, - extract_skip_serializing_if, parse_enum_to_schema, parse_struct_to_schema, - parse_type_to_schema_ref, rename_field, strip_raw_prefix_owned, + extract_default, extract_field_rename, extract_rename_all, extract_skip, parse_enum_to_schema, + parse_struct_to_schema, rename_field, strip_raw_prefix_owned, }; diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index bfedbd24..57683206 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -3,34 +3,57 @@ use std::collections::{BTreeMap, HashSet}; use syn::{FnArg, PatType, Type}; use vespera_core::route::{MediaType, Operation, Parameter, ParameterLocation, Response}; +use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType}; + +use crate::metadata::HeaderParam; use super::{ - parameters::parse_function_parameter, path::extract_path_parameters, - request_body::parse_request_body, response::parse_return_type, + extractors::{is_validated_type, unwrap_validated_type}, + parameters::parse_function_parameter, + path::extract_path_parameters, + request_body::parse_request_body, + response::parse_return_type, schema::parse_type_to_schema_ref_with_schemas, }; +#[derive(Clone, Copy, Default)] +pub struct OperationRouteConfig<'a> { + pub error_status: Option<&'a [u16]>, + pub typed_responses: Option<&'a [(u16, String)]>, + /// Declared non-200 success status from `status = ` (validated 2xx). + pub success_status: Option, + pub tags: Option<&'a [String]>, + pub security: Option<&'a [String]>, + pub headers: Option<&'a [HeaderParam]>, + pub operation_id: Option<&'a str>, + pub summary: Option<&'a str>, + pub request_example: Option<&'a serde_json::Value>, + pub response_example: Option<&'a serde_json::Value>, + pub deprecated: bool, +} + /// Build Operation from function signature #[allow(clippy::too_many_lines)] pub fn build_operation_from_function( sig: &syn::Signature, path: &str, - known_schemas: &HashSet, - struct_definitions: &std::collections::HashMap, - error_status: Option<&[u16]>, - tags: Option<&[String]>, + known_schemas: &HashSet<&str>, + struct_definitions: &std::collections::HashMap<&str, &str>, + config: OperationRouteConfig<'_>, ) -> Operation { let path_params = extract_path_parameters(path); let mut parameters = Vec::new(); let mut request_body = None; let mut path_extractor_type: Option = None; + let mut has_validated_extractor = false; let string_type: OnceCell = OnceCell::new(); // First pass: find Path extractor and extract its type for input in &sig.inputs { if let FnArg::Typed(PatType { ty, .. }) = input - && let Type::Path(type_path) = ty.as_ref() + && let Type::Path(type_path) = unwrap_validated_type(ty.as_ref()) { + has_validated_extractor |= is_validated_type(ty.as_ref()); let path_segments = &type_path.path; if !path_segments.segments.is_empty() { let segment = path_segments.segments.last().unwrap(); @@ -136,7 +159,7 @@ pub fn build_operation_from_function( } // Build HashSet once for O(1) path-param membership tests in parse_function_parameter - let path_param_set: HashSet = path_params.iter().cloned().collect(); + let path_param_set: HashSet<&str> = path_params.iter().map(String::as_str).collect(); // Parse function parameters (skip Path extractor as we already handled it) for input in &sig.inputs { @@ -146,7 +169,7 @@ pub fn build_operation_from_function( } else { // Skip Path extractor - we already handled path parameters above let is_path_extractor = if let FnArg::Typed(PatType { ty, .. }) = input - && let Type::Path(type_path) = ty.as_ref() + && let Type::Path(type_path) = unwrap_validated_type(ty.as_ref()) && !&type_path.path.segments.is_empty() { let segment = &type_path.path.segments.last().unwrap(); @@ -169,40 +192,50 @@ pub fn build_operation_from_function( } } + if let Some(headers) = config.headers { + parameters.extend(headers.iter().map(header_parameter)); + } + deduplicate_header_parameters(&mut parameters); + // Parse return type - may return multiple responses (for Result types) let mut responses = parse_return_type(&sig.output, known_schemas, struct_definitions); + if let Some(example) = config.request_example + && let Some(body) = request_body.as_mut() + { + for media in body.content.values_mut() { + media.example = Some(example.clone()); + } + } + // Add additional error status codes from error_status attribute - if let Some(status_codes) = error_status { - // Find the error response schema (usually 400 or the first error response) - let error_schema = responses + if let Some(status_codes) = config.error_status { + // Clone the existing error response's media (its content-type AND schema) + // for each extra status code — the content-type may be `text/plain` when + // the error body is a bare `String`, not always `application/json`. + let error_media = responses .iter() - .find(|(code, _)| code != &&"200".to_string()) - .and_then(|(_, resp)| { - resp.content - .as_ref()? - .get("application/json")? - .schema - .clone() - }); - - if let Some(schema) = error_schema { + .find(|(code, _)| code.as_str() != "200") + .and_then(|(_, resp)| resp.content.as_ref()?.iter().next()) + .map(|(content_type, media)| (content_type.clone(), media.schema.clone())); + + if let Some((content_type, schema)) = error_media { for &status_code in status_codes { let status_str = status_code.to_string(); // Only add if not already present responses.entry(status_str).or_insert_with(|| { let mut err_content = BTreeMap::new(); err_content.insert( - "application/json".to_string(), + content_type.clone(), MediaType { - schema: Some(schema.clone()), + schema: schema.clone(), example: None, examples: None, }, ); Response { - description: "Error response".to_string(), + description: error_response_description(), headers: None, content: Some(err_content), } @@ -211,467 +244,204 @@ pub fn build_operation_from_function( } } - Operation { - operation_id: Some(sig.ident.to_string()), - tags: tags.map(<[std::string::String]>::to_vec), - summary: None, - description: None, - parameters: if parameters.is_empty() { - None - } else { - Some(parameters) - }, - request_body, - responses, - security: None, - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use rstest::rstest; - use vespera_core::schema::{SchemaRef, SchemaType}; - - use super::*; - - fn param_schema_type(param: &Parameter) -> Option { - match param.schema.as_ref()? { - SchemaRef::Inline(schema) => schema.schema_type, - SchemaRef::Ref(_) => None, + // Add typed error responses from `responses = [(404, NotFoundError)]`. + // These intentionally overwrite `error_status` entries for the same code. + if let Some(typed_responses) = config.typed_responses { + for (status_code, schema_name) in typed_responses { + responses.insert( + status_code.to_string(), + typed_response(schema_name, response_description_for_status(*status_code)), + ); } } - fn build(sig_src: &str, path: &str, error_status: Option<&[u16]>) -> Operation { - let sig: syn::Signature = syn::parse_str(sig_src).expect("signature parse failed"); - build_operation_from_function( - &sig, - path, - &HashSet::new(), - &HashMap::new(), - error_status, - None, - ) - } - - #[derive(Clone, Debug)] - struct ExpectedParam { - name: &'static str, - schema: Option, - } - - #[derive(Clone, Debug)] - struct ExpectedBody { - content_type: &'static str, - schema: Option, - } - - #[derive(Clone, Debug)] - struct ExpectedResp { - status: &'static str, - schema: Option, - } - - fn assert_body(op: &Operation, expected: Option<&ExpectedBody>) { - match expected { - None => assert!(op.request_body.is_none()), - Some(exp) => { - let body = op.request_body.as_ref().expect("request body expected"); - let media = body - .content - .get(exp.content_type) - .or_else(|| { - // allow fallback to the only available content type if expected is absent - if body.content.len() == 1 { - body.content.values().next() - } else { - None - } - }) - .expect("expected content type"); - if let Some(schema_ty) = &exp.schema { - match media.schema.as_ref().expect("schema expected") { - SchemaRef::Inline(schema) => { - assert_eq!(schema.schema_type, Some(*schema_ty)); - } - SchemaRef::Ref(_) => panic!("expected inline schema"), - } - } - } + // Feature 1: explicit error declarations are authoritative. When a route + // declares any explicit error response (via `responses` and/or + // `error_status`), drop the auto-default `400` that `parse_return_type` + // infers for `Result<_, E>` — unless `400` is itself among the declared + // codes. The inferred success (200) response is unaffected. + let declares_errors = config.typed_responses.is_some_and(|r| !r.is_empty()) + || config.error_status.is_some_and(|s| !s.is_empty()); + if declares_errors { + let declares_400 = config + .typed_responses + .is_some_and(|typed| typed.iter().any(|(code, _)| *code == 400)) + || config + .error_status + .is_some_and(|codes| codes.contains(&400)); + if !declares_400 { + responses.remove("400"); } } - fn assert_params(op: &Operation, expected: &[ExpectedParam]) { - match op.parameters.as_ref() { - None => assert!(expected.is_empty()), - Some(params) => { - assert_eq!(params.len(), expected.len()); - for (param, exp) in params.iter().zip(expected) { - assert_eq!(param.name, exp.name); - assert_eq!(param_schema_type(param), exp.schema); - } - } - } + if has_validated_extractor { + responses + .entry("422".to_string()) + .or_insert_with(validation_error_response); } - fn assert_responses(op: &Operation, expected: &[ExpectedResp]) { - for exp in expected { - let resp = op.responses.get(exp.status).expect("response missing"); - let media = resp - .content - .as_ref() - .and_then(|c| c.get("application/json")) - .or_else(|| resp.content.as_ref().and_then(|c| c.get("text/plain"))) - .expect("media type missing"); - if let Some(schema_ty) = &exp.schema { - match media.schema.as_ref().expect("schema expected") { - SchemaRef::Inline(schema) => { - assert_eq!(schema.schema_type, Some(*schema_ty)); - } - SchemaRef::Ref(_) => panic!("expected inline schema"), - } - } + if let Some(example) = config.response_example + && let Some(response) = responses.get_mut("200") + && let Some(content) = response.content.as_mut() + { + for media in content.values_mut() { + media.example = Some(example.clone()); } } - fn build_with_tags(sig_src: &str, path: &str, tags: Option<&[String]>) -> Operation { - let sig: syn::Signature = syn::parse_str(sig_src).expect("signature parse failed"); - build_operation_from_function(&sig, path, &HashSet::new(), &HashMap::new(), None, tags) - } - - #[test] - fn test_build_operation_with_tags() { - let tags = vec!["users".to_string(), "admin".to_string()]; - let op = build_with_tags("fn test() -> String", "/test", Some(&tags)); - assert_eq!(op.tags, Some(tags)); - } - - #[test] - fn test_build_operation_without_tags() { - let op = build_with_tags("fn test() -> String", "/test", None); - assert_eq!(op.tags, None); - } - - #[test] - fn test_build_operation_operation_id() { - let op = build("fn my_handler() -> String", "/test", None); - assert_eq!(op.operation_id, Some("my_handler".to_string())); - } - - #[rstest] - #[case( - "fn upload(data: String) -> String", - "/upload", - None::<&[u16]>, - vec![], - Some(ExpectedBody { content_type: "text/plain", schema: Some(SchemaType::String) }), - vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] - )] - #[case( - "fn upload_ref(data: &str) -> String", - "/upload", - None::<&[u16]>, - vec![], - Some(ExpectedBody { content_type: "text/plain", schema: Some(SchemaType::String) }), - vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] - )] - #[case( - "fn get(Path(params): Path<(i32,)>) -> String", - "/users/{id}/{name}", - None::<&[u16]>, - vec![ - ExpectedParam { name: "id", schema: Some(SchemaType::Integer) }, - ExpectedParam { name: "name", schema: Some(SchemaType::String) }, - ], - None, - vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] - )] - #[case( - "fn get() -> String", - "/items/{item_id}", - None::<&[u16]>, - vec![ExpectedParam { name: "item_id", schema: Some(SchemaType::String) }], - None, - vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] - )] - #[case( - "fn get(Path(id): Path) -> String", - "/shops/{shop_id}/items/{item_id}", - None::<&[u16]>, - vec![ - ExpectedParam { name: "shop_id", schema: Some(SchemaType::String) }, - ExpectedParam { name: "item_id", schema: Some(SchemaType::String) }, - ], - None, - vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] - )] - #[case( - "fn create(Json(body): Json) -> Result", - "/create", - None::<&[u16]>, - vec![], - Some(ExpectedBody { content_type: "application/json", schema: None }), - vec![ - ExpectedResp { status: "200", schema: Some(SchemaType::String) }, - ExpectedResp { status: "400", schema: Some(SchemaType::String) }, - ] - )] - #[case( - "fn get(Path(params): Path<(i32,)>) -> String", - "/users/{id}/{name}/{extra}", - None::<&[u16]>, - vec![ - ExpectedParam { name: "id", schema: Some(SchemaType::Integer) }, - ExpectedParam { name: "name", schema: Some(SchemaType::String) }, - ExpectedParam { name: "extra", schema: Some(SchemaType::String) }, - ], - None, - vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] - )] - #[case( - "fn get() -> String", - "/items/{item_id}/extra/{more}", - None::<&[u16]>, - vec![ - ExpectedParam { name: "item_id", schema: Some(SchemaType::String) }, - ExpectedParam { name: "more", schema: Some(SchemaType::String) }, - ], - None, - vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] - )] - #[case( - "fn post(data: String) -> String", - "/post", - None::<&[u16]>, - vec![], - Some(ExpectedBody { content_type: "text/plain", schema: Some(SchemaType::String) }), - vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] - )] - #[case( - "fn no_error_extra() -> String", - "/plain", - Some(&[500u16][..]), - vec![], - None, - vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] - )] - #[case( - "fn create() -> Result", - "/create", - Some(&[400u16, 500u16][..]), - vec![], - None, - vec![ - ExpectedResp { status: "200", schema: Some(SchemaType::String) }, - ExpectedResp { status: "400", schema: Some(SchemaType::String) }, - ExpectedResp { status: "500", schema: Some(SchemaType::String) }, - ] - )] - #[case( - "fn create() -> Result", - "/create", - Some(&[401u16, 402u16][..]), - vec![], - None, - vec![ - ExpectedResp { status: "200", schema: Some(SchemaType::String) }, - ExpectedResp { status: "400", schema: Some(SchemaType::String) }, - ExpectedResp { status: "401", schema: Some(SchemaType::String) }, - ExpectedResp { status: "402", schema: Some(SchemaType::String) }, - ] - )] - fn test_build_operation_cases( - #[case] sig_src: &str, - #[case] path: &str, - #[case] extra_status: Option<&[u16]>, - #[case] expected_params: Vec, - #[case] expected_body: Option, - #[case] expected_resps: Vec, - ) { - let op = build(sig_src, path, extra_status); - assert_params(&op, &expected_params); - assert_body(&op, expected_body.as_ref()); - assert_responses(&op, &expected_resps); + // Feature 2: re-key the inferred success response under the declared + // non-200 status (`status = `). No-body success statuses (204 No + // Content, 304 Not Modified) must not carry a response body. + if let Some(success) = config.success_status + && success != 200 + && let Some(mut response) = responses.remove("200") + { + if matches!(success, 204 | 304) { + response.content = None; + } + responses.insert(success.to_string(), response); } - // ======== Tests for uncovered lines ======== - - #[test] - fn test_single_path_param_with_single_type() { - // Test: Path with single type - // This exercises the branch: path_params.len() == 1 with non-tuple type - let op = build("fn get(Path(id): Path) -> String", "/users/{id}", None); - - // Should have exactly 1 path parameter with Integer type - let params = op.parameters.as_ref().expect("parameters expected"); - assert_eq!(params.len(), 1); - assert_eq!(params[0].name, "id"); - assert_eq!(param_schema_type(¶ms[0]), Some(SchemaType::Integer)); + Operation { + operation_id: config + .operation_id + .map(str::to_owned) + .or_else(|| Some(sig.ident.to_string())), + tags: config.tags.map(<[std::string::String]>::to_vec), + summary: config.summary.map(str::to_owned), + description: None, + parameters: if parameters.is_empty() { + None + } else { + Some(parameters) + }, + request_body, + responses, + security: config.security.map(security_requirements), + deprecated: config.deprecated.then_some(true), + // OpenAPI 3.1 Operation Object members vespera does not populate from + // `#[route]` (no DSL for externalDocs / callbacks / operation-level + // servers): `None` so they are skip-serialized — output is unchanged. + external_docs: None, + callbacks: None, + servers: None, } +} - #[test] - fn test_single_path_param_with_string_type() { - // Another test for line 55: Path with single path param - let op = build( - "fn get(Path(id): Path) -> String", - "/users/{user_id}", - None, - ); - - let params = op.parameters.as_ref().expect("parameters expected"); - assert_eq!(params.len(), 1); - assert_eq!(params[0].name, "user_id"); - assert_eq!(param_schema_type(¶ms[0]), Some(SchemaType::String)); +fn header_parameter(header: &HeaderParam) -> Parameter { + Parameter { + name: header.name.clone(), + r#in: ParameterLocation::Header, + description: header.description.clone(), + required: Some(header.required), + schema: Some(SchemaRef::Inline(Box::new(Schema { + schema_type: Some(SchemaType::String), + ..Schema::default() + }))), + example: None, } +} - #[test] - fn test_non_path_extractor_with_query() { - // Test: non-Path extractor handling - // When input is Query, it should NOT be treated as Path - let op = build( - "fn search(Query(params): Query) -> String", - "/search", - None, - ); - - // Test: Query params should be extended to parameters - // But QueryParams is not in known_schemas/struct_definitions so it won't appear - // The key is that it doesn't treat Query as a Path extractor (line 85 returns false) - assert!(op.request_body.is_none()); // Query is not a body - } +fn error_response_description() -> String { + "Error response".to_string() +} - #[test] - fn test_non_path_extractor_with_state() { - // Test: State should be ignored - let op = build( - "fn handler(State(state): State) -> String", - "/handler", - None, - ); - - // State is not a path extractor, and State params are typically ignored - // line 85 returns false, so line 89 extends parameters (but State is usually filtered out) - assert!(op.parameters.is_none() || op.parameters.as_ref().unwrap().is_empty()); +fn response_description_for_status(status_code: u16) -> String { + if (200..300).contains(&status_code) { + "Successful response".to_string() + } else { + error_response_description() } +} - #[test] - fn test_string_body() { - // String arg is handled by parse_request_body via is_string_like() - let op = build("fn upload(content: String) -> String", "/upload", None); - - let body = op.request_body.as_ref().expect("request body expected"); - assert!(body.content.contains_key("text/plain")); - let media = body.content.get("text/plain").unwrap(); - match media.schema.as_ref().unwrap() { - SchemaRef::Inline(schema) => { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } - SchemaRef::Ref(_) => panic!("expected inline schema"), +/// Header parameters can be declared from both typed extractors and route-site +/// `headers = [...]`. Keep the first occurrence (signature-derived parameters +/// are appended before route-site headers and usually carry the richer schema) +/// and drop later duplicates using HTTP's case-insensitive header-name rules. +fn deduplicate_header_parameters(parameters: &mut Vec) { + let mut seen_headers = HashSet::new(); + parameters.retain(|parameter| { + if parameter.r#in != ParameterLocation::Header { + return true; } - } - - #[test] - fn test_str_ref_body() { - // &str arg is handled by parse_request_body via is_string_like() - let op = build("fn upload(content: &str) -> String", "/upload", None); - - let body = op.request_body.as_ref().expect("request body expected"); - assert!(body.content.contains_key("text/plain")); - } - - #[test] - fn test_string_ref_body() { - // &String arg is handled by parse_request_body via is_string_like() - let op = build("fn upload(content: &String) -> String", "/upload", None); - - let body = op.request_body.as_ref().expect("request body expected"); - assert!(body.content.contains_key("text/plain")); - } - - #[test] - fn test_non_string_arg_not_body() { - // Non-string args don't become request body - let op = build("fn process(count: i32) -> String", "/process", None); - assert!(op.request_body.is_none()); - } - - #[test] - fn test_multiple_path_params_with_single_type() { - // Test: multiple path params but single type - let op = build( - "fn get(Path(id): Path) -> String", - "/shops/{shop_id}/items/{item_id}", - None, - ); - - // Both params should use String type - let params = op.parameters.as_ref().expect("parameters expected"); - assert_eq!(params.len(), 2); - assert_eq!(param_schema_type(¶ms[0]), Some(SchemaType::String)); - assert_eq!(param_schema_type(¶ms[1]), Some(SchemaType::String)); - } + seen_headers.insert(parameter.name.to_ascii_lowercase()) + }); +} - #[test] - fn test_reference_to_non_path_type_not_body() { - // &(tuple) is not string-like, no body created - let op = build("fn process(data: &(i32, i32)) -> String", "/process", None); - assert!(op.request_body.is_none()); - } +fn typed_response(schema_name: &str, description: String) -> Response { + let mut content = BTreeMap::new(); + content.insert( + "application/json".to_string(), + MediaType { + schema: Some(SchemaRef::Ref(Reference::schema(schema_name))), + example: None, + examples: None, + }, + ); - #[test] - fn test_reference_to_slice_not_body() { - // &[T] is not string-like, no body created - let op = build("fn process(data: &[u8]) -> String", "/process", None); - assert!(op.request_body.is_none()); + Response { + description, + headers: None, + content: Some(content), } +} - #[test] - fn test_tuple_type_not_body() { - // Tuple type is not string-like, no body created - let op = build( - "fn process(data: (i32, String)) -> String", - "/process", - None, - ); - assert!(op.request_body.is_none()); - } +fn validation_error_response() -> Response { + let mut error_properties = BTreeMap::new(); + error_properties.insert( + "path".to_string(), + SchemaRef::Inline(Box::new(Schema::string())), + ); + error_properties.insert( + "message".to_string(), + SchemaRef::Inline(Box::new(Schema::string())), + ); + + let error_item = SchemaRef::Inline(Box::new(Schema { + schema_type: Some(SchemaType::Object), + properties: Some(error_properties), + required: Some(vec!["path".to_string(), "message".to_string()]), + ..Schema::default() + })); + + let mut response_properties = BTreeMap::new(); + response_properties.insert( + "errors".to_string(), + SchemaRef::Inline(Box::new(Schema { + schema_type: Some(SchemaType::Array), + items: Some(error_item), + ..Schema::default() + })), + ); + + let mut content = BTreeMap::new(); + content.insert( + "application/json".to_string(), + MediaType { + schema: Some(SchemaRef::Inline(Box::new(Schema { + schema_type: Some(SchemaType::Object), + properties: Some(response_properties), + required: Some(vec!["errors".to_string()]), + ..Schema::default() + }))), + example: None, + examples: None, + }, + ); - #[test] - fn test_array_type_not_body() { - // Array type is not string-like, no body created - let op = build("fn process(data: [u8; 4]) -> String", "/process", None); - assert!(op.request_body.is_none()); + Response { + description: "Validation failed".to_string(), + headers: None, + content: Some(content), } +} - #[test] - fn test_non_path_extractor_generates_params_and_extends() { - // Test: non-Path extractor that generates params - // Query where T is a known struct generates query parameters - let sig: syn::Signature = syn::parse_str("fn search(Query(params): Query, TypedHeader(auth): TypedHeader) -> String").unwrap(); - - let mut struct_definitions = HashMap::new(); - struct_definitions.insert( - "SearchParams".to_string(), - "pub struct SearchParams { pub q: String }".to_string(), - ); - - let op = build_operation_from_function( - &sig, - "/search", - &HashSet::new(), - &struct_definitions, - None, - None, - ); - - // Query is not Path (line 85 returns false) - // parse_function_parameter returns Some for Query - // Line 89: parameters.extend(params) - // TypedHeader also generates a header parameter - assert!(op.parameters.is_some()); - let params = op.parameters.unwrap(); - // Should have query param(s) and header param - assert!(!params.is_empty()); - } +fn security_requirements(security: &[String]) -> Vec>> { + security + .iter() + .map(|scheme| BTreeMap::from([(scheme.clone(), Vec::new())])) + .collect() } + +#[cfg(test)] +mod tests; diff --git a/crates/vespera_macro/src/parser/operation/tests.rs b/crates/vespera_macro/src/parser/operation/tests.rs new file mode 100644 index 00000000..b4eee0d5 --- /dev/null +++ b/crates/vespera_macro/src/parser/operation/tests.rs @@ -0,0 +1,820 @@ +//! Unit tests for the function-to-`Operation` parser in `super`. +//! +//! Split out of `operation.rs` so that file stays within the repo's +//! 1000-line source cap. The module path `parser::operation::tests` is +//! unchanged, so insta snapshot names are unaffected. + +use std::collections::HashMap; + +use rstest::rstest; +use vespera_core::schema::{SchemaRef, SchemaType}; + +use super::*; + +fn param_schema_type(param: &Parameter) -> Option { + match param.schema.as_ref()? { + SchemaRef::Inline(schema) => schema.schema_type, + SchemaRef::Ref(_) => None, + } +} + +fn build(sig_src: &str, path: &str, error_status: Option<&[u16]>) -> Operation { + let sig: syn::Signature = syn::parse_str(sig_src).expect("signature parse failed"); + build_operation_from_function( + &sig, + path, + &HashSet::new(), + &HashMap::new(), + OperationRouteConfig { + error_status, + ..OperationRouteConfig::default() + }, + ) +} + +fn build_with_typed_responses( + sig_src: &str, + error_status: Option<&[u16]>, + typed_responses: &[(u16, String)], +) -> Operation { + let sig: syn::Signature = syn::parse_str(sig_src).expect("signature parse failed"); + build_operation_from_function( + &sig, + "/items/{id}", + &HashSet::new(), + &HashMap::new(), + OperationRouteConfig { + error_status, + typed_responses: Some(typed_responses), + ..OperationRouteConfig::default() + }, + ) +} + +#[derive(Clone, Debug)] +struct ExpectedParam { + name: &'static str, + schema: Option, +} + +#[derive(Clone, Debug)] +struct ExpectedBody { + content_type: &'static str, + schema: Option, +} + +#[derive(Clone, Debug)] +struct ExpectedResp { + status: &'static str, + schema: Option, +} + +fn assert_body(op: &Operation, expected: Option<&ExpectedBody>) { + match expected { + None => assert!(op.request_body.is_none()), + Some(exp) => { + let body = op.request_body.as_ref().expect("request body expected"); + let media = body + .content + .get(exp.content_type) + .or_else(|| { + // allow fallback to the only available content type if expected is absent + if body.content.len() == 1 { + body.content.values().next() + } else { + None + } + }) + .expect("expected content type"); + if let Some(schema_ty) = &exp.schema { + match media.schema.as_ref().expect("schema expected") { + SchemaRef::Inline(schema) => { + assert_eq!(schema.schema_type, Some(*schema_ty)); + } + SchemaRef::Ref(_) => panic!("expected inline schema"), + } + } + } + } +} + +fn assert_params(op: &Operation, expected: &[ExpectedParam]) { + match op.parameters.as_ref() { + None => assert!(expected.is_empty()), + Some(params) => { + assert_eq!(params.len(), expected.len()); + for (param, exp) in params.iter().zip(expected) { + assert_eq!(param.name, exp.name); + assert_eq!(param_schema_type(param), exp.schema); + } + } + } +} + +fn assert_responses(op: &Operation, expected: &[ExpectedResp]) { + for exp in expected { + let resp = op.responses.get(exp.status).expect("response missing"); + let media = resp + .content + .as_ref() + .and_then(|c| c.get("application/json")) + .or_else(|| resp.content.as_ref().and_then(|c| c.get("text/plain"))) + .expect("media type missing"); + if let Some(schema_ty) = &exp.schema { + match media.schema.as_ref().expect("schema expected") { + SchemaRef::Inline(schema) => { + assert_eq!(schema.schema_type, Some(*schema_ty)); + } + SchemaRef::Ref(_) => panic!("expected inline schema"), + } + } + } +} + +fn build_with_tags(sig_src: &str, path: &str, tags: Option<&[String]>) -> Operation { + let sig: syn::Signature = syn::parse_str(sig_src).expect("signature parse failed"); + build_operation_from_function( + &sig, + path, + &HashSet::new(), + &HashMap::new(), + OperationRouteConfig { + tags, + ..OperationRouteConfig::default() + }, + ) +} + +fn build_with_security(sig_src: &str, path: &str, security: Option<&[String]>) -> Operation { + let sig: syn::Signature = syn::parse_str(sig_src).expect("signature parse failed"); + build_operation_from_function( + &sig, + path, + &HashSet::new(), + &HashMap::new(), + OperationRouteConfig { + security, + ..OperationRouteConfig::default() + }, + ) +} + +fn build_with_operation_metadata( + sig_src: &str, + path: &str, + operation_id: Option<&str>, + summary: Option<&str>, + deprecated: bool, +) -> Operation { + let sig: syn::Signature = syn::parse_str(sig_src).expect("signature parse failed"); + build_operation_from_function( + &sig, + path, + &HashSet::new(), + &HashMap::new(), + OperationRouteConfig { + operation_id, + summary, + deprecated, + ..OperationRouteConfig::default() + }, + ) +} + +#[test] +fn test_build_operation_with_tags() { + let tags = vec!["users".to_string(), "admin".to_string()]; + let op = build_with_tags("fn test() -> String", "/test", Some(&tags)); + assert_eq!(op.tags, Some(tags)); +} + +#[test] +fn test_build_operation_without_tags() { + let op = build_with_tags("fn test() -> String", "/test", None); + assert_eq!(op.tags, None); +} + +#[test] +fn test_build_operation_operation_id() { + let op = build("fn my_handler() -> String", "/test", None); + assert_eq!(op.operation_id, Some("my_handler".to_string())); +} + +#[test] +fn test_build_operation_operation_id_override() { + let op = build_with_operation_metadata( + "fn my_handler() -> String", + "/test", + Some("getUser"), + None, + false, + ); + assert_eq!(op.operation_id, Some("getUser".to_string())); +} + +#[test] +fn test_build_operation_summary_and_deprecated() { + let op = build_with_operation_metadata( + "fn my_handler() -> String", + "/test", + None, + Some("Get a user"), + true, + ); + assert_eq!(op.summary, Some("Get a user".to_string())); + assert_eq!(op.deprecated, Some(true)); +} + +#[rstest] +#[case( + "fn upload(data: String) -> String", + "/upload", + None::<&[u16]>, + vec![], + Some(ExpectedBody { content_type: "text/plain", schema: Some(SchemaType::String) }), + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] +#[case( + "fn upload_ref(data: &str) -> String", + "/upload", + None::<&[u16]>, + vec![], + Some(ExpectedBody { content_type: "text/plain", schema: Some(SchemaType::String) }), + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] +#[case( + "fn get(Path(params): Path<(i32,)>) -> String", + "/users/{id}/{name}", + None::<&[u16]>, + vec![ + ExpectedParam { name: "id", schema: Some(SchemaType::Integer) }, + ExpectedParam { name: "name", schema: Some(SchemaType::String) }, + ], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] +#[case( + "fn get() -> String", + "/items/{item_id}", + None::<&[u16]>, + vec![ExpectedParam { name: "item_id", schema: Some(SchemaType::String) }], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] +#[case( + "fn get(Path(id): Path) -> String", + "/shops/{shop_id}/items/{item_id}", + None::<&[u16]>, + vec![ + ExpectedParam { name: "shop_id", schema: Some(SchemaType::String) }, + ExpectedParam { name: "item_id", schema: Some(SchemaType::String) }, + ], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] +#[case( + "fn create(Json(body): Json) -> Result", + "/create", + None::<&[u16]>, + vec![], + Some(ExpectedBody { content_type: "application/json", schema: None }), + vec![ + ExpectedResp { status: "200", schema: Some(SchemaType::String) }, + ExpectedResp { status: "400", schema: Some(SchemaType::String) }, + ] + )] +#[case( + "fn get(Path(params): Path<(i32,)>) -> String", + "/users/{id}/{name}/{extra}", + None::<&[u16]>, + vec![ + ExpectedParam { name: "id", schema: Some(SchemaType::Integer) }, + ExpectedParam { name: "name", schema: Some(SchemaType::String) }, + ExpectedParam { name: "extra", schema: Some(SchemaType::String) }, + ], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] +#[case( + "fn get() -> String", + "/items/{item_id}/extra/{more}", + None::<&[u16]>, + vec![ + ExpectedParam { name: "item_id", schema: Some(SchemaType::String) }, + ExpectedParam { name: "more", schema: Some(SchemaType::String) }, + ], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] +#[case( + "fn post(data: String) -> String", + "/post", + None::<&[u16]>, + vec![], + Some(ExpectedBody { content_type: "text/plain", schema: Some(SchemaType::String) }), + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] +#[case( + "fn no_error_extra() -> String", + "/plain", + Some(&[500u16][..]), + vec![], + None, + vec![ExpectedResp { status: "200", schema: Some(SchemaType::String) }] + )] +#[case( + "fn create() -> Result", + "/create", + Some(&[400u16, 500u16][..]), + vec![], + None, + vec![ + ExpectedResp { status: "200", schema: Some(SchemaType::String) }, + ExpectedResp { status: "400", schema: Some(SchemaType::String) }, + ExpectedResp { status: "500", schema: Some(SchemaType::String) }, + ] + )] +// Feature 1: declaring `error_status = [401, 402]` makes the explicit error +// set authoritative, so the auto-inferred 400 for `Result<_, E>` is dropped +// (400 is not among the declared codes). The 200 success response is intact. +#[case( + "fn create() -> Result", + "/create", + Some(&[401u16, 402u16][..]), + vec![], + None, + vec![ + ExpectedResp { status: "200", schema: Some(SchemaType::String) }, + ExpectedResp { status: "401", schema: Some(SchemaType::String) }, + ExpectedResp { status: "402", schema: Some(SchemaType::String) }, + ] + )] +fn test_build_operation_cases( + #[case] sig_src: &str, + #[case] path: &str, + #[case] extra_status: Option<&[u16]>, + #[case] expected_params: Vec, + #[case] expected_body: Option, + #[case] expected_resps: Vec, +) { + let op = build(sig_src, path, extra_status); + assert_params(&op, &expected_params); + assert_body(&op, expected_body.as_ref()); + assert_responses(&op, &expected_resps); +} + +#[test] +fn typed_responses_use_schema_refs_and_override_error_status() { + let typed = vec![(404, "NotFoundError".to_string())]; + let op = build_with_typed_responses( + "fn get() -> Result", + Some(&[404u16, 500u16]), + &typed, + ); + + let response = op.responses.get("404").expect("404 response"); + let schema = response + .content + .as_ref() + .and_then(|content| content.get("application/json")) + .and_then(|media| media.schema.as_ref()) + .expect("typed schema"); + match schema { + SchemaRef::Ref(reference) => { + assert_eq!(reference.ref_path, "#/components/schemas/NotFoundError"); + } + SchemaRef::Inline(_) => panic!("typed response must use schema ref"), + } + assert!(op.responses.contains_key("500")); +} + +fn build_with_success_status( + sig_src: &str, + success_status: Option, + error_status: Option<&[u16]>, + typed_responses: Option<&[(u16, String)]>, +) -> Operation { + let sig: syn::Signature = syn::parse_str(sig_src).expect("signature parse failed"); + build_operation_from_function( + &sig, + "/items/{id}", + &HashSet::new(), + &HashMap::new(), + OperationRouteConfig { + error_status, + typed_responses, + success_status, + ..OperationRouteConfig::default() + }, + ) +} + +// ======== Feature 1: explicit error declarations suppress the auto-400 ======== + +#[test] +fn error_status_declaration_suppresses_auto_400() { + // `Result<_, E>` infers a default 400; declaring `error_status = [500]` + // makes the explicit error set authoritative, dropping the auto-400. + let op = build( + "fn create() -> Result", + "/create", + Some(&[500u16]), + ); + assert!(op.responses.contains_key("200"), "200 success is preserved"); + assert!(op.responses.contains_key("500")); + assert!( + !op.responses.contains_key("400"), + "auto-400 must be suppressed when an explicit error set is declared" + ); +} + +#[test] +fn typed_responses_declaration_suppresses_auto_400() { + let typed = vec![(500u16, "ServerError".to_string())]; + let op = build_with_success_status( + "fn create() -> Result", + None, + None, + Some(&typed), + ); + assert!(op.responses.contains_key("200")); + assert!(op.responses.contains_key("500")); + assert!( + !op.responses.contains_key("400"), + "auto-400 must be suppressed when `responses` is declared" + ); +} + +#[test] +fn declared_400_is_kept_via_error_status() { + // When 400 is itself among the declared codes, it survives. + let op = build( + "fn create() -> Result", + "/create", + Some(&[400u16, 404u16]), + ); + assert!( + op.responses.contains_key("400"), + "declared 400 must be kept" + ); + assert!(op.responses.contains_key("404")); +} + +#[test] +fn declared_400_is_kept_via_typed_responses() { + let typed = vec![(400u16, "BadRequest".to_string())]; + let op = build_with_success_status( + "fn create() -> Result", + None, + None, + Some(&typed), + ); + assert!( + op.responses.contains_key("400"), + "declared 400 must be kept" + ); +} + +#[test] +fn no_declaration_keeps_inferred_400_backward_compatible() { + // A plain `Result<_, E>` with no annotations keeps the inferred 400. + let op = build("fn create() -> Result", "/create", None); + assert!(op.responses.contains_key("200")); + assert!( + op.responses.contains_key("400"), + "without explicit declarations the inferred 400 stays (backward compatible)" + ); +} + +// ======== Feature 2: `status = ` re-keys the success response ======== + +#[test] +fn success_status_rekeys_200_and_preserves_body() { + let op = build_with_success_status("fn create() -> String", Some(201), None, None); + assert!(op.responses.contains_key("201")); + assert!(!op.responses.contains_key("200"), "200 is re-keyed to 201"); + assert!( + op.responses.get("201").unwrap().content.is_some(), + "201 keeps the inferred body" + ); +} + +#[test] +fn success_status_204_drops_body() { + let op = build_with_success_status("fn create() -> String", Some(204), None, None); + let resp = op.responses.get("204").expect("204 response"); + assert!( + resp.content.is_none(), + "204 No Content must not carry a response body" + ); + assert!(!op.responses.contains_key("200")); +} + +#[test] +fn success_status_204_with_error_status_yields_only_204_and_404() { + // Mirrors the example `/error/status-code/{id}`: + // `status = 204, error_status = [404]` on `Result`. + let op = build_with_success_status( + "fn del() -> Result", + Some(204), + Some(&[404u16]), + None, + ); + assert!(op.responses.contains_key("204")); + assert!(op.responses.contains_key("404")); + assert!(!op.responses.contains_key("200"), "no spurious 200"); + assert!(!op.responses.contains_key("400"), "no spurious 400"); + assert!(op.responses.get("204").unwrap().content.is_none()); +} + +#[test] +fn success_status_200_is_noop() { + let op = build_with_success_status("fn create() -> String", Some(200), None, None); + assert!(op.responses.contains_key("200")); +} + +#[test] +fn validated_json_builds_request_body_and_422_response() { + let op = build( + "fn create(Validated(Json(req)): Validated>) -> String", + "/users", + None, + ); + + assert_body( + &op, + Some(&ExpectedBody { + content_type: "application/json", + schema: None, + }), + ); + let response = op.responses.get("422").expect("422 response present"); + assert_eq!(response.description, "Validation failed"); + let schema = response + .content + .as_ref() + .and_then(|content| content.get("application/json")) + .and_then(|media| media.schema.as_ref()) + .expect("422 json schema"); + let SchemaRef::Inline(schema) = schema else { + panic!("validation response should be inline schema") + }; + assert_eq!(schema.required, Some(vec!["errors".to_string()])); + assert!(schema.properties.as_ref().unwrap().contains_key("errors")); +} + +#[test] +fn validated_path_uses_inner_path_type() { + let op = build( + "fn get(Validated(Path(id)): Validated>) -> String", + "/users/{id}", + None, + ); + + assert_params( + &op, + &[ExpectedParam { + name: "id", + schema: Some(SchemaType::Integer), + }], + ); + assert!(op.responses.contains_key("422")); +} + +#[test] +fn duplicate_header_parameters_are_deduplicated_case_insensitively() { + let sig: syn::Signature = + syn::parse_str("fn traced(TypedHeader(x_trace_id): TypedHeader) -> String") + .expect("signature parse failed"); + let route_headers = vec![HeaderParam { + name: "x-trace-id".to_string(), + required: true, + description: Some("Route-site duplicate".to_string()), + }]; + + let op = build_operation_from_function( + &sig, + "/traced", + &HashSet::new(), + &HashMap::new(), + OperationRouteConfig { + headers: Some(&route_headers), + ..OperationRouteConfig::default() + }, + ); + + let headers: Vec<_> = op + .parameters + .as_ref() + .expect("parameters present") + .iter() + .filter(|parameter| parameter.r#in == ParameterLocation::Header) + .collect(); + assert_eq!(headers.len(), 1); + assert_eq!(headers[0].name, "x-trace-id"); +} + +#[test] +fn typed_response_descriptions_match_status_class() { + let typed = vec![(200, "OkBody".to_string()), (404, "NotFound".to_string())]; + let op = build_with_typed_responses("fn get() -> String", None, &typed); + + assert_eq!( + op.responses.get("200").expect("200 response").description, + "Successful response" + ); + assert_eq!( + op.responses.get("404").expect("404 response").description, + "Error response" + ); +} + +// ======== Tests for uncovered lines ======== + +#[test] +fn test_single_path_param_with_single_type() { + // Test: Path with single type + // This exercises the branch: path_params.len() == 1 with non-tuple type + let op = build("fn get(Path(id): Path) -> String", "/users/{id}", None); + + // Should have exactly 1 path parameter with Integer type + let params = op.parameters.as_ref().expect("parameters expected"); + assert_eq!(params.len(), 1); + assert_eq!(params[0].name, "id"); + assert_eq!(param_schema_type(¶ms[0]), Some(SchemaType::Integer)); +} + +#[test] +fn test_single_path_param_with_string_type() { + // Another test for line 55: Path with single path param + let op = build( + "fn get(Path(id): Path) -> String", + "/users/{user_id}", + None, + ); + + let params = op.parameters.as_ref().expect("parameters expected"); + assert_eq!(params.len(), 1); + assert_eq!(params[0].name, "user_id"); + assert_eq!(param_schema_type(¶ms[0]), Some(SchemaType::String)); +} + +#[test] +fn test_non_path_extractor_with_query() { + // Test: non-Path extractor handling + // When input is Query, it should NOT be treated as Path + let op = build( + "fn search(Query(params): Query) -> String", + "/search", + None, + ); + + // Test: Query params should be extended to parameters + // But QueryParams is not in known_schemas/struct_definitions so it won't appear + // The key is that it doesn't treat Query as a Path extractor (line 85 returns false) + assert!(op.request_body.is_none()); // Query is not a body +} + +#[test] +fn test_non_path_extractor_with_state() { + // Test: State should be ignored + let op = build( + "fn handler(State(state): State) -> String", + "/handler", + None, + ); + + // State is not a path extractor, and State params are typically ignored + // line 85 returns false, so line 89 extends parameters (but State is usually filtered out) + assert!(op.parameters.is_none() || op.parameters.as_ref().unwrap().is_empty()); +} + +#[test] +fn test_string_body() { + // String arg is handled by parse_request_body via is_string_like() + let op = build("fn upload(content: String) -> String", "/upload", None); + + let body = op.request_body.as_ref().expect("request body expected"); + assert!(body.content.contains_key("text/plain")); + let media = body.content.get("text/plain").unwrap(); + match media.schema.as_ref().unwrap() { + SchemaRef::Inline(schema) => { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } + SchemaRef::Ref(_) => panic!("expected inline schema"), + } +} + +#[test] +fn test_str_ref_body() { + // &str arg is handled by parse_request_body via is_string_like() + let op = build("fn upload(content: &str) -> String", "/upload", None); + + let body = op.request_body.as_ref().expect("request body expected"); + assert!(body.content.contains_key("text/plain")); +} + +#[test] +fn test_string_ref_body() { + // &String arg is handled by parse_request_body via is_string_like() + let op = build("fn upload(content: &String) -> String", "/upload", None); + + let body = op.request_body.as_ref().expect("request body expected"); + assert!(body.content.contains_key("text/plain")); +} + +#[test] +fn test_non_string_arg_not_body() { + // Non-string args don't become request body + let op = build("fn process(count: i32) -> String", "/process", None); + assert!(op.request_body.is_none()); +} + +#[test] +fn test_multiple_path_params_with_single_type() { + // Test: multiple path params but single type + let op = build( + "fn get(Path(id): Path) -> String", + "/shops/{shop_id}/items/{item_id}", + None, + ); + + // Both params should use String type + let params = op.parameters.as_ref().expect("parameters expected"); + assert_eq!(params.len(), 2); + assert_eq!(param_schema_type(¶ms[0]), Some(SchemaType::String)); + assert_eq!(param_schema_type(¶ms[1]), Some(SchemaType::String)); +} + +#[test] +fn test_reference_to_non_path_type_not_body() { + // &(tuple) is not string-like, no body created + let op = build("fn process(data: &(i32, i32)) -> String", "/process", None); + assert!(op.request_body.is_none()); +} + +#[test] +fn test_reference_to_slice_not_body() { + // &[T] is not string-like, no body created + let op = build("fn process(data: &[u8]) -> String", "/process", None); + assert!(op.request_body.is_none()); +} + +#[test] +fn test_tuple_type_not_body() { + // Tuple type is not string-like, no body created + let op = build( + "fn process(data: (i32, String)) -> String", + "/process", + None, + ); + assert!(op.request_body.is_none()); +} + +#[test] +fn test_array_type_not_body() { + // Array type is not string-like, no body created + let op = build("fn process(data: [u8; 4]) -> String", "/process", None); + assert!(op.request_body.is_none()); +} + +#[test] +fn test_non_path_extractor_generates_params_and_extends() { + // Test: non-Path extractor that generates params + // Query where T is a known struct generates query parameters + let sig: syn::Signature = syn::parse_str("fn search(Query(params): Query, TypedHeader(auth): TypedHeader) -> String").unwrap(); + + let mut struct_definitions = HashMap::new(); + struct_definitions.insert("SearchParams", "pub struct SearchParams { pub q: String }"); + + let op = build_operation_from_function( + &sig, + "/search", + &HashSet::new(), + &struct_definitions, + OperationRouteConfig::default(), + ); + + // Query is not Path (line 85 returns false) + // parse_function_parameter returns Some for Query + // Line 89: parameters.extend(params) + // TypedHeader also generates a header parameter + assert!(op.parameters.is_some()); + let params = op.parameters.unwrap(); + // Should have query param(s) and header param + assert!(!params.is_empty()); +} + +#[test] +fn route_security_generates_requirement_objects_and_preserves_empty() { + let bearer = vec!["bearerAuth".to_string(), "apiKey".to_string()]; + let op = build_with_security("fn secure() -> String", "/secure", Some(&bearer)); + let requirements = op.security.expect("security present"); + assert_eq!(requirements.len(), 2); + assert!(requirements[0].contains_key("bearerAuth")); + assert!(requirements[1].contains_key("apiKey")); + + let empty: Vec = Vec::new(); + let op = build_with_security("fn public() -> String", "/public", Some(&empty)); + assert_eq!(op.security, Some(Vec::new())); +} diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 551d7832..b0ba4ef0 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -1,436 +1,67 @@ use std::collections::{HashMap, HashSet}; -use syn::{FnArg, Pat, PatType, Type}; -use vespera_core::{ - route::{Parameter, ParameterLocation}, - schema::{Schema, SchemaRef}, -}; +use syn::{FnArg, Pat, PatType}; +use vespera_core::route::Parameter; -use super::schema::{ - extract_field_rename, extract_rename_all, is_primitive_type, parse_struct_to_schema, - parse_type_to_schema_ref_with_schemas, rename_field, -}; -use crate::schema_macro::type_utils::{ - is_map_type as utils_is_map_type, is_primitive_like as utils_is_primitive_like, -}; +use super::extractors::unwrap_validated_type; -/// Combined check: type is either a JSON-schema primitive or a known container type. -fn is_primitive_or_like(ty: &Type) -> bool { - is_primitive_type(ty) || utils_is_primitive_like(ty) -} - -/// Convert `SchemaRef` for query parameters, adding nullable flag if optional. -/// Preserves `$ref` for known types (e.g. enums) — only wraps with nullable when optional. -fn convert_to_inline_schema(field_schema: SchemaRef, is_optional: bool) -> SchemaRef { - match field_schema { - SchemaRef::Inline(mut schema) => { - if is_optional { - schema.nullable = Some(true); - } - SchemaRef::Inline(schema) - } - SchemaRef::Ref(r) => { - if is_optional { - SchemaRef::Inline(Box::new(Schema { - ref_path: Some(r.ref_path), - schema_type: None, - nullable: Some(true), - ..Default::default() - })) - } else { - SchemaRef::Ref(r) - } - } - } -} +mod header; +mod path; +mod query; +mod shared; -/// Analyze function parameter and convert to `OpenAPI` Parameter(s) -/// Returns None if parameter should be ignored (e.g., Query<`HashMap`<...>>) -/// Returns Some(Vec) with one or more parameters -/// -/// `path_params` provides ordered access for tuple-index matching in Path handling. -/// `path_param_set` provides O(1) membership test for bare-name path parameter detection. -#[allow(clippy::too_many_lines)] +/// Analyze function parameter and convert to OpenAPI parameter(s). pub fn parse_function_parameter( arg: &FnArg, path_params: &[String], - path_param_set: &HashSet, - known_schemas: &HashSet, - struct_definitions: &HashMap, + path_param_set: &HashSet<&str>, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, ) -> Option> { match arg { FnArg::Receiver(_) => None, FnArg::Typed(PatType { pat, ty, .. }) => { - // Extract parameter name from pattern - let param_name = match pat.as_ref() { - Pat::Ident(ident) => ident.ident.to_string(), - Pat::TupleStruct(tuple_struct) => { - // Handle Path(id) pattern - if tuple_struct.elems.len() == 1 - && let Pat::Ident(ident) = &tuple_struct.elems[0] - { - ident.ident.to_string() - } else { - return None; - } - } - _ => return None, - }; - - // Check for Option> first - if let Type::Path(type_path) = ty.as_ref() { - let path = &type_path.path; - if !path.segments.is_empty() { - let segment = path.segments.first().unwrap(); - let ident_str = segment.ident.to_string(); + let param_name = extract_param_name(pat.as_ref())?; + let ty = unwrap_validated_type(ty.as_ref()); - // Handle Option> - if ident_str == "Option" - && let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - && let Type::Path(inner_type_path) = inner_ty - && !inner_type_path.path.segments.is_empty() - { - let inner_segment = inner_type_path.path.segments.last().unwrap(); - let inner_ident_str = inner_segment.ident.to_string(); - - if inner_ident_str == "TypedHeader" { - // TypedHeader always uses string schema regardless of inner type - return Some(vec![Parameter { - name: param_name.replace('_', "-"), - r#in: ParameterLocation::Header, - description: None, - required: Some(false), - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - }]); - } - } - } + if let Some(parameters) = header::parse_option_typed_header(¶m_name, ty) { + return Some(parameters); } - - // Check for common Axum extractors first (before checking path_params) - // Handle both Path and vespera::axum::extract::Path by checking the last segment - if let Type::Path(type_path) = ty.as_ref() { - let path = &type_path.path; - if !path.segments.is_empty() { - // Check the last segment (handles both Path and vespera::axum::extract::Path) - let segment = path.segments.last().unwrap(); - let ident_str = segment.ident.to_string(); - - match ident_str.as_str() { - "Path" => { - // Path extractor - use path parameter name from route if available - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() - { - // Check if inner type is a tuple (e.g., Path<(String, String, String)>) - if let Type::Tuple(tuple) = inner_ty { - // For tuple types, extract parameters from path string - let mut parameters = Vec::new(); - let tuple_elems = &tuple.elems; - - // Match tuple elements with path parameters - for (idx, elem_ty) in tuple_elems.iter().enumerate() { - if let Some(param_name) = path_params.get(idx) { - parameters.push(Parameter { - name: param_name.clone(), - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some( - parse_type_to_schema_ref_with_schemas( - elem_ty, - known_schemas, - struct_definitions, - ), - ), - example: None, - }); - } - } - - if !parameters.is_empty() { - return Some(parameters); - } - } else { - // Single path parameter - // Allow only when exactly one path parameter is provided - if path_params.len() != 1 { - return None; - } - let name = path_params[0].clone(); - return Some(vec![Parameter { - name, - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - } - } - "Query" => { - // Query extractor - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() - { - // Check if it's HashMap or BTreeMap - ignore these - if utils_is_map_type(inner_ty) { - return None; - } - - // Check if it's a struct - expand to individual parameters - if let Some(struct_params) = parse_query_struct_to_parameters( - inner_ty, - known_schemas, - struct_definitions, - ) { - return Some(struct_params); - } - - // Ignore primitive-like query params (including Vec/Option of primitive) - if is_primitive_or_like(inner_ty) { - return None; - } - - // Check if it's a known type (primitive or known schema) - // If unknown, don't add parameter - if !is_known_type(inner_ty, known_schemas, struct_definitions) { - return None; - } - - // Otherwise, treat as single parameter - return Some(vec![Parameter { - name: param_name, - r#in: ParameterLocation::Query, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - } - "Header" => { - // Header extractor - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = - args.args.first() - { - // Ignore primitive-like headers - if is_primitive_or_like(inner_ty) { - return None; - } - return Some(vec![Parameter { - name: param_name, - r#in: ParameterLocation::Header, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - inner_ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); - } - } - "TypedHeader" => { - // TypedHeader extractor (axum::TypedHeader) - // TypedHeader always uses string schema regardless of inner type - return Some(vec![Parameter { - name: param_name.replace('_', "-"), - r#in: ParameterLocation::Header, - description: None, - required: Some(true), - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - }]); - } - "Json" | "Form" | "TypedMultipart" | "Multipart" => { - // These extractors are handled as RequestBody - return None; - } - _ => {} - } - } + if let Some(parameters) = + path::parse_path_extractor(ty, path_params, known_schemas, struct_definitions) + { + return Some(parameters); } - - // Check if it's a path parameter (by name match) - for non-extractor cases - if path_param_set.contains(¶m_name) { - return Some(vec![Parameter { - name: param_name, - r#in: ParameterLocation::Path, - description: None, - required: Some(true), - schema: Some(parse_type_to_schema_ref_with_schemas( - ty, - known_schemas, - struct_definitions, - )), - example: None, - }]); + if let Some(parameters) = + query::parse_query_extractor(¶m_name, ty, known_schemas, struct_definitions) + { + return Some(parameters); } - - // Bare primitive without extractor is ignored (cannot infer location) - None - } - } -} - -fn is_known_type( - ty: &Type, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> bool { - // Check if it's a primitive type - if is_primitive_type(ty) { - return true; - } - - // Check if it's a known struct - if let Type::Path(type_path) = ty { - let path = &type_path.path; - if path.segments.is_empty() { - return false; - } - - let segment = path.segments.last().unwrap(); - let ident_str = segment.ident.to_string(); - - // Get type name (handle both simple and qualified paths) - - // Check if it's in struct_definitions or known_schemas - if struct_definitions.contains_key(&ident_str) || known_schemas.contains(&ident_str) { - return true; - } - - // Check for generic types like Vec, Option - recursively check inner type - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - match ident_str.as_str() { - "Vec" | "HashSet" | "BTreeSet" | "Option" => { - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - return is_known_type(inner_ty, known_schemas, struct_definitions); - } - } - _ => {} + if let Some(parameters) = + header::parse_header_extractor(¶m_name, ty, known_schemas, struct_definitions) + { + return Some(parameters); } + + path::parse_bare_path_parameter( + ¶m_name, + ty, + path_param_set, + known_schemas, + struct_definitions, + ) } } - - false } -/// Parse struct fields to individual query parameters -/// Returns None if the type is not a struct or cannot be parsed -fn parse_query_struct_to_parameters( - ty: &Type, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Option> { - // Check if it's a known struct - if let Type::Path(type_path) = ty { - let path = &type_path.path; - if path.segments.is_empty() { - return None; - } - - let segment = path.segments.last().unwrap(); - let ident_str = segment.ident.to_string(); - - // Get type name (handle both simple and qualified paths) - - // Check if it's a known struct - if let Some(struct_def) = struct_definitions.get(&ident_str) - && let Ok(struct_item) = syn::parse_str::(struct_def) - { - let mut parameters = Vec::new(); - - // Extract rename_all attribute from struct - let rename_all = extract_rename_all(&struct_item.attrs); - - if let syn::Fields::Named(fields_named) = &struct_item.fields { - for field in &fields_named.named { - let rust_field_name = field - .ident - .as_ref() - .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string); - - // Check for field-level rename attribute first (takes precedence) - let field_name = extract_field_rename(&field.attrs) - .unwrap_or_else(|| rename_field(&rust_field_name, rename_all.as_deref())); - - let field_type = &field.ty; - - // Check if field is Option - let is_optional = matches!( - field_type, - Type::Path(type_path) - if type_path - .path - .segments - .first() - .is_some_and(|s| s.ident == "Option") - ); - - // Parse field type to schema (inline, not ref) - // For Query parameters, we need inline schemas, not refs - let mut field_schema = parse_type_to_schema_ref_with_schemas( - field_type, - known_schemas, - struct_definitions, - ); - - // Convert ref to inline if needed (Query parameters should not use refs) - // If it's a ref to a known struct, get the struct definition and inline it - if let SchemaRef::Ref(ref_ref) = &field_schema - && let Some(type_name) = - ref_ref.ref_path.strip_prefix("#/components/schemas/") - && let Some(struct_def) = struct_definitions.get(type_name) - && let Ok(nested_struct_item) = - syn::parse_str::(struct_def) - { - // Parse the nested struct to schema (inline) - let nested_schema = parse_struct_to_schema( - &nested_struct_item, - known_schemas, - struct_definitions, - ); - field_schema = SchemaRef::Inline(Box::new(nested_schema)); - } - - let final_schema = convert_to_inline_schema(field_schema, is_optional); - - let required = !is_optional; - - parameters.push(Parameter { - name: field_name, - r#in: ParameterLocation::Query, - description: None, - required: Some(required), - schema: Some(final_schema), - example: None, - }); - } - } - - if !parameters.is_empty() { - return Some(parameters); - } +fn extract_param_name(pat: &Pat) -> Option { + match pat { + Pat::Ident(ident) => Some(ident.ident.to_string()), + Pat::TupleStruct(tuple_struct) if tuple_struct.elems.len() == 1 => { + extract_param_name(&tuple_struct.elems[0]) } + _ => None, } - None } #[cfg(test)] @@ -440,162 +71,52 @@ mod tests { use insta::{assert_debug_snapshot, with_settings}; use rstest::rstest; use vespera_core::route::ParameterLocation; - use vespera_core::schema::{Reference, SchemaType}; use super::*; - fn setup_test_data(func_src: &str) -> (HashSet, HashMap) { + fn setup_test_data( + func_src: &str, + ) -> (HashSet<&'static str>, HashMap<&'static str, &'static str>) { let mut struct_definitions = HashMap::new(); - let mut known_schemas: HashSet = HashSet::new(); + let mut known_schemas = HashSet::new(); if func_src.contains("QueryParams") { - known_schemas.insert("QueryParams".to_string()); + known_schemas.insert("QueryParams"); struct_definitions.insert( - "QueryParams".to_string(), - r" - pub struct QueryParams { - pub page: i32, - pub limit: Option, - } - " - .to_string(), + "QueryParams", + r"pub struct QueryParams { pub page: i32, pub limit: Option }", ); } if func_src.contains("User") { - known_schemas.insert("User".to_string()); - struct_definitions.insert( - "User".to_string(), - r" - pub struct User { - pub id: i32, - pub name: String, - } - " - .to_string(), - ); + known_schemas.insert("User"); + struct_definitions.insert("User", r"pub struct User { pub id: i32, pub name: String }"); } (known_schemas, struct_definitions) } #[rstest] - #[case( - "fn test(params: Path<(String, i32)>) {}", - vec!["user_id".to_string(), "count".to_string()], - vec![vec![ParameterLocation::Path, ParameterLocation::Path]], - "path_tuple" - )] - #[case( - "fn show(Path(id): Path) {}", - vec!["item_id".to_string()], - vec![vec![ParameterLocation::Path]], - "path_single" - )] - #[case( - "fn test(Query(params): Query>) {}", - vec![], - vec![vec![]], - "query_hashmap" - )] - #[case( - "fn test(TypedHeader(user_agent): TypedHeader, count: i32) {}", - vec![], - vec![ - vec![ParameterLocation::Header], - vec![], - ], - "typed_header_and_arg" - )] - #[case( - "fn test(TypedHeader(user_agent): TypedHeader, content_type: Option>, authorization: Option>>) {}", - vec![], - vec![ - vec![ParameterLocation::Header], - vec![ParameterLocation::Header], - vec![ParameterLocation::Header], - ], - "typed_header_multi" - )] - #[case( - "fn test(user_agent: TypedHeader, count: i32) {}", - vec![], - vec![ - vec![ParameterLocation::Header], - vec![], - ], - "header_value_and_arg" - )] - #[case( - "fn test(&self, id: i32) {}", - vec![], - vec![ - vec![], - vec![], - ], - "method_receiver" - )] - #[case( - "fn test(Path((a, b)): Path<(i32, String)>) {}", - vec![], - vec![vec![]], - "path_tuple_destructure" - )] - #[case( - "fn test(params: Query) {}", - vec![], - vec![vec![ParameterLocation::Query, ParameterLocation::Query]], - "query_struct" - )] - #[case( - "fn test(body: Json) {}", - vec![], - vec![vec![]], - "json_body" - )] - #[case( - "fn test(params: Query) {}", - vec![], - vec![vec![]], - "query_unknown" - )] - #[case( - "fn test(params: Query>) {}", - vec![], - vec![vec![]], - "query_map" - )] - #[case( - "fn test(user: Query) {}", - vec![], - vec![vec![ParameterLocation::Query, ParameterLocation::Query]], - "query_user" - )] - #[case( - "fn test(custom: Header) {}", - vec![], - vec![vec![ParameterLocation::Header]], - "header_custom" - )] - #[case( - "fn test(input: Form) {}", - vec![], - vec![vec![]], - "form_body" - )] - #[case( - "fn test(upload: TypedMultipart) {}", - vec![], - vec![vec![]], - "typed_multipart_body" - )] - #[case( - "fn test(multipart: Multipart) {}", - vec![], - vec![vec![]], - "raw_multipart_body" - )] - fn test_parse_function_parameter_cases( + #[case("fn test(params: Path<(String, i32)>) {}", vec!["user_id".to_string(), "count".to_string()], vec![vec![ParameterLocation::Path, ParameterLocation::Path]], "path_tuple")] + #[case("fn show(Path(id): Path) {}", vec!["item_id".to_string()], vec![vec![ParameterLocation::Path]], "path_single")] + #[case("fn test(Query(params): Query>) {}", vec![], vec![vec![]], "query_hashmap")] + #[case("fn test(TypedHeader(user_agent): TypedHeader, count: i32) {}", vec![], vec![vec![ParameterLocation::Header], vec![]], "typed_header_and_arg")] + #[case("fn test(TypedHeader(user_agent): TypedHeader, content_type: Option>, authorization: Option>>) {}", vec![], vec![vec![ParameterLocation::Header], vec![ParameterLocation::Header], vec![ParameterLocation::Header]], "typed_header_multi")] + #[case("fn test(user_agent: TypedHeader, count: i32) {}", vec![], vec![vec![ParameterLocation::Header], vec![]], "header_value_and_arg")] + #[case("fn test(&self, id: i32) {}", vec![], vec![vec![], vec![]], "method_receiver")] + #[case("fn test(Path((a, b)): Path<(i32, String)>) {}", vec![], vec![vec![]], "path_tuple_destructure")] + #[case("fn test(params: Query) {}", vec![], vec![vec![ParameterLocation::Query, ParameterLocation::Query]], "query_struct")] + #[case("fn test(Validated(Query(params)): Validated>) {}", vec![], vec![vec![ParameterLocation::Query, ParameterLocation::Query]], "validated_query_struct")] + #[case("fn test(Validated(Path(id)): Validated>) {}", vec!["item_id".to_string()], vec![vec![ParameterLocation::Path]], "validated_path_single")] + #[case("fn test(body: Json) {}", vec![], vec![vec![]], "json_body")] + #[case("fn test(params: Query) {}", vec![], vec![vec![]], "query_unknown")] + #[case("fn test(params: Query>) {}", vec![], vec![vec![]], "query_map")] + #[case("fn test(user: Query) {}", vec![], vec![vec![ParameterLocation::Query, ParameterLocation::Query]], "query_user")] + #[case("fn test(custom: Header) {}", vec![], vec![vec![ParameterLocation::Header]], "header_custom")] + #[case("fn test(input: Form) {}", vec![], vec![vec![]], "form_body")] + #[case("fn test(upload: TypedMultipart) {}", vec![], vec![vec![]], "typed_multipart_body")] + #[case("fn test(multipart: Multipart) {}", vec![], vec![vec![]], "raw_multipart_body")] + fn parse_function_parameter_cases( #[case] func_src: &str, #[case] path_params: Vec, #[case] expected_locations: Vec>, @@ -603,7 +124,7 @@ mod tests { ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); let (known_schemas, struct_definitions) = setup_test_data(func_src); - let path_param_set: HashSet = path_params.iter().cloned().collect(); + let path_param_set: HashSet<&str> = path_params.iter().map(String::as_str).collect(); let mut parameters = Vec::new(); for (idx, arg) in func.sig.inputs.iter().enumerate() { @@ -634,57 +155,28 @@ mod tests { ); parameters.extend(params.clone()); } - with_settings!({ snapshot_suffix => format!("params_{}", suffix) }, { + with_settings!({ snapshot_path => "snapshots", snapshot_suffix => format!("params_{suffix}") }, { assert_debug_snapshot!(parameters); }); } #[rstest] - #[case( - "fn test(id: Query) {}", - vec![], - )] - #[case( - "fn test(auth: Header) {}", - vec![], - )] - #[case( - "fn test(params: Query>) {}", - vec![], - )] - #[case( - "fn test(params: Query>) {}", - vec![], - )] - #[case( - "fn test(Path([a]): Path<[i32; 1]>) {}", - vec![], - )] - #[case( - "fn test(id: Path) {}", - vec!["user_id".to_string(), "post_id".to_string()], - )] - #[case( - "fn test((x, y): (i32, i32)) {}", - vec![], - )] - fn test_parse_function_parameter_wrong_cases( + #[case("fn test(id: Query) {}", vec![])] + #[case("fn test(auth: Header) {}", vec![])] + #[case("fn test(params: Query>) {}", vec![])] + #[case("fn test(params: Query>) {}", vec![])] + #[case("fn test(Path([a]): Path<[i32; 1]>) {}", vec![])] + #[case("fn test(id: Path) {}", vec!["user_id".to_string(), "post_id".to_string()])] + #[case("fn test((x, y): (i32, i32)) {}", vec![])] + fn parse_function_parameter_wrong_cases( #[case] func_src: &str, #[case] path_params: Vec, ) { let func: syn::ItemFn = syn::parse_str(func_src).unwrap(); - let (known_schemas, struct_definitions) = setup_test_data(func_src); - - // Provide custom types for header/query known schemas/structs - let mut struct_definitions = struct_definitions; - struct_definitions.insert( - "User".to_string(), - "pub struct User { pub id: i32 }".to_string(), - ); - let mut known_schemas = known_schemas; - known_schemas.insert("CustomHeader".to_string()); - - let path_param_set: HashSet = path_params.iter().cloned().collect(); + let (mut known_schemas, mut struct_definitions) = setup_test_data(func_src); + struct_definitions.insert("User", "pub struct User { pub id: i32 }"); + known_schemas.insert("CustomHeader"); + let path_param_set: HashSet<&str> = path_params.iter().map(String::as_str).collect(); for (idx, arg) in func.sig.inputs.iter().enumerate() { let result = parse_function_parameter( @@ -700,585 +192,4 @@ mod tests { ); } } - - #[rstest] - #[case("String", true)] - #[case("i32", true)] - #[case("Vec", true)] - #[case("Option", true)] - #[case("CustomType", false)] - fn test_is_primitive_like_fn(#[case] type_str: &str, #[case] expected: bool) { - let ty: Type = syn::parse_str(type_str).unwrap(); - let result = is_primitive_or_like(&ty); - assert_eq!(result, expected, "type_str={type_str}"); - } - - #[rstest] - #[case("HashMap", true)] - #[case("BTreeMap", true)] - #[case("String", false)] - #[case("Vec", false)] - fn test_is_map_type(#[case] type_str: &str, #[case] expected: bool) { - let ty: Type = syn::parse_str(type_str).unwrap(); - assert_eq!(utils_is_map_type(&ty), expected, "type_str={type_str}"); - } - - #[rstest] - #[case("i32", HashSet::new(), HashMap::new(), true)] // primitive type - #[case( - "User", - HashSet::new(), - { - let mut map = HashMap::new(); - map.insert("User".to_string(), "pub struct User { id: i32 }".to_string()); - map - }, - true - )] // known struct - #[case( - "Product", - { - let mut set = HashSet::new(); - set.insert("Product".to_string()); - set - }, - HashMap::new(), - true - )] // known schema - #[case("Vec", HashSet::new(), HashMap::new(), true)] // Vec with known inner type - #[case("Option", HashSet::new(), HashMap::new(), true)] // Option with known inner type - #[case("UnknownType", HashSet::new(), HashMap::new(), false)] // unknown type - fn test_is_known_type( - #[case] type_str: &str, - #[case] known_schemas: HashSet, - #[case] struct_definitions: HashMap, - #[case] expected: bool, - ) { - let ty: Type = syn::parse_str(type_str).unwrap(); - assert_eq!( - is_known_type(&ty, &known_schemas, &struct_definitions), - expected, - "Type: {type_str}" - ); - } - - #[test] - fn test_parse_query_struct_to_parameters() { - let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashSet::new(); - - // Test with struct that has fields - struct_definitions.insert( - "QueryParams".to_string(), - r#" - #[serde(rename_all = "camelCase")] - pub struct QueryParams { - pub page: i32, - #[serde(rename = "per_page")] - pub limit: Option, - pub search: String, - } - "# - .to_string(), - ); - - let ty: Type = syn::parse_str("QueryParams").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 3); - assert_eq!(params[0].name, "page"); - assert_eq!(params[0].r#in, ParameterLocation::Query); - assert_eq!(params[1].name, "per_page"); - assert_eq!(params[1].r#in, ParameterLocation::Query); - assert_eq!(params[2].name, "search"); - assert_eq!(params[2].r#in, ParameterLocation::Query); - - // Test with struct that has nested struct (ref to inline conversion) - struct_definitions.insert( - "NestedQuery".to_string(), - r" - pub struct NestedQuery { - pub user: User, - } - " - .to_string(), - ); - struct_definitions.insert( - "User".to_string(), - r" - pub struct User { - pub id: i32, - } - " - .to_string(), - ); - known_schemas.insert("User".to_string()); - - let ty: Type = syn::parse_str("NestedQuery").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!(result.is_some()); - - // Test with non-struct type - let ty: Type = syn::parse_str("i32").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!(result.is_none()); - - // Test with unknown struct - let ty: Type = syn::parse_str("UnknownStruct").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!(result.is_none()); - - // Test with struct that has Option fields - struct_definitions.insert( - "OptionalQuery".to_string(), - r" - pub struct OptionalQuery { - pub required: i32, - pub optional: Option, - } - " - .to_string(), - ); - - let ty: Type = syn::parse_str("OptionalQuery").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 2); - assert_eq!(params[0].required, Some(true)); - assert_eq!(params[1].required, Some(false)); - } - - // ======== Tests for uncovered lines ======== - - #[test] - fn test_query_single_non_struct_known_type() { - // Test line 128: Return single Query parameter where T is a known non-primitive type - // This should return a single parameter when Query wraps a known type that's not primitive-like - let mut known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - - // Add a known type that's not a struct - known_schemas.insert("CustomId".to_string()); - - let func: syn::ItemFn = syn::parse_str("fn test(id: Query) {}").unwrap(); - let path_params: Vec = vec![]; - let path_param_set: HashSet = HashSet::new(); - - for arg in &func.sig.inputs { - let result = parse_function_parameter( - arg, - &path_params, - &path_param_set, - &known_schemas, - &struct_definitions, - ); - // Line 128 returns Some(vec![Parameter...]) for single Query parameter - assert!(result.is_some(), "Expected single Query parameter"); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - assert_eq!(params[0].r#in, ParameterLocation::Query); - } - } - - #[test] - fn test_path_param_by_name_match() { - // Test line 159: path param matched by name (non-extractor case) - // When a parameter name matches a path param name directly without Path extractor - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - - let func: syn::ItemFn = syn::parse_str("fn test(user_id: i32) {}").unwrap(); - let path_params = vec!["user_id".to_string()]; - let path_param_set: HashSet = path_params.iter().cloned().collect(); - - for arg in &func.sig.inputs { - let result = parse_function_parameter( - arg, - &path_params, - &path_param_set, - &known_schemas, - &struct_definitions, - ); - // Line 159: path_params.contains(¶m_name) returns true, so it creates a Path parameter - assert!(result.is_some(), "Expected path parameter by name match"); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - assert_eq!(params[0].r#in, ParameterLocation::Path); - assert_eq!(params[0].name, "user_id"); - } - } - - #[test] - fn test_is_known_type_empty_segments() { - // Test line 209: empty path segments returns false - // Create a Type::Path programmatically with empty segments - use syn::punctuated::Punctuated; - - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - - // Create Type::Path with empty segments - let type_path = syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: Punctuated::new(), // Empty segments! - }, - }; - let ty = Type::Path(type_path); - - // Tests: path.segments.is_empty() is true - assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); - } - - #[test] - fn test_is_known_type_non_vec_option_generic() { - // Test line 230: non-Vec/Option generic type (like Result or Box) - // The match at line 224-229 only handles Vec and Option - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - - // Box has angle brackets but is not Vec or Option - let ty: Type = syn::parse_str("Box").unwrap(); - // Line 230: the default case `_ => {}` is hit, returns false - assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); - - // Result also not handled - let ty: Type = syn::parse_str("Result").unwrap(); - assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); - } - - #[test] - fn test_parse_query_struct_empty_path_segments() { - // Test line 245: empty path segments in parse_query_struct_to_parameters - // Create a Type::Path programmatically with empty segments - use syn::punctuated::Punctuated; - - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - - // Create Type::Path with empty segments - let type_path = syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: Punctuated::new(), // Empty segments! - }, - }; - let ty = Type::Path(type_path); - - // Tests: path.segments.is_empty() is true - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - assert!( - result.is_none(), - "Empty path segments should return None (line 245)" - ); - } - - #[test] - fn test_schema_ref_to_inline_conversion_optional() { - // Test line 313: SchemaRef::Ref converted to inline for Optional fields - // This requires a field that: - // 1. Is Option where T is a known schema - // 2. T is NOT in struct_definitions (so ref stays as Ref) - // 3. field_schema is still Ref after the conversion attempt - // - // Note: parse_type_to_schema_ref_with_schemas for Option may create - // an inline schema wrapping the inner ref, not a direct Ref. - // Line 313 is a defensive case that may be hard to hit in practice. - let mut struct_definitions = HashMap::new(); - let known_schemas = HashSet::new(); - - // Use a simple struct with Option to verify the optional handling works - struct_definitions.insert( - "QueryWithOptional".to_string(), - r" - pub struct QueryWithOptional { - pub count: Option, - } - " - .to_string(), - ); - - let ty: Type = syn::parse_str("QueryWithOptional").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - assert_eq!(params[0].required, Some(false)); - match ¶ms[0].schema { - Some(SchemaRef::Inline(schema)) => { - assert_eq!(schema.nullable, Some(true)); - } - _ => panic!("Expected inline schema with nullable"), - } - } - - #[test] - fn test_schema_ref_preserved_for_required_field() { - // Required field with known schema but no struct definition → $ref preserved - let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashSet::new(); - - struct_definitions.insert( - "QueryWithRef".to_string(), - r" - pub struct QueryWithRef { - pub item: RefType, - } - " - .to_string(), - ); - - // RefType is a known schema (will generate SchemaRef::Ref) - // No struct definition, so ref stays as-is (e.g. enum type) - known_schemas.insert("RefType".to_string()); - - let ty: Type = syn::parse_str("QueryWithRef").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - // $ref is preserved for required fields - match ¶ms[0].schema { - Some(SchemaRef::Ref(r)) => { - assert_eq!(r.ref_path, "#/components/schemas/RefType"); - } - _ => panic!("Expected $ref schema for required known type"), - } - } - - #[test] - fn test_schema_ref_converted_to_inline_with_struct_def() { - // Test lines 294-304: Ref IS converted when struct_def exists - let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashSet::new(); - - // Main struct with a field of type NestedType - struct_definitions.insert( - "QueryWithNested".to_string(), - r" - pub struct QueryWithNested { - pub nested: NestedType, - } - " - .to_string(), - ); - - // NestedType is both in known_schemas AND has a struct definition - known_schemas.insert("NestedType".to_string()); - struct_definitions.insert( - "NestedType".to_string(), - r" - pub struct NestedType { - pub value: i32, - } - " - .to_string(), - ); - - let ty: Type = syn::parse_str("QueryWithNested").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - // Lines 294-304: Ref is converted to inline by parsing the nested struct - match ¶ms[0].schema { - Some(SchemaRef::Inline(_)) => { - // Successfully converted - } - _ => panic!("Expected inline schema (converted from Ref via struct_def)"), - } - } - - // Tests for convert_to_inline_schema helper function - #[test] - fn test_convert_to_inline_schema_inline() { - let schema = SchemaRef::Inline(Box::new(Schema::string())); - let result = convert_to_inline_schema(schema, false); - match result { - SchemaRef::Inline(s) => { - assert_eq!(s.schema_type, Some(SchemaType::String)); - assert!(s.nullable.is_none()); - } - SchemaRef::Ref(_) => panic!("Expected Inline"), - } - } - - #[test] - fn test_convert_to_inline_schema_inline_optional() { - let schema = SchemaRef::Inline(Box::new(Schema::string())); - let result = convert_to_inline_schema(schema, true); - match result { - SchemaRef::Inline(s) => { - assert_eq!(s.schema_type, Some(SchemaType::String)); - assert_eq!(s.nullable, Some(true)); - } - SchemaRef::Ref(_) => panic!("Expected Inline"), - } - } - - #[test] - fn test_convert_to_inline_schema_ref_optional_preserves_ref_path() { - let schema = SchemaRef::Ref(Reference { - ref_path: "#/components/schemas/User".to_string(), - }); - let result = convert_to_inline_schema(schema, true); - match result { - SchemaRef::Inline(s) => { - assert_eq!(s.ref_path, Some("#/components/schemas/User".to_string())); - assert_eq!(s.nullable, Some(true)); - assert_eq!(s.schema_type, None); - } - SchemaRef::Ref(_) => panic!("Expected Inline wrapper for optional $ref"), - } - } - - #[test] - fn test_convert_to_inline_schema_ref_required_passes_through() { - use vespera_core::schema::Reference; - let schema = SchemaRef::Ref(Reference::schema("SomeType")); - let result = convert_to_inline_schema(schema, false); - match result { - SchemaRef::Ref(r) => { - assert_eq!(r.ref_path, "#/components/schemas/SomeType"); - } - SchemaRef::Inline(_) => panic!("Expected $ref pass-through for required field"), - } - } - - #[test] - fn test_convert_to_inline_schema_ref_optional_wraps_nullable() { - use vespera_core::schema::Reference; - let schema = SchemaRef::Ref(Reference::schema("SomeType")); - let result = convert_to_inline_schema(schema, true); - match result { - SchemaRef::Inline(s) => { - assert_eq!( - s.ref_path, - Some("#/components/schemas/SomeType".to_string()) - ); - assert_eq!(s.nullable, Some(true)); - } - SchemaRef::Ref(_) => panic!("Expected Inline wrapper for optional $ref"), - } - } - - // ======== Enum query parameter tests ======== - - #[test] - fn test_query_struct_with_enum_field_produces_ref() { - // Enum field in a query struct should produce $ref to the enum schema - let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashSet::new(); - - struct_definitions.insert( - "FilterParams".to_string(), - r" - pub struct FilterParams { - pub status: Status, - pub page: i32, - } - " - .to_string(), - ); - - // Status is a known enum schema (registered via #[derive(Schema)]) - // Its definition is an enum, so ItemStruct parsing will fail → $ref preserved - known_schemas.insert("Status".to_string()); - struct_definitions.insert( - "Status".to_string(), - r" - pub enum Status { - Active, - Inactive, - Pending, - } - " - .to_string(), - ); - - let ty: Type = syn::parse_str("FilterParams").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 2); - - // First param: status → $ref to enum schema - assert_eq!(params[0].name, "status"); - assert_eq!(params[0].r#in, ParameterLocation::Query); - assert_eq!(params[0].required, Some(true)); - match ¶ms[0].schema { - Some(SchemaRef::Ref(r)) => { - assert_eq!(r.ref_path, "#/components/schemas/Status"); - } - _ => panic!( - "Expected $ref for enum query parameter, got: {:?}", - params[0].schema - ), - } - - // Second param: page → inline integer - assert_eq!(params[1].name, "page"); - assert_eq!(params[1].required, Some(true)); - match ¶ms[1].schema { - Some(SchemaRef::Inline(s)) => { - assert_eq!(s.schema_type, Some(SchemaType::Integer)); - } - _ => panic!("Expected inline integer schema"), - } - } - - #[test] - fn test_query_struct_with_optional_enum_field() { - // Option field → nullable $ref - let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashSet::new(); - - struct_definitions.insert( - "FilterParams".to_string(), - r" - pub struct FilterParams { - pub status: Option, - } - " - .to_string(), - ); - - known_schemas.insert("Status".to_string()); - struct_definitions.insert( - "Status".to_string(), - r" - pub enum Status { - Active, - Inactive, - } - " - .to_string(), - ); - - let ty: Type = syn::parse_str("FilterParams").unwrap(); - let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - - assert!(result.is_some()); - let params = result.unwrap(); - assert_eq!(params.len(), 1); - assert_eq!(params[0].name, "status"); - assert_eq!(params[0].required, Some(false)); - - // Option → inline schema with ref_path + nullable - match ¶ms[0].schema { - Some(SchemaRef::Inline(s)) => { - assert_eq!(s.ref_path, Some("#/components/schemas/Status".to_string())); - assert_eq!(s.nullable, Some(true)); - } - _ => panic!("Expected inline schema with ref_path and nullable for Option"), - } - } } diff --git a/crates/vespera_macro/src/parser/parameters/header.rs b/crates/vespera_macro/src/parser/parameters/header.rs new file mode 100644 index 00000000..a789940e --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters/header.rs @@ -0,0 +1,85 @@ +use std::collections::{HashMap, HashSet}; + +use syn::Type; +use vespera_core::{ + route::{Parameter, ParameterLocation}, + schema::{Schema, SchemaRef}, +}; + +use super::shared::is_primitive_or_like; +use crate::parser::schema::parse_type_to_schema_ref_with_schemas; + +pub(super) fn parse_option_typed_header(param_name: &str, ty: &Type) -> Option> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.first()?; + if segment.ident != "Option" { + return None; + } + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(Type::Path(inner_type_path))) = args.args.first() else { + return None; + }; + let inner_segment = inner_type_path.path.segments.last()?; + (inner_segment.ident == "TypedHeader").then(|| vec![typed_header_parameter(param_name, false)]) +} + +pub(super) fn parse_header_extractor( + param_name: &str, + ty: &Type, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, +) -> Option> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + match segment.ident.to_string().as_str() { + "Header" => parse_header(param_name, segment, known_schemas, struct_definitions), + "TypedHeader" => Some(vec![typed_header_parameter(param_name, true)]), + _ => None, + } +} + +fn parse_header( + param_name: &str, + segment: &syn::PathSegment, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, +) -> Option> { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() else { + return None; + }; + if is_primitive_or_like(inner_ty) { + return None; + } + Some(vec![Parameter { + name: param_name.to_string(), + r#in: ParameterLocation::Header, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }]) +} + +fn typed_header_parameter(param_name: &str, required: bool) -> Parameter { + Parameter { + name: param_name.replace('_', "-"), + r#in: ParameterLocation::Header, + description: None, + required: Some(required), + schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), + example: None, + } +} diff --git a/crates/vespera_macro/src/parser/parameters/path.rs b/crates/vespera_macro/src/parser/parameters/path.rs new file mode 100644 index 00000000..d2ecd329 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters/path.rs @@ -0,0 +1,119 @@ +use std::collections::{HashMap, HashSet}; + +use syn::Type; +use vespera_core::route::{Parameter, ParameterLocation}; + +use crate::parser::schema::parse_type_to_schema_ref_with_schemas; + +pub(super) fn parse_path_extractor( + ty: &Type, + path_params: &[String], + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, +) -> Option> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if segment.ident != "Path" { + return None; + } + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() else { + return None; + }; + + if let Type::Tuple(tuple) = inner_ty { + let parameters = tuple + .elems + .iter() + .enumerate() + .filter_map(|(idx, elem_ty)| { + path_params.get(idx).map(|param_name| Parameter { + name: param_name.clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + elem_ty, + known_schemas, + struct_definitions, + )), + example: None, + }) + }) + .collect::>(); + return (!parameters.is_empty()).then_some(parameters); + } + + (path_params.len() == 1).then(|| { + vec![Parameter { + name: path_params[0].clone(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }] + }) +} + +pub(super) fn parse_bare_path_parameter( + param_name: &str, + ty: &Type, + path_param_set: &HashSet<&str>, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, +) -> Option> { + path_param_set.contains(param_name).then(|| { + vec![Parameter { + name: param_name.to_string(), + r#in: ParameterLocation::Path, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + ty, + known_schemas, + struct_definitions, + )), + example: None, + }] + }) +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use vespera_core::route::ParameterLocation; + + use crate::parser::parameters::parse_function_parameter; + + #[test] + fn path_param_by_name_match() { + let func: syn::ItemFn = syn::parse_str("fn test(user_id: i32) {}").unwrap(); + let path_params = vec!["user_id".to_string()]; + let path_param_set: HashSet<&str> = path_params.iter().map(String::as_str).collect(); + + for arg in &func.sig.inputs { + let result = parse_function_parameter( + arg, + &path_params, + &path_param_set, + &HashSet::new(), + &HashMap::new(), + ); + assert!(result.is_some(), "Expected path parameter by name match"); + let params = result.unwrap(); + assert_eq!(params.len(), 1); + assert_eq!(params[0].r#in, ParameterLocation::Path); + assert_eq!(params[0].name, "user_id"); + } + } +} diff --git a/crates/vespera_macro/src/parser/parameters/query.rs b/crates/vespera_macro/src/parser/parameters/query.rs new file mode 100644 index 00000000..b0259a14 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters/query.rs @@ -0,0 +1,383 @@ +use std::collections::{HashMap, HashSet}; + +use syn::Type; +use vespera_core::{ + route::{Parameter, ParameterLocation}, + schema::SchemaRef, +}; + +use super::shared::{convert_to_inline_schema, is_known_type, is_primitive_or_like}; +use crate::{ + parser::schema::{ + extract_default, extract_field_rename, extract_rename_all, parse_struct_to_schema, + parse_type_to_schema_ref_with_schemas, rename_field, + }, + schema_macro::type_utils::{is_map_type as utils_is_map_type, is_option_type}, +}; + +pub(super) fn parse_query_extractor( + param_name: &str, + ty: &Type, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, +) -> Option> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if segment.ident != "Query" { + return None; + } + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() else { + return None; + }; + + if utils_is_map_type(inner_ty) { + return None; + } + if let Some(struct_params) = + parse_query_struct_to_parameters(inner_ty, known_schemas, struct_definitions) + { + return Some(struct_params); + } + if is_primitive_or_like(inner_ty) || !is_known_type(inner_ty, known_schemas, struct_definitions) + { + return None; + } + + Some(vec![Parameter { + name: param_name.to_string(), + r#in: ParameterLocation::Query, + description: None, + required: Some(true), + schema: Some(parse_type_to_schema_ref_with_schemas( + inner_ty, + known_schemas, + struct_definitions, + )), + example: None, + }]) +} + +pub(super) fn parse_query_struct_to_parameters( + ty: &Type, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, +) -> Option> { + let Type::Path(type_path) = ty else { + return None; + }; + let path = &type_path.path; + if path.segments.is_empty() { + return None; + } + + let ident_str = path.segments.last().unwrap().ident.to_string(); + if let Some(struct_def) = struct_definitions.get(ident_str.as_str()) + && let Ok(struct_item) = syn::parse_str::(struct_def) + { + let mut parameters = Vec::new(); + let rename_all = extract_rename_all(&struct_item.attrs); + + if let syn::Fields::Named(fields_named) = &struct_item.fields { + for field in &fields_named.named { + let rust_field_name = field + .ident + .as_ref() + .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string); + let field_name = extract_field_rename(&field.attrs) + .unwrap_or_else(|| rename_field(&rust_field_name, rename_all.as_deref())); + let field_type = &field.ty; + let is_optional = is_option_type(field_type); + // #[serde(default)] fields are optional in request inputs even + // when the Rust type is non-Option (B4: request optional). + let has_default = extract_default(&field.attrs).is_some(); + let mut field_schema = parse_type_to_schema_ref_with_schemas( + field_type, + known_schemas, + struct_definitions, + ); + + if let SchemaRef::Ref(ref_ref) = &field_schema + && let Some(type_name) = ref_ref.ref_path.strip_prefix("#/components/schemas/") + && let Some(struct_def) = struct_definitions.get(type_name) + && let Ok(nested_struct_item) = syn::parse_str::(struct_def) + { + let nested_schema = parse_struct_to_schema( + &nested_struct_item, + known_schemas, + struct_definitions, + ); + field_schema = SchemaRef::Inline(Box::new(nested_schema)); + } + + parameters.push(Parameter { + name: field_name, + r#in: ParameterLocation::Query, + description: None, + required: Some(!(is_optional || has_default)), + schema: Some(convert_to_inline_schema(field_schema, is_optional)), + example: None, + }); + } + } + + if !parameters.is_empty() { + return Some(parameters); + } + } + None +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use syn::Type; + use vespera_core::{ + route::ParameterLocation, + schema::{SchemaRef, SchemaType}, + }; + + use super::*; + use crate::parser::parameters::parse_function_parameter; + + #[test] + fn parse_query_struct_to_parameters_cases() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashSet::new(); + + struct_definitions.insert( + "QueryParams", + r#"#[serde(rename_all = "camelCase")] + pub struct QueryParams { + pub page: i32, + #[serde(rename = "per_page")] + pub limit: Option, + pub search: String, + }"#, + ); + + let ty: Type = syn::parse_str("QueryParams").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("query params should parse"); + assert_eq!(params.len(), 3); + assert_eq!(params[0].name, "page"); + assert_eq!(params[0].r#in, ParameterLocation::Query); + assert_eq!(params[1].name, "per_page"); + assert_eq!(params[1].r#in, ParameterLocation::Query); + assert_eq!(params[2].name, "search"); + assert_eq!(params[2].r#in, ParameterLocation::Query); + + struct_definitions.insert("NestedQuery", r"pub struct NestedQuery { pub user: User }"); + struct_definitions.insert("User", r"pub struct User { pub id: i32 }"); + known_schemas.insert("User"); + + let ty: Type = syn::parse_str("NestedQuery").unwrap(); + assert!( + parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions).is_some() + ); + let ty: Type = syn::parse_str("i32").unwrap(); + assert!( + parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions).is_none() + ); + let ty: Type = syn::parse_str("UnknownStruct").unwrap(); + assert!( + parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions).is_none() + ); + + struct_definitions.insert( + "OptionalQuery", + r"pub struct OptionalQuery { pub required: i32, pub optional: Option }", + ); + let ty: Type = syn::parse_str("OptionalQuery").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("optional query should parse"); + assert_eq!(params.len(), 2); + assert_eq!(params[0].required, Some(true)); + assert_eq!(params[1].required, Some(false)); + } + + #[test] + fn query_single_non_struct_known_type() { + let mut known_schemas = HashSet::new(); + known_schemas.insert("CustomId"); + let func: syn::ItemFn = syn::parse_str("fn test(id: Query) {}").unwrap(); + let path_params: Vec = vec![]; + let path_param_set: HashSet<&str> = HashSet::new(); + + for arg in &func.sig.inputs { + let result = parse_function_parameter( + arg, + &path_params, + &path_param_set, + &known_schemas, + &HashMap::<&str, &str>::new(), + ); + assert!(result.is_some(), "Expected single Query parameter"); + let params = result.unwrap(); + assert_eq!(params.len(), 1); + assert_eq!(params[0].r#in, ParameterLocation::Query); + } + } + + #[test] + fn parse_query_struct_empty_path_segments() { + use syn::punctuated::Punctuated; + + let ty = Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: Punctuated::new(), + }, + }); + assert!( + parse_query_struct_to_parameters( + &ty, + &HashSet::<&str>::new(), + &HashMap::<&str, &str>::new() + ) + .is_none() + ); + } + + #[test] + fn schema_ref_to_inline_conversion_optional() { + let mut struct_definitions = HashMap::new(); + struct_definitions.insert( + "QueryWithOptional", + r"pub struct QueryWithOptional { pub count: Option }", + ); + + let ty: Type = syn::parse_str("QueryWithOptional").unwrap(); + let params = + parse_query_struct_to_parameters(&ty, &HashSet::<&str>::new(), &struct_definitions) + .expect("query should parse"); + assert_eq!(params.len(), 1); + assert_eq!(params[0].required, Some(false)); + match ¶ms[0].schema { + Some(SchemaRef::Inline(schema)) => assert_eq!(schema.nullable, Some(true)), + _ => panic!("Expected inline schema with nullable"), + } + } + + #[test] + fn schema_ref_preserved_for_required_field() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashSet::new(); + struct_definitions.insert( + "QueryWithRef", + r"pub struct QueryWithRef { pub item: RefType }", + ); + known_schemas.insert("RefType"); + + let ty: Type = syn::parse_str("QueryWithRef").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("query should parse"); + match ¶ms[0].schema { + Some(SchemaRef::Ref(r)) => assert_eq!(r.ref_path, "#/components/schemas/RefType"), + _ => panic!("Expected $ref schema for required known type"), + } + } + + #[test] + fn schema_ref_converted_to_inline_with_struct_def() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashSet::new(); + struct_definitions.insert( + "QueryWithNested", + r"pub struct QueryWithNested { pub nested: NestedType }", + ); + known_schemas.insert("NestedType"); + struct_definitions.insert("NestedType", r"pub struct NestedType { pub value: i32 }"); + + let ty: Type = syn::parse_str("QueryWithNested").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("query should parse"); + assert!(matches!(params[0].schema, Some(SchemaRef::Inline(_)))); + } + + #[test] + fn query_struct_with_enum_field_produces_ref() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashSet::new(); + struct_definitions.insert( + "FilterParams", + r"pub struct FilterParams { pub status: Status, pub page: i32 }", + ); + known_schemas.insert("Status"); + struct_definitions.insert("Status", r"pub enum Status { Active, Inactive, Pending }"); + + let ty: Type = syn::parse_str("FilterParams").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("query should parse"); + assert_eq!(params.len(), 2); + assert_eq!(params[0].name, "status"); + assert_eq!(params[0].r#in, ParameterLocation::Query); + assert_eq!(params[0].required, Some(true)); + match ¶ms[0].schema { + Some(SchemaRef::Ref(r)) => assert_eq!(r.ref_path, "#/components/schemas/Status"), + _ => panic!( + "Expected $ref for enum query parameter, got: {:?}", + params[0].schema + ), + } + assert_eq!(params[1].name, "page"); + match ¶ms[1].schema { + Some(SchemaRef::Inline(s)) => assert_eq!(s.schema_type, Some(SchemaType::Integer)), + _ => panic!("Expected inline integer schema"), + } + } + + #[test] + fn query_struct_serde_default_field_is_optional() { + // B4: #[serde(default)] makes a non-Option query field optional in + // request inputs (it can be omitted; the server fills the default). + let mut struct_definitions = HashMap::new(); + struct_definitions.insert( + "Paged", + r"pub struct Paged { + #[serde(default)] + pub page: i32, + pub q: String, + }", + ); + let ty: Type = syn::parse_str("Paged").unwrap(); + let params = + parse_query_struct_to_parameters(&ty, &HashSet::<&str>::new(), &struct_definitions) + .expect("query should parse"); + assert_eq!(params.len(), 2); + assert_eq!(params[0].name, "page"); + assert_eq!(params[0].required, Some(false)); // default → optional + assert_eq!(params[1].name, "q"); + assert_eq!(params[1].required, Some(true)); + } + + #[test] + fn query_struct_with_optional_enum_field() { + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashSet::new(); + struct_definitions.insert( + "FilterParams", + r"pub struct FilterParams { pub status: Option }", + ); + known_schemas.insert("Status"); + struct_definitions.insert("Status", r"pub enum Status { Active, Inactive }"); + + let ty: Type = syn::parse_str("FilterParams").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions) + .expect("query should parse"); + assert_eq!(params[0].required, Some(false)); + match ¶ms[0].schema { + Some(SchemaRef::Inline(s)) => { + assert_eq!(s.ref_path, Some("#/components/schemas/Status".to_string())); + assert_eq!(s.nullable, Some(true)); + } + _ => panic!("Expected inline schema with ref_path and nullable for Option"), + } + } +} diff --git a/crates/vespera_macro/src/parser/parameters/shared.rs b/crates/vespera_macro/src/parser/parameters/shared.rs new file mode 100644 index 00000000..8e02dd34 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters/shared.rs @@ -0,0 +1,212 @@ +use std::collections::{HashMap, HashSet}; + +use syn::Type; +use vespera_core::schema::{Schema, SchemaRef}; + +use crate::{ + parser::schema::is_primitive_type, + schema_macro::type_utils::is_primitive_like as utils_is_primitive_like, +}; + +pub(super) fn is_primitive_or_like(ty: &Type) -> bool { + is_primitive_type(ty) || utils_is_primitive_like(ty) +} + +pub(super) fn convert_to_inline_schema(field_schema: SchemaRef, is_optional: bool) -> SchemaRef { + match field_schema { + SchemaRef::Inline(mut schema) => { + if is_optional { + schema.nullable = Some(true); + } + SchemaRef::Inline(schema) + } + SchemaRef::Ref(r) if is_optional => SchemaRef::Inline(Box::new(Schema { + ref_path: Some(r.ref_path), + schema_type: None, + nullable: Some(true), + ..Default::default() + })), + SchemaRef::Ref(r) => SchemaRef::Ref(r), + } +} + +pub(super) fn is_known_type( + ty: &Type, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, +) -> bool { + if is_primitive_type(ty) { + return true; + } + + if let Type::Path(type_path) = ty { + let path = &type_path.path; + if path.segments.is_empty() { + return false; + } + + let segment = path.segments.last().unwrap(); + let ident_str = segment.ident.to_string(); + if struct_definitions.contains_key(ident_str.as_str()) + || known_schemas.contains(ident_str.as_str()) + { + return true; + } + + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + match ident_str.as_str() { + "Vec" | "HashSet" | "BTreeSet" | "Option" => { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + return is_known_type(inner_ty, known_schemas, struct_definitions); + } + } + _ => {} + } + } + } + + false +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use rstest::rstest; + use syn::Type; + use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType}; + + use super::*; + use crate::schema_macro::type_utils::is_map_type as utils_is_map_type; + + #[rstest] + #[case("String", true)] + #[case("i32", true)] + #[case("Vec", true)] + #[case("Option", true)] + #[case("CustomType", false)] + fn primitive_like(#[case] type_str: &str, #[case] expected: bool) { + let ty: Type = syn::parse_str(type_str).unwrap(); + assert_eq!(is_primitive_or_like(&ty), expected); + } + + #[rstest] + #[case("HashMap", true)] + #[case("BTreeMap", true)] + #[case("String", false)] + #[case("Vec", false)] + fn map_type(#[case] type_str: &str, #[case] expected: bool) { + let ty: Type = syn::parse_str(type_str).unwrap(); + assert_eq!(utils_is_map_type(&ty), expected); + } + + #[rstest] + #[case("i32", HashSet::new(), HashMap::new(), true)] + #[case("User", HashSet::new(), { + let mut map = HashMap::new(); + map.insert("User", "pub struct User { id: i32 }"); + map + }, true)] + #[case("Product", { + let mut set = HashSet::new(); + set.insert("Product"); + set + }, HashMap::new(), true)] + #[case("Vec", HashSet::new(), HashMap::new(), true)] + #[case("Option", HashSet::new(), HashMap::new(), true)] + #[case("UnknownType", HashSet::new(), HashMap::new(), false)] + fn known_type( + #[case] type_str: &str, + #[case] known_schemas: HashSet<&str>, + #[case] struct_definitions: HashMap<&str, &str>, + #[case] expected: bool, + ) { + let ty: Type = syn::parse_str(type_str).unwrap(); + assert_eq!( + is_known_type(&ty, &known_schemas, &struct_definitions), + expected + ); + } + + #[test] + fn known_type_empty_segments() { + use syn::punctuated::Punctuated; + + let ty = Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: Punctuated::new(), + }, + }); + assert!(!is_known_type(&ty, &HashSet::new(), &HashMap::new())); + } + + #[test] + fn known_type_non_vec_option_generic() { + let known_schemas = HashSet::new(); + let struct_definitions = HashMap::new(); + let ty: Type = syn::parse_str("Box").unwrap(); + assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); + let ty: Type = syn::parse_str("Result").unwrap(); + assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); + } + + #[test] + fn convert_to_inline_schema_inline() { + let schema = SchemaRef::Inline(Box::new(Schema::string())); + let result = convert_to_inline_schema(schema, false); + let SchemaRef::Inline(s) = result else { + panic!("Expected Inline") + }; + assert_eq!(s.schema_type, Some(SchemaType::String)); + assert!(s.nullable.is_none()); + } + + #[test] + fn convert_to_inline_schema_inline_optional() { + let schema = SchemaRef::Inline(Box::new(Schema::string())); + let result = convert_to_inline_schema(schema, true); + let SchemaRef::Inline(s) = result else { + panic!("Expected Inline") + }; + assert_eq!(s.schema_type, Some(SchemaType::String)); + assert_eq!(s.nullable, Some(true)); + } + + #[test] + fn convert_to_inline_schema_ref_optional_preserves_ref_path() { + let schema = SchemaRef::Ref(Reference { + ref_path: "#/components/schemas/User".to_string(), + }); + let result = convert_to_inline_schema(schema, true); + let SchemaRef::Inline(s) = result else { + panic!("Expected Inline wrapper") + }; + assert_eq!(s.ref_path, Some("#/components/schemas/User".to_string())); + assert_eq!(s.nullable, Some(true)); + assert_eq!(s.schema_type, None); + } + + #[test] + fn convert_to_inline_schema_ref_required_passes_through() { + let schema = SchemaRef::Ref(Reference::schema("SomeType")); + let result = convert_to_inline_schema(schema, false); + let SchemaRef::Ref(r) = result else { + panic!("Expected $ref") + }; + assert_eq!(r.ref_path, "#/components/schemas/SomeType"); + } + + #[test] + fn convert_to_inline_schema_ref_optional_wraps_nullable() { + let schema = SchemaRef::Ref(Reference::schema("User")); + let result = convert_to_inline_schema(schema, true); + let SchemaRef::Inline(s) = result else { + panic!("Expected Inline wrapper") + }; + assert_eq!(s.ref_path, Some("#/components/schemas/User".to_string())); + assert_eq!(s.nullable, Some(true)); + assert_eq!(s.schema_type, None); + } +} diff --git a/crates/vespera_macro/src/parser/path.rs b/crates/vespera_macro/src/parser/path.rs index 3d563964..96443e5f 100644 --- a/crates/vespera_macro/src/parser/path.rs +++ b/crates/vespera_macro/src/parser/path.rs @@ -1,9 +1,9 @@ /// Extract path parameters from a path string pub fn extract_path_parameters(path: &str) -> Vec { let mut params = Vec::new(); - let segments: Vec<&str> = path.split('/').collect(); - for segment in segments { + // Iterate the split lazily — no intermediate `Vec<&str>` allocation. + for segment in path.split('/') { if segment.starts_with('{') && segment.ends_with('}') { let param = segment.trim_start_matches('{').trim_end_matches('}'); params.push(param.to_string()); diff --git a/crates/vespera_macro/src/parser/request_body.rs b/crates/vespera_macro/src/parser/request_body.rs index 07c5be86..bf10d01e 100644 --- a/crates/vespera_macro/src/parser/request_body.rs +++ b/crates/vespera_macro/src/parser/request_body.rs @@ -4,7 +4,7 @@ use syn::{FnArg, PatType, Type}; use vespera_core::route::{MediaType, RequestBody}; use vespera_core::schema::{Schema, SchemaRef, SchemaType}; -use super::schema::parse_type_to_schema_ref_with_schemas; +use super::{extractors::unwrap_validated_type, schema::parse_type_to_schema_ref_with_schemas}; fn is_string_like(ty: &Type) -> bool { match ty { @@ -22,13 +22,14 @@ fn is_string_like(ty: &Type) -> bool { #[allow(clippy::too_many_lines)] pub fn parse_request_body( arg: &FnArg, - known_schemas: &HashSet, - struct_definitions: &std::collections::HashMap, + known_schemas: &HashSet<&str>, + struct_definitions: &std::collections::HashMap<&str, &str>, ) -> Option { match arg { FnArg::Receiver(_) => None, FnArg::Typed(PatType { ty, .. }) => { - if let Type::Path(type_path) = ty.as_ref() { + let ty = unwrap_validated_type(ty.as_ref()); + if let Type::Path(type_path) = ty { let path = &type_path.path; // Check the last segment (handles both Json and vespera::axum::Json) @@ -134,7 +135,7 @@ pub fn parse_request_body( } } - if is_string_like(ty.as_ref()) { + if is_string_like(ty) { let schema = parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions); let mut content = BTreeMap::new(); @@ -182,6 +183,16 @@ mod tests { #[rstest] #[case::json("fn test(Json(payload): Json) {}", true, "json")] + #[case::validated_json( + "fn test(Validated(Json(payload)): Validated>) {}", + true, + "validated_json" + )] + #[case::validated_form( + "fn test(Validated(Form(input)): Validated>) {}", + true, + "validated_form" + )] #[case::form("fn test(Form(input): Form) {}", true, "form")] #[case::string("fn test(just_string: String) {}", true, "string")] #[case::str("fn test(just_str: &str) {}", true, "str")] diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs index c63e3cab..b871ba15 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -9,18 +9,16 @@ use crate::parser::is_keyword_type::{KeywordType, is_keyword_type, is_keyword_ty /// Unwrap Json to get T /// Handles both Json and `vespera::axum::Json` by checking the last segment fn unwrap_json(ty: &Type) -> &Type { - if let Type::Path(type_path) = ty { - let path = &type_path.path; - if !path.segments.is_empty() { - // Check the last segment (handles both Json and vespera::axum::Json) - let segment = path.segments.last().unwrap(); - if segment.ident == "Json" - && let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - { - return inner_ty; - } - } + // Check the last segment (handles both `Json` and + // `vespera::axum::Json`). `segments.last()` is `None` for an empty + // path, so the let-chain replaces the prior `is_empty()` guard + `unwrap()`. + if let Type::Path(type_path) = ty + && let Some(segment) = type_path.path.segments.last() + && segment.ident == "Json" + && let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + return inner_ty; } ty } @@ -90,7 +88,7 @@ fn is_non_body_type(ty: &Type) -> bool { /// Non-body types (`StatusCode`, `HeaderMap`, `CookieJar`) are filtered out. /// The last remaining element is treated as the response body. /// Any presence of `HeaderMap` in the tuple marks headers as present. -fn extract_ok_payload_and_headers(ok_ty: &Type) -> (Type, Option>) { +fn extract_ok_payload_and_headers(ok_ty: &Type) -> (Type, Option>) { if let Type::Tuple(tuple) = ok_ty { // Find the body type: last element that is NOT a non-body type let payload_ty = tuple @@ -106,7 +104,7 @@ fn extract_ok_payload_and_headers(ok_ty: &Type) -> (Type, Option (Type, Option bool { + match ty { + Type::Reference(reference) => is_string_like(&reference.elem), + Type::Path(type_path) => type_path + .path + .segments + .last() + .is_some_and(|seg| seg.ident == "String" || seg.ident == "str"), + _ => false, + } +} + +/// The response `Content-Type` for a body of the given original (pre-`unwrap_json`) +/// type: bare strings are `text/plain`; `Json` and structs are +/// `application/json`. +fn body_content_type(ty: &Type) -> &'static str { + if is_string_like(ty) { + "text/plain" + } else { + "application/json" + } +} + +/// The last non-metadata element of a tuple body (`(StatusCode, T)` → `T`), or +/// `ty` itself when it is not a tuple. +fn tuple_body(ty: &Type) -> &Type { + if let Type::Tuple(tuple) = ty { + tuple + .elems + .iter() + .rev() + .find(|elem| !is_non_body_type(elem)) + .unwrap_or(ty) + } else { + ty + } +} + +/// The original `(Ok, Err)` argument types of a `Result` return type +/// (no `Json` unwrapping) — used for content-type determination only. +fn result_args(ty: &Type) -> Option<(&Type, &Type)> { + let type_path = match unwrap_json(ty) { + Type::Path(type_path) => type_path, + Type::Reference(type_ref) => match type_ref.elem.as_ref() { + Type::Path(type_path) => type_path, + _ => return None, + }, + _ => return None, + }; + if is_keyword_type_by_type_path(type_path, &KeywordType::Result) + && let Some(segment) = type_path.path.segments.last() + && let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && args.args.len() >= 2 + && let (Some(syn::GenericArgument::Type(ok)), Some(syn::GenericArgument::Type(err))) = + (args.args.first(), args.args.get(1)) + { + Some((ok, err)) + } else { + None + } +} + +/// `(200-body content-type, error-body content-type)` for a handler return type. +/// Bare `String`/`&str` bodies map to `text/plain` (what axum actually sends); +/// `Json` and structs map to `application/json`. +fn response_content_types(ty: &Type) -> (&'static str, &'static str) { + if let Some((ok, err)) = result_args(ty) { + ( + body_content_type(tuple_body(ok)), + body_content_type(tuple_body(err)), + ) + } else { + (body_content_type(tuple_body(ty)), "application/json") + } +} + +fn content_for_type( + ty: &Type, + content_type: &str, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, +) -> Option> { + if is_keyword_type(ty, &KeywordType::StatusCode) { + return None; + } + + let schema = parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions); + let mut content = BTreeMap::new(); + content.insert( + content_type.to_string(), + MediaType { + schema: Some(schema), + example: None, + examples: None, + }, + ); + Some(content) +} + +fn successful_response( + content: Option>, + headers: Option>, +) -> Response { + Response { + description: "Successful response".to_string(), + headers, + content, + } +} + +fn error_response(content: Option>) -> Response { + Response { + description: "Error response".to_string(), + headers: None, + content, + } +} + +fn insert_result_responses( + responses: &mut BTreeMap, + ok_ty: &Type, + err_ty: &Type, + ok_content_type: &str, + err_content_type: &str, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, +) { + let (ok_payload_ty, ok_headers) = extract_ok_payload_and_headers(ok_ty); + let ok_content = content_for_type( + &ok_payload_ty, + ok_content_type, + known_schemas, + struct_definitions, + ); + responses.insert( + "200".to_string(), + successful_response(ok_content, ok_headers), + ); + + if let Some((status_code, error_type)) = extract_status_code_tuple(err_ty) { + let err_content = content_for_type( + &error_type, + err_content_type, + known_schemas, + struct_definitions, + ); + responses.insert(status_code.to_string(), error_response(err_content)); + } else { + let err_ty_unwrapped = unwrap_json(err_ty); + let err_content = content_for_type( + err_ty_unwrapped, + err_content_type, + known_schemas, + struct_definitions, + ); + responses.insert("400".to_string(), error_response(err_content)); + } +} + +fn insert_plain_response( + responses: &mut BTreeMap, + ty: &Type, + content_type: &str, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, +) { + let unwrapped_ty = unwrap_json(ty); + let content = content_for_type( + unwrapped_ty, + content_type, + known_schemas, + struct_definitions, + ); + responses.insert("200".to_string(), successful_response(content, None)); +} + /// Analyze return type and convert to Responses map -#[allow(clippy::too_many_lines)] pub fn parse_return_type( return_type: &ReturnType, - known_schemas: &HashSet, - struct_definitions: &HashMap, + known_schemas: &HashSet<&str>, + struct_definitions: &HashMap<&str, &str>, ) -> BTreeMap { let mut responses = BTreeMap::new(); @@ -139,129 +314,24 @@ pub fn parse_return_type( ); } ReturnType::Type(_, ty) => { - // Check if it's a Result + let (ok_content_type, err_content_type) = response_content_types(ty); if let Some((ok_ty, err_ty)) = extract_result_types(ty) { - // Handle success response (200) - let (ok_payload_ty, ok_headers) = extract_ok_payload_and_headers(&ok_ty); - - // StatusCode alone means no response body — just the HTTP status code - let ok_content = if is_keyword_type(&ok_payload_ty, &KeywordType::StatusCode) { - None - } else { - let ok_schema = parse_type_to_schema_ref_with_schemas( - &ok_payload_ty, - known_schemas, - struct_definitions, - ); - let mut content = BTreeMap::new(); - content.insert( - "application/json".to_string(), - MediaType { - schema: Some(ok_schema), - example: None, - examples: None, - }, - ); - Some(content) - }; - - responses.insert( - "200".to_string(), - Response { - description: "Successful response".to_string(), - headers: ok_headers, - content: ok_content, - }, + insert_result_responses( + &mut responses, + &ok_ty, + &err_ty, + ok_content_type, + err_content_type, + known_schemas, + struct_definitions, ); - - // Handle error response - // Check if error is (StatusCode, E) tuple - if let Some((status_code, error_type)) = extract_status_code_tuple(&err_ty) { - // Use the status code from the tuple - let err_schema = parse_type_to_schema_ref_with_schemas( - &error_type, - known_schemas, - struct_definitions, - ); - let mut err_content = BTreeMap::new(); - err_content.insert( - "application/json".to_string(), - MediaType { - schema: Some(err_schema), - example: None, - examples: None, - }, - ); - - responses.insert( - status_code.to_string(), - Response { - description: "Error response".to_string(), - headers: None, - content: Some(err_content), - }, - ); - } else { - // Regular error type - use default 400 - // Unwrap Json if present - let err_ty_unwrapped = unwrap_json(&err_ty); - let err_schema = parse_type_to_schema_ref_with_schemas( - err_ty_unwrapped, - known_schemas, - struct_definitions, - ); - let mut err_content = BTreeMap::new(); - err_content.insert( - "application/json".to_string(), - MediaType { - schema: Some(err_schema), - example: None, - examples: None, - }, - ); - - responses.insert( - "400".to_string(), - Response { - description: "Error response".to_string(), - headers: None, - content: Some(err_content), - }, - ); - } } else { - // Not a Result type - regular response - // Unwrap Json if present - let unwrapped_ty = unwrap_json(ty); - - // StatusCode alone means no response body - let content = if is_keyword_type(unwrapped_ty, &KeywordType::StatusCode) { - None - } else { - let schema = parse_type_to_schema_ref_with_schemas( - unwrapped_ty, - known_schemas, - struct_definitions, - ); - let mut c = BTreeMap::new(); - c.insert( - "application/json".to_string(), - MediaType { - schema: Some(schema), - example: None, - examples: None, - }, - ); - Some(c) - }; - - responses.insert( - "200".to_string(), - Response { - description: "Successful response".to_string(), - headers: None, - content, - }, + insert_plain_response( + &mut responses, + ty, + ok_content_type, + known_schemas, + struct_definitions, ); } } @@ -271,536 +341,4 @@ pub fn parse_return_type( } #[cfg(test)] -mod tests { - use std::collections::HashMap; - - use rstest::rstest; - use vespera_core::schema::{SchemaRef, SchemaType}; - - use super::*; - - #[derive(Debug)] - struct ExpectedSchema { - schema_type: SchemaType, - nullable: bool, - items_schema_type: Option, - } - - #[derive(Debug)] - struct ExpectedResponse { - status: &'static str, - schema: ExpectedSchema, - } - - fn parse_return_type_str(return_type_str: &str) -> syn::ReturnType { - if return_type_str.is_empty() { - syn::ReturnType::Default - } else { - let full_signature = format!("fn test() {return_type_str}"); - syn::parse_str::(&full_signature) - .expect("Failed to parse return type") - .output - } - } - - fn assert_schema_matches(schema_ref: &SchemaRef, expected: &ExpectedSchema) { - match schema_ref { - SchemaRef::Inline(schema) => { - assert_eq!(schema.schema_type, Some(expected.schema_type)); - assert_eq!(schema.nullable.unwrap_or(false), expected.nullable); - if let Some(item_ty) = &expected.items_schema_type { - let items = schema - .items - .as_ref() - .expect("items should be present for array"); - match items.as_ref() { - SchemaRef::Inline(item_schema) => { - assert_eq!(item_schema.schema_type, Some(*item_ty)); - } - SchemaRef::Ref(_) => panic!("expected inline schema for array items"), - } - } - } - SchemaRef::Ref(_) => panic!("expected inline schema"), - } - } - - #[rstest] - #[case("", None, None, None)] - #[case( - "-> String", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), - None, - None - )] - #[case( - "-> &str", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), - None, - None - )] - #[case( - "-> i32", - Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), - None, - None - )] - #[case( - "-> bool", - Some(ExpectedSchema { schema_type: SchemaType::Boolean, nullable: false, items_schema_type: None }), - None, - None - )] - #[case( - "-> Vec", - Some(ExpectedSchema { schema_type: SchemaType::Array, nullable: false, items_schema_type: Some(SchemaType::String) }), - None, - None - )] - #[case( - "-> Option", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: true, items_schema_type: None }), - None, - None - )] - #[case( - "-> Result", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), - None - )] - #[case( - "-> Result", - Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), - None - )] - #[case( - "-> Result, String>", - Some(ExpectedSchema { schema_type: SchemaType::Object, nullable: false, items_schema_type: None }), - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), - None - )] - #[case( - "-> Result<&str, String>", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), - None - )] - #[case( - "-> Result", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), - None - )] - #[case( - "-> Result)>", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), - None - )] - #[case( - "-> Result<(HeaderMap, Json), String>", - Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), - Some(true) - )] - #[case( - "-> Result)>", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None } }), - None - )] - // StatusCode as the sole Ok response type → no content (empty body) - #[case( - "-> Result", - None, - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), - None - )] - // CookieJar in Ok tuple → body is Json, CookieJar filtered out - #[case( - "-> Result<(CookieJar, Json), (StatusCode, String)>", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), - None - )] - // CookieJar + StatusCode in Ok tuple → body is last non-metadata element - #[case( - "-> Result<(StatusCode, CookieJar, Json), String>", - Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), - Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), - None - )] - // Non-Result: StatusCode alone → no content (covers line 155) - #[case("-> StatusCode", None, None, None)] - // Non-Result: Json wrapper → unwraps to T - #[case( - "-> Json", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), - None, - None - )] - // Non-Result: Json wrapper → unwraps to integer - #[case( - "-> Json", - Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), - None, - None - )] - // Non-Result: f64 → number type - #[case( - "-> f64", - Some(ExpectedSchema { schema_type: SchemaType::Number, nullable: false, items_schema_type: None }), - None, - None - )] - // Non-Result: qualified axum::Json → unwraps to String - #[case( - "-> axum::Json", - Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), - None, - None - )] - fn test_parse_return_type( - #[case] return_type_str: &str, - #[case] ok_expectation: Option, - #[case] err_expectation: Option, - #[case] ok_headers_expected: Option, - ) { - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - let return_type = parse_return_type_str(return_type_str); - - let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); - - // Validate success response - let ok_response = responses.get("200").expect("200 response should exist"); - assert_eq!(ok_response.description, "Successful response"); - match &ok_expectation { - None => { - assert!(ok_response.content.is_none()); - } - Some(expected_schema) => { - let content = ok_response - .content - .as_ref() - .expect("ok content should exist"); - let media_type = content - .get("application/json") - .expect("ok media type should exist"); - let schema_ref = media_type.schema.as_ref().expect("ok schema should exist"); - assert_schema_matches(schema_ref, expected_schema); - } - } - if let Some(expect_headers) = ok_headers_expected { - assert_eq!(ok_response.headers.is_some(), expect_headers); - } - - // Validate error response (if any) - match &err_expectation { - None => assert_eq!(responses.len(), 1), - Some(err) => { - assert_eq!(responses.len(), 2); - let err_response = responses - .get(err.status) - .expect("error response should exist"); - assert_eq!(err_response.description, "Error response"); - let content = err_response - .content - .as_ref() - .expect("error content should exist"); - let media_type = content - .get("application/json") - .expect("error media type should exist"); - let schema_ref = media_type - .schema - .as_ref() - .expect("error schema should exist"); - assert_schema_matches(schema_ref, &err.schema); - } - } - } - - // ======== Tests for uncovered lines ======== - - #[test] - fn test_extract_result_types_non_path_non_ref() { - // Test line 43: type that's neither Path nor Reference returns None - // Tuple type is neither Path nor Reference - let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); - let result = extract_result_types(&ty); - assert!(result.is_none()); - - // Array type - let ty: syn::Type = syn::parse_str("[i32; 3]").unwrap(); - let result = extract_result_types(&ty); - assert!(result.is_none()); - - // Slice type - let ty: syn::Type = syn::parse_str("[i32]").unwrap(); - let result = extract_result_types(&ty); - assert!(result.is_none()); - } - - #[test] - fn test_extract_result_types_ref_to_non_path() { - // Test line 43: &(Tuple) - Reference to non-Path type - // Tests: else branch - let ty: syn::Type = syn::parse_str("&(i32, String)").unwrap(); - let result = extract_result_types(&ty); - // The Reference's elem is a Tuple, not a Path, so line 39 condition fails - // Falls through to line 43 - assert!(result.is_none()); - } - - #[test] - fn test_extract_result_types_empty_path_segments() { - // Test line 48: path.segments.is_empty() returns None - // Create a Type::Path programmatically with empty segments - use syn::punctuated::Punctuated; - - let type_path = syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: Punctuated::new(), // Empty segments! - }, - }; - let ty = syn::Type::Path(type_path); - - // Tests: path.segments.is_empty() is true - let result = extract_result_types(&ty); - assert!( - result.is_none(), - "Empty path segments should return None (line 48)" - ); - } - - #[test] - fn test_extract_result_types_empty_path_via_reference() { - // Test line 48 via reference path: &Type::Path with empty segments - use syn::punctuated::Punctuated; - - // Create inner Type::Path with empty segments - let inner_type_path = syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: Punctuated::new(), - }, - }; - let inner_ty = syn::Type::Path(inner_type_path); - - // Wrap in a reference - let ty = syn::Type::Reference(syn::TypeReference { - and_token: syn::token::And::default(), - lifetime: None, - mutability: None, - elem: Box::new(inner_ty), - }); - - // Tests: reference to path then empty segments - let result = extract_result_types(&ty); - assert!( - result.is_none(), - "Empty path segments via reference should return None (line 48)" - ); - } - - #[test] - fn test_extract_result_types_with_reference() { - // Test the Reference path (line 38-41) that succeeds - // &Result should still extract types - let ty: syn::Type = syn::parse_str("&Result").unwrap(); - let _result = extract_result_types(&ty); - // Note: This doesn't actually work because is_keyword_type_by_type_path - // checks for Result type, but ref to Result is different - // The important thing is the code doesn't panic - // Tests: exercises reference path even if result is None - } - - #[test] - fn test_unwrap_json_non_json() { - // Test unwrap_json with non-Json type returns original - let ty: syn::Type = syn::parse_str("String").unwrap(); - let unwrapped = unwrap_json(&ty); - // Should return the same type - assert!(matches!(unwrapped, syn::Type::Path(_))); - } - - #[test] - fn test_unwrap_json_with_json() { - // Test unwrap_json with Json - let ty: syn::Type = syn::parse_str("Json").unwrap(); - let unwrapped = unwrap_json(&ty); - // Should unwrap to String - if let syn::Type::Path(type_path) = unwrapped { - assert_eq!( - type_path.path.segments.last().unwrap().ident.to_string(), - "String" - ); - } else { - panic!("Expected Path type"); - } - } - - #[test] - fn test_parse_return_type_tuple() { - // Test parse_return_type with tuple type (exercises line 43 via extract_result_types) - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - let return_type = parse_return_type_str("-> (i32, String)"); - - let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); - - // Tuple is not a Result, so it should be treated as regular response - assert!(responses.contains_key("200")); - assert_eq!(responses.len(), 1); - } - - #[test] - fn test_extract_ok_payload_and_headers_tuple_without_headermap() { - // Test line 95: tuple without HeaderMap returns None for headers - let ty: syn::Type = syn::parse_str("(StatusCode, String)").unwrap(); - let (payload, headers) = extract_ok_payload_and_headers(&ty); - - // Payload should be String (last element unwrapped) - if let syn::Type::Path(type_path) = &payload { - assert_eq!( - type_path.path.segments.last().unwrap().ident.to_string(), - "String" - ); - } - // Headers should be None (no HeaderMap in tuple) - this is line 95 - assert!(headers.is_none()); - } - - #[test] - fn test_parse_return_type_result_with_ok_tuple_no_headermap() { - // Test line 95 via full parse_return_type: Result<(StatusCode, Json), E> - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - let return_type = parse_return_type_str("-> Result<(StatusCode, Json), String>"); - - let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); - - // Should have 200 and 400 responses - assert!(responses.contains_key("200")); - let ok_response = responses.get("200").unwrap(); - // Headers should be None - assert!(ok_response.headers.is_none()); - } - - // ======== CookieJar tuple extraction tests ======== - - #[test] - fn test_extract_ok_payload_and_headers_cookie_jar_tuple() { - // (CookieJar, Json) → payload should be String, CookieJar filtered - let ty: syn::Type = syn::parse_str("(CookieJar, Json)").unwrap(); - let (payload, headers) = extract_ok_payload_and_headers(&ty); - - if let syn::Type::Path(type_path) = &payload { - assert_eq!( - type_path.path.segments.last().unwrap().ident.to_string(), - "String" - ); - } else { - panic!("Expected Path type for payload"); - } - assert!(headers.is_none()); - } - - #[test] - fn test_extract_ok_payload_and_headers_cookie_jar_with_status_code() { - // (StatusCode, CookieJar, Json) → payload should be i32 - let ty: syn::Type = syn::parse_str("(StatusCode, CookieJar, Json)").unwrap(); - let (payload, headers) = extract_ok_payload_and_headers(&ty); - - if let syn::Type::Path(type_path) = &payload { - assert_eq!( - type_path.path.segments.last().unwrap().ident.to_string(), - "i32" - ); - } else { - panic!("Expected Path type for payload"); - } - assert!(headers.is_none()); - } - - #[test] - fn test_extract_ok_payload_and_headers_all_non_body_types() { - // (StatusCode, CookieJar) → no body element found, returns original tuple - let ty: syn::Type = syn::parse_str("(StatusCode, CookieJar)").unwrap(); - let (payload, headers) = extract_ok_payload_and_headers(&ty); - // No body element found → falls through to return original type - assert!(matches!(payload, syn::Type::Tuple(_))); - assert!(headers.is_none()); - } - - #[test] - fn test_unwrap_json_qualified_path() { - // vespera::axum::Json → should unwrap to String via last-segment matching - let ty: syn::Type = syn::parse_str("vespera::axum::Json").unwrap(); - let unwrapped = unwrap_json(&ty); - if let syn::Type::Path(type_path) = unwrapped { - assert_eq!( - type_path.path.segments.last().unwrap().ident.to_string(), - "String" - ); - } else { - panic!("Expected Path type"); - } - } - - #[test] - fn test_unwrap_json_non_generic_path() { - // Type with segments but no angle brackets → returns original - let ty: syn::Type = syn::parse_str("std::string::String").unwrap(); - let unwrapped = unwrap_json(&ty); - if let syn::Type::Path(type_path) = unwrapped { - assert_eq!( - type_path.path.segments.last().unwrap().ident.to_string(), - "String" - ); - } else { - panic!("Expected Path type"); - } - } - - #[test] - fn test_parse_return_type_non_result_status_code() { - // Direct StatusCode return (not in Result) → 200 with no content - let known_schemas = HashSet::new(); - let struct_definitions = HashMap::new(); - let return_type = parse_return_type_str("-> StatusCode"); - - let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); - - assert_eq!(responses.len(), 1); - let ok_response = responses.get("200").unwrap(); - assert!( - ok_response.content.is_none(), - "StatusCode return should have no content" - ); - assert!(ok_response.headers.is_none()); - } - - #[test] - fn test_is_non_body_type() { - let status: syn::Type = syn::parse_str("StatusCode").unwrap(); - assert!(is_non_body_type(&status)); - - let header_map: syn::Type = syn::parse_str("HeaderMap").unwrap(); - assert!(is_non_body_type(&header_map)); - - let cookie_jar: syn::Type = syn::parse_str("CookieJar").unwrap(); - assert!(is_non_body_type(&cookie_jar)); - - let string: syn::Type = syn::parse_str("String").unwrap(); - assert!(!is_non_body_type(&string)); - - let json: syn::Type = syn::parse_str("Json").unwrap(); - assert!(!is_non_body_type(&json)); - } -} +mod tests; diff --git a/crates/vespera_macro/src/parser/response/tests.rs b/crates/vespera_macro/src/parser/response/tests.rs new file mode 100644 index 00000000..58e5729b --- /dev/null +++ b/crates/vespera_macro/src/parser/response/tests.rs @@ -0,0 +1,566 @@ +use std::collections::HashMap; + +use rstest::rstest; +use vespera_core::schema::{SchemaRef, SchemaType}; + +use super::*; + +#[derive(Debug)] +struct ExpectedSchema { + schema_type: SchemaType, + nullable: bool, + items_schema_type: Option, +} + +#[derive(Debug)] +struct ExpectedResponse { + status: &'static str, + schema: ExpectedSchema, +} + +fn parse_return_type_str(return_type_str: &str) -> syn::ReturnType { + if return_type_str.is_empty() { + syn::ReturnType::Default + } else { + let full_signature = format!("fn test() {return_type_str}"); + syn::parse_str::(&full_signature) + .expect("Failed to parse return type") + .output + } +} + +fn assert_schema_matches(schema_ref: &SchemaRef, expected: &ExpectedSchema) { + match schema_ref { + SchemaRef::Inline(schema) => { + assert_eq!(schema.schema_type, Some(expected.schema_type)); + assert_eq!(schema.nullable.unwrap_or(false), expected.nullable); + if let Some(item_ty) = &expected.items_schema_type { + let items = schema + .items + .as_ref() + .expect("items should be present for array"); + match items { + SchemaRef::Inline(item_schema) => { + assert_eq!(item_schema.schema_type, Some(*item_ty)); + } + SchemaRef::Ref(_) => panic!("expected inline schema for array items"), + } + } + } + SchemaRef::Ref(_) => panic!("expected inline schema"), + } +} + +#[rstest] +#[case("", None, None, None)] +#[case( + "-> String", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + None, + None + )] +#[case( + "-> &str", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + None, + None + )] +#[case( + "-> i32", + Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), + None, + None + )] +#[case( + "-> bool", + Some(ExpectedSchema { schema_type: SchemaType::Boolean, nullable: false, items_schema_type: None }), + None, + None + )] +#[case( + "-> Vec", + Some(ExpectedSchema { schema_type: SchemaType::Array, nullable: false, items_schema_type: Some(SchemaType::String) }), + None, + None + )] +#[case( + "-> Option", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: true, items_schema_type: None }), + None, + None + )] +#[case( + "-> Result", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] +#[case( + "-> Result", + Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] +#[case( + "-> Result, String>", + Some(ExpectedSchema { schema_type: SchemaType::Object, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] +#[case( + "-> Result<&str, String>", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] +#[case( + "-> Result", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] +#[case( + "-> Result)>", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] +#[case( + "-> Result<(HeaderMap, Json), String>", + Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + Some(true) + )] +#[case( + "-> Result)>", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None } }), + None + )] +// StatusCode as the sole Ok response type → no content (empty body) +#[case( + "-> Result", + None, + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] +// CookieJar in Ok tuple → body is Json, CookieJar filtered out +#[case( + "-> Result<(CookieJar, Json), (StatusCode, String)>", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] +// CookieJar + StatusCode in Ok tuple → body is last non-metadata element +#[case( + "-> Result<(StatusCode, CookieJar, Json), String>", + Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), + Some(ExpectedResponse { status: "400", schema: ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None } }), + None + )] +// Non-Result: StatusCode alone → no content (covers line 155) +#[case("-> StatusCode", None, None, None)] +// Non-Result: Json wrapper → unwraps to T +#[case( + "-> Json", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + None, + None + )] +// Non-Result: Json wrapper → unwraps to integer +#[case( + "-> Json", + Some(ExpectedSchema { schema_type: SchemaType::Integer, nullable: false, items_schema_type: None }), + None, + None + )] +// Non-Result: f64 → number type +#[case( + "-> f64", + Some(ExpectedSchema { schema_type: SchemaType::Number, nullable: false, items_schema_type: None }), + None, + None + )] +// Non-Result: qualified axum::Json → unwraps to String +#[case( + "-> axum::Json", + Some(ExpectedSchema { schema_type: SchemaType::String, nullable: false, items_schema_type: None }), + None, + None + )] +fn test_parse_return_type( + #[case] return_type_str: &str, + #[case] ok_expectation: Option, + #[case] err_expectation: Option, + #[case] ok_headers_expected: Option, +) { + let known_schemas = HashSet::new(); + let struct_definitions = HashMap::new(); + let return_type = parse_return_type_str(return_type_str); + + let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); + + // Validate success response + let ok_response = responses.get("200").expect("200 response should exist"); + assert_eq!(ok_response.description, "Successful response"); + match &ok_expectation { + None => { + assert!(ok_response.content.is_none()); + } + Some(expected_schema) => { + let content = ok_response + .content + .as_ref() + .expect("ok content should exist"); + let media_type = content.values().next().expect("ok media type should exist"); + let schema_ref = media_type.schema.as_ref().expect("ok schema should exist"); + assert_schema_matches(schema_ref, expected_schema); + } + } + if let Some(expect_headers) = ok_headers_expected { + assert_eq!(ok_response.headers.is_some(), expect_headers); + } + + // Validate error response (if any) + match &err_expectation { + None => assert_eq!(responses.len(), 1), + Some(err) => { + assert_eq!(responses.len(), 2); + let err_response = responses + .get(err.status) + .expect("error response should exist"); + assert_eq!(err_response.description, "Error response"); + let content = err_response + .content + .as_ref() + .expect("error content should exist"); + let media_type = content + .values() + .next() + .expect("error media type should exist"); + let schema_ref = media_type + .schema + .as_ref() + .expect("error schema should exist"); + assert_schema_matches(schema_ref, &err.schema); + } + } +} + +#[rstest] +#[case("-> String", "200", "text/plain")] +#[case("-> &str", "200", "text/plain")] +#[case("-> Json", "200", "application/json")] +#[case("-> i32", "200", "application/json")] +#[case("-> Result", "200", "text/plain")] +#[case("-> Result", "400", "text/plain")] +#[case( + "-> Result, (StatusCode, String)>", + "200", + "application/json" +)] +#[case("-> Result, (StatusCode, String)>", "400", "text/plain")] +#[case( + "-> Result)>", + "400", + "application/json" +)] +fn response_content_type_matches_body_kind( + #[case] return_type_str: &str, + #[case] status: &str, + #[case] expected_content_type: &str, +) { + let return_type = parse_return_type_str(return_type_str); + let responses = parse_return_type(&return_type, &HashSet::new(), &HashMap::new()); + let content = responses + .get(status) + .and_then(|response| response.content.as_ref()) + .unwrap_or_else(|| panic!("{status} content missing for `{return_type_str}`")); + assert!( + content.contains_key(expected_content_type), + "`{return_type_str}` {status}: expected {expected_content_type}, got {:?}", + content.keys().collect::>() + ); +} + +// ======== Tests for uncovered lines ======== + +#[test] +fn test_extract_result_types_non_path_non_ref() { + // Test line 43: type that's neither Path nor Reference returns None + // Tuple type is neither Path nor Reference + let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); + let result = extract_result_types(&ty); + assert!(result.is_none()); + + // Array type + let ty: syn::Type = syn::parse_str("[i32; 3]").unwrap(); + let result = extract_result_types(&ty); + assert!(result.is_none()); + + // Slice type + let ty: syn::Type = syn::parse_str("[i32]").unwrap(); + let result = extract_result_types(&ty); + assert!(result.is_none()); +} + +#[test] +fn test_extract_result_types_ref_to_non_path() { + // Test line 43: &(Tuple) - Reference to non-Path type + // Tests: else branch + let ty: syn::Type = syn::parse_str("&(i32, String)").unwrap(); + let result = extract_result_types(&ty); + // The Reference's elem is a Tuple, not a Path, so line 39 condition fails + // Falls through to line 43 + assert!(result.is_none()); +} + +#[test] +fn test_extract_result_types_empty_path_segments() { + // Test line 48: path.segments.is_empty() returns None + // Create a Type::Path programmatically with empty segments + use syn::punctuated::Punctuated; + + let type_path = syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: Punctuated::new(), // Empty segments! + }, + }; + let ty = syn::Type::Path(type_path); + + // Tests: path.segments.is_empty() is true + let result = extract_result_types(&ty); + assert!( + result.is_none(), + "Empty path segments should return None (line 48)" + ); +} + +#[test] +fn test_extract_result_types_empty_path_via_reference() { + // Test line 48 via reference path: &Type::Path with empty segments + use syn::punctuated::Punctuated; + + // Create inner Type::Path with empty segments + let inner_type_path = syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: Punctuated::new(), + }, + }; + let inner_ty = syn::Type::Path(inner_type_path); + + // Wrap in a reference + let ty = syn::Type::Reference(syn::TypeReference { + and_token: syn::token::And::default(), + lifetime: None, + mutability: None, + elem: Box::new(inner_ty), + }); + + // Tests: reference to path then empty segments + let result = extract_result_types(&ty); + assert!( + result.is_none(), + "Empty path segments via reference should return None (line 48)" + ); +} + +#[test] +fn test_extract_result_types_with_reference() { + // Test the Reference path (line 38-41) that succeeds + // &Result should still extract types + let ty: syn::Type = syn::parse_str("&Result").unwrap(); + let _result = extract_result_types(&ty); + // Note: This doesn't actually work because is_keyword_type_by_type_path + // checks for Result type, but ref to Result is different + // The important thing is the code doesn't panic + // Tests: exercises reference path even if result is None +} + +#[test] +fn test_unwrap_json_non_json() { + // Test unwrap_json with non-Json type returns original + let ty: syn::Type = syn::parse_str("String").unwrap(); + let unwrapped = unwrap_json(&ty); + // Should return the same type + assert!(matches!(unwrapped, syn::Type::Path(_))); +} + +#[test] +fn test_unwrap_json_with_json() { + // Test unwrap_json with Json + let ty: syn::Type = syn::parse_str("Json").unwrap(); + let unwrapped = unwrap_json(&ty); + // Should unwrap to String + if let syn::Type::Path(type_path) = unwrapped { + assert_eq!( + type_path.path.segments.last().unwrap().ident.to_string(), + "String" + ); + } else { + panic!("Expected Path type"); + } +} + +#[test] +fn test_parse_return_type_tuple() { + // Test parse_return_type with tuple type (exercises line 43 via extract_result_types) + let known_schemas = HashSet::new(); + let struct_definitions = HashMap::new(); + let return_type = parse_return_type_str("-> (i32, String)"); + + let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); + + // Tuple is not a Result, so it should be treated as regular response + assert!(responses.contains_key("200")); + assert_eq!(responses.len(), 1); +} + +#[test] +fn test_extract_ok_payload_and_headers_tuple_without_headermap() { + // Test line 95: tuple without HeaderMap returns None for headers + let ty: syn::Type = syn::parse_str("(StatusCode, String)").unwrap(); + let (payload, headers) = extract_ok_payload_and_headers(&ty); + + // Payload should be String (last element unwrapped) + if let syn::Type::Path(type_path) = &payload { + assert_eq!( + type_path.path.segments.last().unwrap().ident.to_string(), + "String" + ); + } + // Headers should be None (no HeaderMap in tuple) - this is line 95 + assert!(headers.is_none()); +} + +#[test] +fn test_parse_return_type_result_with_ok_tuple_no_headermap() { + // Test line 95 via full parse_return_type: Result<(StatusCode, Json), E> + let known_schemas = HashSet::new(); + let struct_definitions = HashMap::new(); + let return_type = parse_return_type_str("-> Result<(StatusCode, Json), String>"); + + let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); + + // Should have 200 and 400 responses + assert!(responses.contains_key("200")); + let ok_response = responses.get("200").unwrap(); + // Headers should be None + assert!(ok_response.headers.is_none()); +} + +// ======== CookieJar tuple extraction tests ======== + +#[test] +fn test_extract_ok_payload_and_headers_cookie_jar_tuple() { + // (CookieJar, Json) → payload should be String, CookieJar filtered + let ty: syn::Type = syn::parse_str("(CookieJar, Json)").unwrap(); + let (payload, headers) = extract_ok_payload_and_headers(&ty); + + if let syn::Type::Path(type_path) = &payload { + assert_eq!( + type_path.path.segments.last().unwrap().ident.to_string(), + "String" + ); + } else { + panic!("Expected Path type for payload"); + } + assert!(headers.is_none()); +} + +#[test] +fn test_extract_ok_payload_and_headers_cookie_jar_with_status_code() { + // (StatusCode, CookieJar, Json) → payload should be i32 + let ty: syn::Type = syn::parse_str("(StatusCode, CookieJar, Json)").unwrap(); + let (payload, headers) = extract_ok_payload_and_headers(&ty); + + if let syn::Type::Path(type_path) = &payload { + assert_eq!( + type_path.path.segments.last().unwrap().ident.to_string(), + "i32" + ); + } else { + panic!("Expected Path type for payload"); + } + assert!(headers.is_none()); +} + +#[test] +fn test_extract_ok_payload_and_headers_all_non_body_types() { + // (StatusCode, CookieJar) → no body element found, returns original tuple + let ty: syn::Type = syn::parse_str("(StatusCode, CookieJar)").unwrap(); + let (payload, headers) = extract_ok_payload_and_headers(&ty); + // No body element found → falls through to return original type + assert!(matches!(payload, syn::Type::Tuple(_))); + assert!(headers.is_none()); +} + +#[test] +fn test_unwrap_json_qualified_path() { + // vespera::axum::Json → should unwrap to String via last-segment matching + let ty: syn::Type = syn::parse_str("vespera::axum::Json").unwrap(); + let unwrapped = unwrap_json(&ty); + if let syn::Type::Path(type_path) = unwrapped { + assert_eq!( + type_path.path.segments.last().unwrap().ident.to_string(), + "String" + ); + } else { + panic!("Expected Path type"); + } +} + +#[test] +fn test_unwrap_json_non_generic_path() { + // Type with segments but no angle brackets → returns original + let ty: syn::Type = syn::parse_str("std::string::String").unwrap(); + let unwrapped = unwrap_json(&ty); + if let syn::Type::Path(type_path) = unwrapped { + assert_eq!( + type_path.path.segments.last().unwrap().ident.to_string(), + "String" + ); + } else { + panic!("Expected Path type"); + } +} + +#[test] +fn test_parse_return_type_non_result_status_code() { + // Direct StatusCode return (not in Result) → 200 with no content + let known_schemas = HashSet::new(); + let struct_definitions = HashMap::new(); + let return_type = parse_return_type_str("-> StatusCode"); + + let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); + + assert_eq!(responses.len(), 1); + let ok_response = responses.get("200").unwrap(); + assert!( + ok_response.content.is_none(), + "StatusCode return should have no content" + ); + assert!(ok_response.headers.is_none()); +} + +#[test] +fn test_is_non_body_type() { + let status: syn::Type = syn::parse_str("StatusCode").unwrap(); + assert!(is_non_body_type(&status)); + + let header_map: syn::Type = syn::parse_str("HeaderMap").unwrap(); + assert!(is_non_body_type(&header_map)); + + let cookie_jar: syn::Type = syn::parse_str("CookieJar").unwrap(); + assert!(is_non_body_type(&cookie_jar)); + + let string: syn::Type = syn::parse_str("String").unwrap(); + assert!(!is_non_body_type(&string)); + + let json: syn::Type = syn::parse_str("Json").unwrap(); + assert!(!is_non_body_type(&json)); +} diff --git a/crates/vespera_macro/src/parser/schema/enum_schema.rs b/crates/vespera_macro/src/parser/schema/enum_schema.rs index c43a9520..5dc9b593 100644 --- a/crates/vespera_macro/src/parser/schema/enum_schema.rs +++ b/crates/vespera_macro/src/parser/schema/enum_schema.rs @@ -1,80 +1,48 @@ -//! Enum to JSON Schema conversion for `OpenAPI` generation. -//! -//! This module handles the conversion of Rust enums (as parsed by syn) -//! into OpenAPI-compatible JSON Schema definitions. -//! -//! ## Supported Serde Enum Representations -//! -//! Vespera supports all four serde enum representations: -//! -//! 1. **Externally Tagged** (default): `{"VariantName": {...}}` -//! 2. **Internally Tagged** (`#[serde(tag = "type")]`): `{"type": "VariantName", ...fields...}` -//! 3. **Adjacently Tagged** (`#[serde(tag = "type", content = "data")]`): `{"type": "VariantName", "data": {...}}` -//! 4. **Untagged** (`#[serde(untagged)]`): `{...fields...}` (no tag) -//! -//! Each representation maps to a different `OpenAPI` schema pattern using `oneOf` and optionally `discriminator`. +//! Enum to JSON Schema conversion for OpenAPI generation. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::{ + borrow::Borrow, + collections::{HashMap, HashSet}, + hash::Hash, +}; -use syn::Type; -use vespera_core::schema::{Discriminator, Schema, SchemaRef, SchemaType}; +use vespera_core::schema::Schema; -use super::{ - serde_attrs::{ - SerdeEnumRepr, extract_doc_comment, extract_enum_repr, extract_field_rename, - extract_rename_all, rename_field, strip_raw_prefix_owned, - }, - type_schema::parse_type_to_schema_ref, +use super::serde_attrs::{ + SerdeEnumRepr, extract_doc_comment, extract_enum_repr, extract_rename_all, }; -/// Parses a Rust enum into an `OpenAPI` Schema. -/// -/// Supports all four serde enum representations: -/// - Externally tagged (default): `{"VariantName": {...}}` -/// - Internally tagged (`#[serde(tag = "type")]`): `{"type": "VariantName", ...fields...}` -/// - Adjacently tagged (`#[serde(tag = "type", content = "data")]`): `{"type": "VariantName", "data": {...}}` -/// - Untagged (`#[serde(untagged)]`): `{...fields...}` (no tag) -/// -/// # Arguments -/// * `enum_item` - The parsed enum from syn -/// * `known_schemas` - Map of known schema names for reference resolution -/// * `struct_definitions` - Map of struct names to their source code (for generics) +mod representations; +mod unit; +mod variant; + +/// Parses a Rust enum into an OpenAPI Schema. pub fn parse_enum_to_schema( enum_item: &syn::ItemEnum, - known_schemas: &HashSet, - struct_definitions: &HashMap, + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, ) -> Schema { - // Extract enum-level doc comment for schema description let enum_description = extract_doc_comment(&enum_item.attrs); - - // Extract rename_all attribute from enum let rename_all = extract_rename_all(&enum_item.attrs); - - // Detect the serde enum representation let repr = extract_enum_repr(&enum_item.attrs); - - // Check if all variants are unit variants let all_unit = enum_item .variants .iter() .all(|v| matches!(v.fields, syn::Fields::Unit)); - // For simple enums (all unit variants) with externally tagged representation (default), - // they serialize to just the variant name as a string. - // However, internally/adjacently tagged enums serialize unit variants as objects with tag. if all_unit && matches!(repr, SerdeEnumRepr::ExternallyTagged) { - return parse_unit_enum_to_schema(enum_item, enum_description, rename_all.as_deref()); + return unit::parse_unit_enum_to_schema(enum_item, enum_description, rename_all.as_deref()); } match repr { - SerdeEnumRepr::ExternallyTagged => parse_externally_tagged_enum( + SerdeEnumRepr::ExternallyTagged => representations::parse_externally_tagged_enum( enum_item, enum_description, rename_all.as_deref(), known_schemas, struct_definitions, ), - SerdeEnumRepr::InternallyTagged { tag } => parse_internally_tagged_enum( + SerdeEnumRepr::InternallyTagged { tag } => representations::parse_internally_tagged_enum( enum_item, enum_description, rename_all.as_deref(), @@ -82,16 +50,18 @@ pub fn parse_enum_to_schema( known_schemas, struct_definitions, ), - SerdeEnumRepr::AdjacentlyTagged { tag, content } => parse_adjacently_tagged_enum( - enum_item, - enum_description, - rename_all.as_deref(), - &tag, - &content, - known_schemas, - struct_definitions, - ), - SerdeEnumRepr::Untagged => parse_untagged_enum( + SerdeEnumRepr::AdjacentlyTagged { tag, content } => { + representations::parse_adjacently_tagged_enum( + enum_item, + enum_description, + rename_all.as_deref(), + &tag, + &content, + known_schemas, + struct_definitions, + ) + } + SerdeEnumRepr::Untagged => representations::parse_untagged_enum( enum_item, enum_description, rename_all.as_deref(), @@ -101,554 +71,13 @@ pub fn parse_enum_to_schema( } } -/// Parse a simple enum (all unit variants) to a string schema with enum values. -fn parse_unit_enum_to_schema( - enum_item: &syn::ItemEnum, - description: Option, - rename_all: Option<&str>, -) -> Schema { - let mut enum_values = Vec::with_capacity(enum_item.variants.len()); - - for variant in &enum_item.variants { - let variant_name = strip_raw_prefix_owned(variant.ident.to_string()); - - // Check for variant-level rename attribute first (takes precedence) - let enum_value = extract_field_rename(&variant.attrs) - .unwrap_or_else(|| rename_field(&variant_name, rename_all)); - - enum_values.push(serde_json::Value::String(enum_value)); - } - - Schema { - schema_type: Some(SchemaType::String), - description, - r#enum: if enum_values.is_empty() { - None - } else { - Some(enum_values) - }, - ..Schema::string() - } -} - -/// Get the variant key (name after rename transformations) -fn get_variant_key(variant: &syn::Variant, rename_all: Option<&str>) -> String { - let variant_name = strip_raw_prefix_owned(variant.ident.to_string()); - - extract_field_rename(&variant.attrs).unwrap_or_else(|| rename_field(&variant_name, rename_all)) -} - -/// Build properties for a struct variant's fields -fn build_struct_variant_properties( - fields_named: &syn::FieldsNamed, - enum_rename_all: Option<&str>, - variant_attrs: &[syn::Attribute], - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> (BTreeMap, Vec) { - let mut variant_properties = BTreeMap::new(); - let mut variant_required = Vec::with_capacity(fields_named.named.len()); - let variant_rename_all = extract_rename_all(variant_attrs); - - for field in &fields_named.named { - let rust_field_name = field.ident.as_ref().map_or_else( - || "unknown".to_string(), - |i| strip_raw_prefix_owned(i.to_string()), - ); - - // Check for field-level rename attribute first (takes precedence) - let field_name = extract_field_rename(&field.attrs).unwrap_or_else(|| { - rename_field( - &rust_field_name, - variant_rename_all.as_deref().or(enum_rename_all), - ) - }); - - let field_type = &field.ty; - let mut schema_ref = - parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); - - // Extract doc comment from field and set as description - if let Some(doc) = extract_doc_comment(&field.attrs) { - match &mut schema_ref { - SchemaRef::Inline(schema) => { - schema.description = Some(doc); - } - SchemaRef::Ref(_) => { - let ref_schema = std::mem::replace( - &mut schema_ref, - SchemaRef::Inline(Box::new(Schema::object())), - ); - if let SchemaRef::Ref(reference) = ref_schema { - schema_ref = SchemaRef::Inline(Box::new(Schema { - description: Some(doc), - all_of: Some(vec![SchemaRef::Ref(reference)]), - ..Default::default() - })); - } - } - } - } - - variant_properties.insert(field_name.clone(), schema_ref); - - // Check if field is Option - let is_optional = matches!( - field_type, - Type::Path(type_path) - if type_path - .path - .segments - .first() - .is_some_and(|s| s.ident == "Option") - ); - - if !is_optional { - variant_required.push(field_name); - } - } - - (variant_properties, variant_required) -} - -/// Build a schema for a variant's data (tuple or struct fields) -fn build_variant_data_schema( - variant: &syn::Variant, - enum_rename_all: Option<&str>, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Option { - match &variant.fields { - syn::Fields::Unit => None, - syn::Fields::Unnamed(fields_unnamed) => { - if fields_unnamed.unnamed.len() == 1 { - // Single field tuple variant - just the inner type - let inner_type = &fields_unnamed.unnamed[0].ty; - Some(parse_type_to_schema_ref( - inner_type, - known_schemas, - struct_definitions, - )) - } else { - // Multiple fields tuple variant - array with prefixItems - let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); - for field in &fields_unnamed.unnamed { - let field_schema = - parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); - tuple_item_schemas.push(field_schema); - } - - let tuple_len = tuple_item_schemas.len(); - Some(SchemaRef::Inline(Box::new(Schema { - prefix_items: Some(tuple_item_schemas), - min_items: Some(tuple_len), - max_items: Some(tuple_len), - items: None, - ..Schema::new(SchemaType::Array) - }))) - } - } - syn::Fields::Named(fields_named) => { - let (properties, required) = build_struct_variant_properties( - fields_named, - enum_rename_all, - &variant.attrs, - known_schemas, - struct_definitions, - ); - - Some(SchemaRef::Inline(Box::new(Schema { - properties: if properties.is_empty() { - None - } else { - Some(properties) - }, - required: if required.is_empty() { - None - } else { - Some(required) - }, - ..Schema::object() - }))) - } - } -} - -/// Parse externally tagged enum: `{"VariantName": {...}}` -/// This is serde's default representation. -fn parse_externally_tagged_enum( - enum_item: &syn::ItemEnum, - description: Option, - rename_all: Option<&str>, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Schema { - let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); - - for variant in &enum_item.variants { - let variant_key = get_variant_key(variant, rename_all); - let variant_description = extract_doc_comment(&variant.attrs); - - let variant_schema = match &variant.fields { - syn::Fields::Unit => { - // Unit variant in mixed enum: string with const value - Schema { - description: variant_description, - r#enum: Some(vec![serde_json::Value::String(variant_key)]), - ..Schema::string() - } - } - syn::Fields::Unnamed(fields_unnamed) => { - // Tuple variant: {"VariantName": } - let data_schema = if fields_unnamed.unnamed.len() == 1 { - let inner_type = &fields_unnamed.unnamed[0].ty; - parse_type_to_schema_ref(inner_type, known_schemas, struct_definitions) - } else { - // Multiple fields - array with prefixItems - let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); - for field in &fields_unnamed.unnamed { - let field_schema = - parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); - tuple_item_schemas.push(field_schema); - } - let tuple_len = tuple_item_schemas.len(); - SchemaRef::Inline(Box::new(Schema { - prefix_items: Some(tuple_item_schemas), - min_items: Some(tuple_len), - max_items: Some(tuple_len), - items: None, - ..Schema::new(SchemaType::Array) - })) - }; - - let mut properties = BTreeMap::new(); - properties.insert(variant_key.clone(), data_schema); - - Schema { - description: variant_description, - properties: Some(properties), - required: Some(vec![variant_key]), - ..Schema::object() - } - } - syn::Fields::Named(fields_named) => { - // Struct variant: {"VariantName": {field1: type1, ...}} - let (inner_properties, inner_required) = build_struct_variant_properties( - fields_named, - rename_all, - &variant.attrs, - known_schemas, - struct_definitions, - ); - - let inner_struct_schema = Schema { - properties: if inner_properties.is_empty() { - None - } else { - Some(inner_properties) - }, - required: if inner_required.is_empty() { - None - } else { - Some(inner_required) - }, - ..Schema::object() - }; - - let mut properties = BTreeMap::new(); - properties.insert( - variant_key.clone(), - SchemaRef::Inline(Box::new(inner_struct_schema)), - ); - - Schema { - description: variant_description, - properties: Some(properties), - required: Some(vec![variant_key]), - ..Schema::object() - } - } - }; - - one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); - } - - Schema { - schema_type: None, - description, - one_of: if one_of_schemas.is_empty() { - None - } else { - Some(one_of_schemas) - }, - ..Schema::new(SchemaType::Object) - } -} - -/// Parse internally tagged enum: `{"tag": "VariantName", ...fields...}` -/// Uses `OpenAPI` discriminator for the tag field. -/// Note: serde only allows struct and unit variants for internally tagged enums. -fn parse_internally_tagged_enum( - enum_item: &syn::ItemEnum, - description: Option, - rename_all: Option<&str>, - tag: &str, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Schema { - let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); - - let tag_string = tag.to_string(); - - for variant in &enum_item.variants { - let variant_key = get_variant_key(variant, rename_all); - let variant_description = extract_doc_comment(&variant.attrs); - - let variant_schema = match &variant.fields { - syn::Fields::Unit => { - // Unit variant: {"tag": "VariantName"} - let mut properties = BTreeMap::new(); - properties.insert( - tag_string.clone(), - SchemaRef::Inline(Box::new(Schema { - r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), - ..Schema::string() - })), - ); - - Schema { - description: variant_description, - properties: Some(properties), - required: Some(vec![tag_string.clone()]), - ..Schema::object() - } - } - syn::Fields::Named(fields_named) => { - // Struct variant: {"tag": "VariantName", field1: type1, ...} - let (mut properties, mut required) = build_struct_variant_properties( - fields_named, - rename_all, - &variant.attrs, - known_schemas, - struct_definitions, - ); - - // Add the tag field - properties.insert( - tag_string.clone(), - SchemaRef::Inline(Box::new(Schema { - r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), - ..Schema::string() - })), - ); - required.insert(0, tag_string.clone()); - - Schema { - description: variant_description, - properties: Some(properties), - required: Some(required), - ..Schema::object() - } - } - syn::Fields::Unnamed(_) => { - // Tuple/newtype variants are not supported with internally tagged enums in serde - // Generate a warning schema or skip - continue; - } - }; - - one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); - } - - Schema { - schema_type: None, - description, - one_of: if one_of_schemas.is_empty() { - None - } else { - Some(one_of_schemas) - }, - discriminator: Some(Discriminator { - property_name: tag_string, - mapping: None, // Mapping not needed for inline schemas - }), - ..Default::default() - } -} - -/// Parse adjacently tagged enum: `{"tag": "VariantName", "content": {...}}` -/// Uses `OpenAPI` discriminator for the tag field. -fn parse_adjacently_tagged_enum( - enum_item: &syn::ItemEnum, - description: Option, - rename_all: Option<&str>, - tag: &str, - content: &str, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Schema { - let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); - - let tag_string = tag.to_string(); - let content_string = content.to_string(); - - for variant in &enum_item.variants { - let variant_key = get_variant_key(variant, rename_all); - let variant_description = extract_doc_comment(&variant.attrs); - - let mut properties = BTreeMap::new(); - let mut required = vec![tag_string.clone()]; - - // Add the tag field - properties.insert( - tag_string.clone(), - SchemaRef::Inline(Box::new(Schema { - r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), - ..Schema::string() - })), - ); - - // Add the content field if variant has data - if let Some(data_schema) = - build_variant_data_schema(variant, rename_all, known_schemas, struct_definitions) - { - properties.insert(content_string.clone(), data_schema); - required.push(content_string.clone()); - } - - let variant_schema = Schema { - description: variant_description, - properties: Some(properties), - required: Some(required), - ..Schema::object() - }; - - one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); - } - - Schema { - schema_type: None, - description, - one_of: if one_of_schemas.is_empty() { - None - } else { - Some(one_of_schemas) - }, - discriminator: Some(Discriminator { - property_name: tag_string, - mapping: None, - }), - ..Default::default() - } -} - -/// Parse untagged enum: variant data only, no tag. -/// Uses oneOf without discriminator - validation relies on schema structure matching. -fn parse_untagged_enum( - enum_item: &syn::ItemEnum, - description: Option, - rename_all: Option<&str>, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> Schema { - let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); - - for variant in &enum_item.variants { - let variant_description = extract_doc_comment(&variant.attrs); - - let variant_schema = match &variant.fields { - syn::Fields::Unit => { - // Unit variant in untagged enum: null - Schema { - description: variant_description, - schema_type: Some(SchemaType::Null), - ..Default::default() - } - } - syn::Fields::Unnamed(fields_unnamed) => { - if fields_unnamed.unnamed.len() == 1 { - // Single field tuple variant - just the inner type - let inner_type = &fields_unnamed.unnamed[0].ty; - let mut schema = match parse_type_to_schema_ref( - inner_type, - known_schemas, - struct_definitions, - ) { - SchemaRef::Inline(s) => *s, - SchemaRef::Ref(r) => Schema { - all_of: Some(vec![SchemaRef::Ref(r)]), - ..Default::default() - }, - }; - schema.description = variant_description.or(schema.description); - schema - } else { - // Multiple fields - array with prefixItems - let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); - for field in &fields_unnamed.unnamed { - let field_schema = - parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); - tuple_item_schemas.push(field_schema); - } - let tuple_len = tuple_item_schemas.len(); - Schema { - description: variant_description, - prefix_items: Some(tuple_item_schemas), - min_items: Some(tuple_len), - max_items: Some(tuple_len), - items: None, - ..Schema::new(SchemaType::Array) - } - } - } - syn::Fields::Named(fields_named) => { - // Struct variant - just the object with fields - let (properties, required) = build_struct_variant_properties( - fields_named, - rename_all, - &variant.attrs, - known_schemas, - struct_definitions, - ); - - Schema { - description: variant_description, - properties: if properties.is_empty() { - None - } else { - Some(properties) - }, - required: if required.is_empty() { - None - } else { - Some(required) - }, - ..Schema::object() - } - } - }; - - one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); - } - - Schema { - schema_type: None, - description, - one_of: if one_of_schemas.is_empty() { - None - } else { - Some(one_of_schemas) - }, - ..Default::default() - } -} - #[cfg(test)] mod tests { use insta::{assert_debug_snapshot, with_settings}; use rstest::rstest; use super::*; + use vespera_core::schema::{SchemaRef, SchemaType}; #[rstest] #[case( @@ -694,7 +123,11 @@ mod tests { #[case] suffix: &str, ) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); assert_eq!(schema.schema_type, Some(expected_type)); let got = schema .clone() @@ -704,7 +137,7 @@ mod tests { .map(|v| v.as_str().unwrap().to_string()) .collect::>(); assert_eq!(got, expected_enum); - with_settings!({ snapshot_suffix => format!("unit_{}", suffix) }, { + with_settings!({ snapshot_path => "snapshots", snapshot_suffix => format!("unit_{}", suffix) }, { assert_debug_snapshot!(schema); }); } @@ -751,7 +184,11 @@ mod tests { #[case] suffix: &str, ) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); let one_of = schema.clone().one_of.expect("one_of missing"); assert_eq!(one_of.len(), expected_one_of_len); @@ -798,7 +235,7 @@ mod tests { } } - with_settings!({ snapshot_suffix => format!("tuple_named_{}", suffix) }, { + with_settings!({ snapshot_path => "snapshots", snapshot_suffix => format!("tuple_named_{}", suffix) }, { assert_debug_snapshot!(schema); }); } @@ -823,7 +260,11 @@ mod tests { ) { let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); let one_of = schema.one_of.expect("one_of missing for mixed enum"); assert_eq!(one_of.len(), expected_one_of_len); @@ -847,7 +288,11 @@ mod tests { ) .unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); let one_of = schema.one_of.expect("one_of missing"); let SchemaRef::Inline(variant_obj) = &one_of[0] else { panic!("Expected inline schema") @@ -871,7 +316,11 @@ mod tests { ) .unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); let one_of = schema.one_of.expect("one_of missing"); let SchemaRef::Inline(variant_obj) = &one_of[0] else { panic!("Expected inline schema") @@ -901,7 +350,11 @@ mod tests { ) .unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); let one_of = schema.one_of.expect("one_of missing"); let SchemaRef::Inline(variant_obj) = &one_of[0] else { panic!("Expected inline schema") @@ -927,7 +380,11 @@ mod tests { ) .unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); let one_of = schema.one_of.expect("one_of missing"); let SchemaRef::Inline(variant_obj) = &one_of[0] else { panic!("Expected inline schema") @@ -962,7 +419,11 @@ mod tests { ) .unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); let enum_values = schema.r#enum.expect("enum values missing"); assert_eq!(enum_values[0].as_str().unwrap(), "active-user"); assert_eq!(enum_values[1].as_str().unwrap(), "inactive-user"); @@ -982,7 +443,11 @@ mod tests { ) .unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); let one_of = schema.one_of.expect("one_of missing"); // Check UserCreated variant key is camelCase @@ -1022,7 +487,11 @@ mod tests { ) .unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); let enum_values = schema.r#enum.expect("enum values missing"); assert_eq!(enum_values[0].as_str().unwrap(), "HIGH_PRIORITY"); assert_eq!(enum_values[1].as_str().unwrap(), "LOW_PRIORITY"); @@ -1037,7 +506,11 @@ mod tests { ", ) .unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); // Empty enum should have no enum values assert!(schema.r#enum.is_none() || schema.r#enum.as_ref().unwrap().is_empty()); } @@ -1053,7 +526,11 @@ mod tests { ", ) .unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); let one_of = schema.one_of.expect("one_of missing"); assert_eq!(one_of.len(), 1); } @@ -1071,7 +548,11 @@ mod tests { } "; let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); assert_eq!(schema.description, Some("Enum description".to_string())); } @@ -1087,7 +568,11 @@ mod tests { } "; let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); assert_eq!(schema.description, Some("Data enum".to_string())); assert!(schema.one_of.is_some()); let one_of = schema.one_of.unwrap(); @@ -1115,7 +600,11 @@ mod tests { } "; let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap(); - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); assert!(schema.one_of.is_some()); let one_of = schema.one_of.unwrap(); if let SchemaRef::Inline(variant_schema) = &one_of[0] { @@ -1145,7 +634,11 @@ mod tests { let mut known_schemas = HashSet::new(); known_schemas.insert("User".to_string()); - let schema = parse_enum_to_schema(&enum_item, &known_schemas, &HashMap::new()); + let schema = parse_enum_to_schema( + &enum_item, + &known_schemas, + &HashMap::::new(), + ); let one_of = schema.one_of.expect("one_of missing"); // Get the Data variant schema @@ -1181,557 +674,4 @@ mod tests { SchemaRef::Ref(_) => panic!("Expected inline schema with allOf, not direct $ref"), } } - - // Tests for serde enum representation support - mod enum_repr_tests { - use super::*; - - // Internally tagged enum tests - #[test] - fn test_internally_tagged_enum_unit_variants() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type")] - enum Message { - Ping, - Pong, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - // Should have discriminator - let discriminator = schema - .discriminator - .as_ref() - .expect("discriminator missing"); - assert_eq!(discriminator.property_name, "type"); - - // Should have oneOf - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Each variant should be an object with "type" property - if let SchemaRef::Inline(ping) = &one_of[0] { - let props = ping.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("type")); - let required = ping.required.as_ref().expect("required missing"); - assert!(required.contains(&"type".to_string())); - } else { - panic!("Expected inline schema"); - } - } - - #[test] - fn test_internally_tagged_enum_struct_variants() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "kind")] - enum Event { - Created { id: i32, name: String }, - Updated { id: i32 }, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - // Should have discriminator with custom tag name - let discriminator = schema - .discriminator - .as_ref() - .expect("discriminator missing"); - assert_eq!(discriminator.property_name, "kind"); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Created variant should have kind, id, and name - if let SchemaRef::Inline(created) = &one_of[0] { - let props = created.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("kind")); - assert!(props.contains_key("id")); - assert!(props.contains_key("name")); - } else { - panic!("Expected inline schema"); - } - } - - #[test] - fn test_internally_tagged_enum_with_rename_all() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type", rename_all = "snake_case")] - enum Status { - ActiveUser, - InactiveUser, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - let one_of = schema.one_of.expect("one_of missing"); - if let SchemaRef::Inline(active) = &one_of[0] { - let props = active.properties.as_ref().expect("properties missing"); - if let SchemaRef::Inline(type_schema) = props.get("type").expect("type missing") { - let enum_vals = type_schema.r#enum.as_ref().expect("enum values missing"); - assert_eq!(enum_vals[0].as_str().unwrap(), "active_user"); - } - } - } - - // Adjacently tagged enum tests - #[test] - fn test_adjacently_tagged_enum_basic() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type", content = "data")] - enum Response { - Success { result: String }, - Error { message: String }, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - // Should have discriminator - let discriminator = schema - .discriminator - .as_ref() - .expect("discriminator missing"); - assert_eq!(discriminator.property_name, "type"); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Each variant should have "type" and "data" properties - if let SchemaRef::Inline(success) = &one_of[0] { - let props = success.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("type")); - assert!(props.contains_key("data")); - - let required = success.required.as_ref().expect("required missing"); - assert!(required.contains(&"type".to_string())); - assert!(required.contains(&"data".to_string())); - } else { - panic!("Expected inline schema"); - } - } - - #[test] - fn test_adjacently_tagged_enum_with_unit_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type", content = "payload")] - enum Command { - Ping, - Message { text: String }, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Ping (unit variant) should only have "type", no "payload" - if let SchemaRef::Inline(ping) = &one_of[0] { - let props = ping.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("type")); - assert!(!props.contains_key("payload")); // Unit variant has no content - - let required = ping.required.as_ref().expect("required missing"); - assert_eq!(required.len(), 1); // Only "type" is required - assert!(required.contains(&"type".to_string())); - } - - // Message should have both "type" and "payload" - if let SchemaRef::Inline(message) = &one_of[1] { - let props = message.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("type")); - assert!(props.contains_key("payload")); - } - } - - #[test] - fn test_adjacently_tagged_enum_tuple_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "t", content = "c")] - enum Value { - Int(i32), - Pair(i32, String), - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Int variant - content should be integer schema - if let SchemaRef::Inline(int_variant) = &one_of[0] { - let props = int_variant.properties.as_ref().expect("properties missing"); - let content = props.get("c").expect("content missing"); - if let SchemaRef::Inline(content_schema) = content { - assert_eq!(content_schema.schema_type, Some(SchemaType::Integer)); - } - } - - // Pair variant - content should be array with prefixItems - if let SchemaRef::Inline(pair_variant) = &one_of[1] { - let props = pair_variant - .properties - .as_ref() - .expect("properties missing"); - let content = props.get("c").expect("content missing"); - if let SchemaRef::Inline(content_schema) = content { - assert_eq!(content_schema.schema_type, Some(SchemaType::Array)); - assert!(content_schema.prefix_items.is_some()); - } - } - } - - // Untagged enum tests - #[test] - fn test_untagged_enum_basic() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum StringOrInt { - String(String), - Int(i32), - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - // Should NOT have discriminator - assert!(schema.discriminator.is_none()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // First variant should be string schema directly (not wrapped in object) - if let SchemaRef::Inline(string_variant) = &one_of[0] { - assert_eq!(string_variant.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline schema"); - } - - // Second variant should be integer schema directly - if let SchemaRef::Inline(int_variant) = &one_of[1] { - assert_eq!(int_variant.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline schema"); - } - } - - #[test] - fn test_untagged_enum_struct_variants() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum Data { - User { name: String, age: i32 }, - Product { title: String, price: f64 }, - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - assert!(schema.discriminator.is_none()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // User variant should be object with name and age (no wrapper) - if let SchemaRef::Inline(user) = &one_of[0] { - assert_eq!(user.schema_type, Some(SchemaType::Object)); - let props = user.properties.as_ref().expect("properties missing"); - assert!(props.contains_key("name")); - assert!(props.contains_key("age")); - } - } - - #[test] - fn test_untagged_enum_unit_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum MaybeValue { - Nothing, - Something(i32), - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Unit variant in untagged enum should be null - if let SchemaRef::Inline(nothing) = &one_of[0] { - assert_eq!(nothing.schema_type, Some(SchemaType::Null)); - } - } - - // Snapshot tests for new representations - #[test] - fn test_internally_tagged_snapshot() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type")] - enum Message { - Request { id: i32, method: String }, - Response { id: i32, result: Option }, - Notification, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - with_settings!({ snapshot_suffix => "internally_tagged" }, { - assert_debug_snapshot!(schema); - }); - } - - #[test] - fn test_adjacently_tagged_snapshot() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type", content = "data")] - enum ApiResponse { - Success { items: Vec }, - Error { code: i32, message: String }, - Empty, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - with_settings!({ snapshot_suffix => "adjacently_tagged" }, { - assert_debug_snapshot!(schema); - }); - } - - #[test] - fn test_untagged_snapshot() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum Value { - Null, - Bool(bool), - Number(f64), - Text(String), - Object { key: String, value: String }, - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - with_settings!({ snapshot_suffix => "untagged" }, { - assert_debug_snapshot!(schema); - }); - } - - // Edge case: Empty struct variant (empty properties/required) - #[test] - fn test_externally_tagged_empty_struct_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - enum Event { - /// Empty struct variant - Empty {}, - Data { value: i32 }, - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - let one_of = schema.clone().one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // Empty variant should have properties with Empty key pointing to object with no properties - if let SchemaRef::Inline(empty_variant) = &one_of[0] { - let props = empty_variant - .properties - .as_ref() - .expect("variant props missing"); - let SchemaRef::Inline(inner) = props.get("Empty").expect("Empty key missing") - else { - panic!("Expected inline schema") - }; - // Empty struct should have properties: None and required: None - assert!(inner.properties.is_none()); - assert!(inner.required.is_none()); - } - - with_settings!({ snapshot_suffix => "externally_tagged_empty_struct" }, { - assert_debug_snapshot!(schema); - }); - } - - // Edge case: Internally tagged enum with tuple variant - #[test] - fn test_internally_tagged_skips_tuple_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(tag = "type")] - enum Message { - Text { content: String }, - Number(i32), - Empty, - } - "#, - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - // Tuple variant `Number(i32)` should be skipped, only 2 variants should remain - let one_of = schema.clone().one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); // Text and Empty only - - // Verify discriminator is present - let discriminator = schema - .discriminator - .as_ref() - .expect("discriminator missing"); - assert_eq!(discriminator.property_name, "type"); - - with_settings!({ snapshot_suffix => "internally_tagged_skip_tuple" }, { - assert_debug_snapshot!(schema); - }); - } - - // Edge case: Untagged enum with tuple variant referencing a known schema - #[test] - fn test_untagged_tuple_variant_with_known_schema_ref() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum Payload { - User(UserData), - Simple(String), - } - ", - ) - .unwrap(); - - // Provide UserData as a known schema so it returns SchemaRef::Ref - let mut known_schemas = HashSet::new(); - known_schemas.insert("UserData".to_string()); - - let schema = parse_enum_to_schema(&enum_item, &known_schemas, &HashMap::new()); - - assert!(schema.discriminator.is_none()); - - let one_of = schema.one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 2); - - // First variant (UserData) should have all_of with a $ref since it's a known schema - if let SchemaRef::Inline(user_variant) = &one_of[0] { - // The schema should have all_of containing the reference - let all_of = user_variant - .all_of - .as_ref() - .expect("all_of missing for known schema ref"); - assert_eq!(all_of.len(), 1); - if let SchemaRef::Ref(reference) = &all_of[0] { - assert!(reference.ref_path.contains("UserData")); - } else { - panic!("Expected SchemaRef::Ref inside all_of"); - } - } else { - panic!("Expected inline schema"); - } - - // Second variant (String) should be inline string schema directly - if let SchemaRef::Inline(simple_variant) = &one_of[1] { - assert_eq!(simple_variant.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline schema"); - } - } - - // Edge case: Untagged enum with multi-field tuple variant - #[test] - fn test_untagged_multi_field_tuple_variant() { - let enum_item: syn::ItemEnum = syn::parse_str( - r" - #[serde(untagged)] - enum Message { - Text(String), - Pair(i32, String), - Triple(i32, String, bool), - } - ", - ) - .unwrap(); - - let schema = parse_enum_to_schema(&enum_item, &HashSet::new(), &HashMap::new()); - - assert!(schema.discriminator.is_none()); - - let one_of = schema.clone().one_of.expect("one_of missing"); - assert_eq!(one_of.len(), 3); - - // Single-field tuple should be string schema directly - if let SchemaRef::Inline(text_variant) = &one_of[0] { - assert_eq!(text_variant.schema_type, Some(SchemaType::String)); - } - - // Multi-field tuple (Pair) should be array with prefixItems - if let SchemaRef::Inline(pair_variant) = &one_of[1] { - assert_eq!(pair_variant.schema_type, Some(SchemaType::Array)); - let prefix_items = pair_variant - .prefix_items - .as_ref() - .expect("prefix_items missing for Pair"); - assert_eq!(prefix_items.len(), 2); - assert_eq!(pair_variant.min_items, Some(2)); - assert_eq!(pair_variant.max_items, Some(2)); - } - - // Multi-field tuple (Triple) should be array with 3 prefixItems - if let SchemaRef::Inline(triple_variant) = &one_of[2] { - assert_eq!(triple_variant.schema_type, Some(SchemaType::Array)); - let prefix_items = triple_variant - .prefix_items - .as_ref() - .expect("prefix_items missing for Triple"); - assert_eq!(prefix_items.len(), 3); - assert_eq!(triple_variant.min_items, Some(3)); - assert_eq!(triple_variant.max_items, Some(3)); - } - - with_settings!({ snapshot_suffix => "untagged_multi_field_tuple" }, { - assert_debug_snapshot!(schema); - }); - } - } } diff --git a/crates/vespera_macro/src/parser/schema/enum_schema/representations.rs b/crates/vespera_macro/src/parser/schema/enum_schema/representations.rs new file mode 100644 index 00000000..b0d27cfa --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/enum_schema/representations.rs @@ -0,0 +1,385 @@ +use std::{ + borrow::Borrow, + collections::{BTreeMap, HashMap, HashSet}, + hash::Hash, +}; + +use vespera_core::schema::{Discriminator, Schema, SchemaRef, SchemaType}; + +use super::super::{serde_attrs::extract_doc_comment, type_schema::parse_type_to_schema_ref}; +use super::{ + unit::get_variant_key, + variant::{build_struct_variant_properties, build_variant_data_schema}, +}; + +/// Parse externally tagged enum: `{"VariantName": {...}}` +/// This is serde's default representation. +pub(super) fn parse_externally_tagged_enum( + enum_item: &syn::ItemEnum, + description: Option, + rename_all: Option<&str>, + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, +) -> Schema { + let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); + + for variant in &enum_item.variants { + let variant_key = get_variant_key(variant, rename_all); + let variant_description = extract_doc_comment(&variant.attrs); + + let variant_schema = match &variant.fields { + syn::Fields::Unit => { + // Unit variant in mixed enum: string with const value + Schema { + description: variant_description, + r#enum: Some(vec![serde_json::Value::String(variant_key)]), + ..Schema::string() + } + } + syn::Fields::Unnamed(fields_unnamed) => { + // Tuple variant: {"VariantName": } + let data_schema = if fields_unnamed.unnamed.len() == 1 { + let inner_type = &fields_unnamed.unnamed[0].ty; + parse_type_to_schema_ref(inner_type, known_schemas, struct_definitions) + } else { + // Multiple fields - array with prefixItems + let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); + for field in &fields_unnamed.unnamed { + let field_schema = + parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); + tuple_item_schemas.push(field_schema); + } + let tuple_len = tuple_item_schemas.len(); + SchemaRef::Inline(Box::new(Schema { + prefix_items: Some(tuple_item_schemas), + min_items: Some(tuple_len), + max_items: Some(tuple_len), + items: None, + ..Schema::new(SchemaType::Array) + })) + }; + + let mut properties = BTreeMap::new(); + properties.insert(variant_key.clone(), data_schema); + + Schema { + description: variant_description, + properties: Some(properties), + required: Some(vec![variant_key]), + ..Schema::object() + } + } + syn::Fields::Named(fields_named) => { + // Struct variant: {"VariantName": {field1: type1, ...}} + let (inner_properties, inner_required) = build_struct_variant_properties( + fields_named, + rename_all, + &variant.attrs, + known_schemas, + struct_definitions, + ); + + let inner_struct_schema = Schema { + properties: if inner_properties.is_empty() { + None + } else { + Some(inner_properties) + }, + required: if inner_required.is_empty() { + None + } else { + Some(inner_required) + }, + ..Schema::object() + }; + + let mut properties = BTreeMap::new(); + properties.insert( + variant_key.clone(), + SchemaRef::Inline(Box::new(inner_struct_schema)), + ); + + Schema { + description: variant_description, + properties: Some(properties), + required: Some(vec![variant_key]), + ..Schema::object() + } + } + }; + + one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); + } + + Schema { + schema_type: None, + description, + one_of: if one_of_schemas.is_empty() { + None + } else { + Some(one_of_schemas) + }, + ..Schema::new(SchemaType::Object) + } +} + +/// Parse internally tagged enum: `{"tag": "VariantName", ...fields...}` +/// Uses `OpenAPI` discriminator for the tag field. +/// Note: serde only allows struct and unit variants for internally tagged enums. +pub(super) fn parse_internally_tagged_enum( + enum_item: &syn::ItemEnum, + description: Option, + rename_all: Option<&str>, + tag: &str, + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, +) -> Schema { + let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); + + let tag_string = tag.to_string(); + + for variant in &enum_item.variants { + let variant_key = get_variant_key(variant, rename_all); + let variant_description = extract_doc_comment(&variant.attrs); + + let variant_schema = match &variant.fields { + syn::Fields::Unit => { + // Unit variant: {"tag": "VariantName"} + let mut properties = BTreeMap::new(); + properties.insert( + tag_string.clone(), + SchemaRef::Inline(Box::new(Schema { + r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), + ..Schema::string() + })), + ); + + Schema { + description: variant_description, + properties: Some(properties), + required: Some(vec![tag_string.clone()]), + ..Schema::object() + } + } + syn::Fields::Named(fields_named) => { + // Struct variant: {"tag": "VariantName", field1: type1, ...} + let (mut properties, mut required) = build_struct_variant_properties( + fields_named, + rename_all, + &variant.attrs, + known_schemas, + struct_definitions, + ); + + // Add the tag field + properties.insert( + tag_string.clone(), + SchemaRef::Inline(Box::new(Schema { + r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), + ..Schema::string() + })), + ); + required.insert(0, tag_string.clone()); + + Schema { + description: variant_description, + properties: Some(properties), + required: Some(required), + ..Schema::object() + } + } + syn::Fields::Unnamed(_) => { + // Tuple/newtype variants are not supported with internally tagged enums in serde + // Generate a warning schema or skip + continue; + } + }; + + one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); + } + + Schema { + schema_type: None, + description, + one_of: if one_of_schemas.is_empty() { + None + } else { + Some(one_of_schemas) + }, + discriminator: Some(Discriminator { + property_name: tag_string, + mapping: None, // Mapping not needed for inline schemas + }), + ..Default::default() + } +} + +/// Parse adjacently tagged enum: `{"tag": "VariantName", "content": {...}}` +/// Uses `OpenAPI` discriminator for the tag field. +pub(super) fn parse_adjacently_tagged_enum( + enum_item: &syn::ItemEnum, + description: Option, + rename_all: Option<&str>, + tag: &str, + content: &str, + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, +) -> Schema { + let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); + + let tag_string = tag.to_string(); + let content_string = content.to_string(); + + for variant in &enum_item.variants { + let variant_key = get_variant_key(variant, rename_all); + let variant_description = extract_doc_comment(&variant.attrs); + + let mut properties = BTreeMap::new(); + let mut required = vec![tag_string.clone()]; + + // Add the tag field + properties.insert( + tag_string.clone(), + SchemaRef::Inline(Box::new(Schema { + r#enum: Some(vec![serde_json::Value::String(variant_key.clone())]), + ..Schema::string() + })), + ); + + // Add the content field if variant has data + if let Some(data_schema) = + build_variant_data_schema(variant, rename_all, known_schemas, struct_definitions) + { + properties.insert(content_string.clone(), data_schema); + required.push(content_string.clone()); + } + + let variant_schema = Schema { + description: variant_description, + properties: Some(properties), + required: Some(required), + ..Schema::object() + }; + + one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); + } + + Schema { + schema_type: None, + description, + one_of: if one_of_schemas.is_empty() { + None + } else { + Some(one_of_schemas) + }, + discriminator: Some(Discriminator { + property_name: tag_string, + mapping: None, + }), + ..Default::default() + } +} + +/// Parse untagged enum: variant data only, no tag. +/// Uses oneOf without discriminator - validation relies on schema structure matching. +pub(super) fn parse_untagged_enum( + enum_item: &syn::ItemEnum, + description: Option, + rename_all: Option<&str>, + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, +) -> Schema { + let mut one_of_schemas = Vec::with_capacity(enum_item.variants.len()); + + for variant in &enum_item.variants { + let variant_description = extract_doc_comment(&variant.attrs); + + let variant_schema = match &variant.fields { + syn::Fields::Unit => { + // Unit variant in untagged enum: null + Schema { + description: variant_description, + schema_type: Some(SchemaType::Null), + ..Default::default() + } + } + syn::Fields::Unnamed(fields_unnamed) => { + if fields_unnamed.unnamed.len() == 1 { + // Single field tuple variant - just the inner type + let inner_type = &fields_unnamed.unnamed[0].ty; + let mut schema = match parse_type_to_schema_ref( + inner_type, + known_schemas, + struct_definitions, + ) { + SchemaRef::Inline(s) => *s, + SchemaRef::Ref(r) => Schema { + all_of: Some(vec![SchemaRef::Ref(r)]), + ..Default::default() + }, + }; + schema.description = variant_description.or(schema.description); + schema + } else { + // Multiple fields - array with prefixItems + let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); + for field in &fields_unnamed.unnamed { + let field_schema = + parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); + tuple_item_schemas.push(field_schema); + } + let tuple_len = tuple_item_schemas.len(); + Schema { + description: variant_description, + prefix_items: Some(tuple_item_schemas), + min_items: Some(tuple_len), + max_items: Some(tuple_len), + items: None, + ..Schema::new(SchemaType::Array) + } + } + } + syn::Fields::Named(fields_named) => { + // Struct variant - just the object with fields + let (properties, required) = build_struct_variant_properties( + fields_named, + rename_all, + &variant.attrs, + known_schemas, + struct_definitions, + ); + + Schema { + description: variant_description, + properties: if properties.is_empty() { + None + } else { + Some(properties) + }, + required: if required.is_empty() { + None + } else { + Some(required) + }, + ..Schema::object() + } + } + }; + + one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema))); + } + + Schema { + schema_type: None, + description, + one_of: if one_of_schemas.is_empty() { + None + } else { + Some(one_of_schemas) + }, + ..Default::default() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/vespera_macro/src/parser/schema/enum_schema/representations/tests.rs b/crates/vespera_macro/src/parser/schema/enum_schema/representations/tests.rs new file mode 100644 index 00000000..e8634b28 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/enum_schema/representations/tests.rs @@ -0,0 +1,616 @@ +use std::collections::{HashMap, HashSet}; + +use crate::parser::schema::enum_schema::parse_enum_to_schema; +use insta::{assert_debug_snapshot, with_settings}; +use vespera_core::schema::{SchemaRef, SchemaType}; + +// Internally tagged enum tests +#[test] +fn test_internally_tagged_enum_unit_variants() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type")] + enum Message { + Ping, + Pong, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + // Should have discriminator + let discriminator = schema + .discriminator + .as_ref() + .expect("discriminator missing"); + assert_eq!(discriminator.property_name, "type"); + + // Should have oneOf + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Each variant should be an object with "type" property + if let SchemaRef::Inline(ping) = &one_of[0] { + let props = ping.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("type")); + let required = ping.required.as_ref().expect("required missing"); + assert!(required.contains(&"type".to_string())); + } else { + panic!("Expected inline schema"); + } +} + +#[test] +fn test_internally_tagged_enum_struct_variants() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "kind")] + enum Event { + Created { id: i32, name: String }, + Updated { id: i32 }, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + // Should have discriminator with custom tag name + let discriminator = schema + .discriminator + .as_ref() + .expect("discriminator missing"); + assert_eq!(discriminator.property_name, "kind"); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Created variant should have kind, id, and name + if let SchemaRef::Inline(created) = &one_of[0] { + let props = created.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("kind")); + assert!(props.contains_key("id")); + assert!(props.contains_key("name")); + } else { + panic!("Expected inline schema"); + } +} + +#[test] +fn test_internally_tagged_enum_with_rename_all() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type", rename_all = "snake_case")] + enum Status { + ActiveUser, + InactiveUser, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + let one_of = schema.one_of.expect("one_of missing"); + if let SchemaRef::Inline(active) = &one_of[0] { + let props = active.properties.as_ref().expect("properties missing"); + if let SchemaRef::Inline(type_schema) = props.get("type").expect("type missing") { + let enum_vals = type_schema.r#enum.as_ref().expect("enum values missing"); + assert_eq!(enum_vals[0].as_str().unwrap(), "active_user"); + } + } +} + +// Adjacently tagged enum tests +#[test] +fn test_adjacently_tagged_enum_basic() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type", content = "data")] + enum Response { + Success { result: String }, + Error { message: String }, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + // Should have discriminator + let discriminator = schema + .discriminator + .as_ref() + .expect("discriminator missing"); + assert_eq!(discriminator.property_name, "type"); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Each variant should have "type" and "data" properties + if let SchemaRef::Inline(success) = &one_of[0] { + let props = success.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("type")); + assert!(props.contains_key("data")); + + let required = success.required.as_ref().expect("required missing"); + assert!(required.contains(&"type".to_string())); + assert!(required.contains(&"data".to_string())); + } else { + panic!("Expected inline schema"); + } +} + +#[test] +fn test_adjacently_tagged_enum_with_unit_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type", content = "payload")] + enum Command { + Ping, + Message { text: String }, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Ping (unit variant) should only have "type", no "payload" + if let SchemaRef::Inline(ping) = &one_of[0] { + let props = ping.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("type")); + assert!(!props.contains_key("payload")); // Unit variant has no content + + let required = ping.required.as_ref().expect("required missing"); + assert_eq!(required.len(), 1); // Only "type" is required + assert!(required.contains(&"type".to_string())); + } + + // Message should have both "type" and "payload" + if let SchemaRef::Inline(message) = &one_of[1] { + let props = message.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("type")); + assert!(props.contains_key("payload")); + } +} + +#[test] +fn test_adjacently_tagged_enum_tuple_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "t", content = "c")] + enum Value { + Int(i32), + Pair(i32, String), + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Int variant - content should be integer schema + if let SchemaRef::Inline(int_variant) = &one_of[0] { + let props = int_variant.properties.as_ref().expect("properties missing"); + let content = props.get("c").expect("content missing"); + if let SchemaRef::Inline(content_schema) = content { + assert_eq!(content_schema.schema_type, Some(SchemaType::Integer)); + } + } + + // Pair variant - content should be array with prefixItems + if let SchemaRef::Inline(pair_variant) = &one_of[1] { + let props = pair_variant + .properties + .as_ref() + .expect("properties missing"); + let content = props.get("c").expect("content missing"); + if let SchemaRef::Inline(content_schema) = content { + assert_eq!(content_schema.schema_type, Some(SchemaType::Array)); + assert!(content_schema.prefix_items.is_some()); + } + } +} + +// Untagged enum tests +#[test] +fn test_untagged_enum_basic() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum StringOrInt { + String(String), + Int(i32), + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + // Should NOT have discriminator + assert!(schema.discriminator.is_none()); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // First variant should be string schema directly (not wrapped in object) + if let SchemaRef::Inline(string_variant) = &one_of[0] { + assert_eq!(string_variant.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline schema"); + } + + // Second variant should be integer schema directly + if let SchemaRef::Inline(int_variant) = &one_of[1] { + assert_eq!(int_variant.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline schema"); + } +} + +#[test] +fn test_untagged_enum_struct_variants() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum Data { + User { name: String, age: i32 }, + Product { title: String, price: f64 }, + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + assert!(schema.discriminator.is_none()); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // User variant should be object with name and age (no wrapper) + if let SchemaRef::Inline(user) = &one_of[0] { + assert_eq!(user.schema_type, Some(SchemaType::Object)); + let props = user.properties.as_ref().expect("properties missing"); + assert!(props.contains_key("name")); + assert!(props.contains_key("age")); + } +} + +#[test] +fn test_untagged_enum_unit_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum MaybeValue { + Nothing, + Something(i32), + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Unit variant in untagged enum should be null + if let SchemaRef::Inline(nothing) = &one_of[0] { + assert_eq!(nothing.schema_type, Some(SchemaType::Null)); + } +} + +// Snapshot tests for new representations +#[test] +fn test_internally_tagged_snapshot() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type")] + enum Message { + Request { id: i32, method: String }, + Response { id: i32, result: Option }, + Notification, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + with_settings!({ snapshot_path => "../../snapshots", snapshot_suffix => "internally_tagged" }, { + assert_debug_snapshot!(schema); + }); +} + +#[test] +fn test_adjacently_tagged_snapshot() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type", content = "data")] + enum ApiResponse { + Success { items: Vec }, + Error { code: i32, message: String }, + Empty, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + with_settings!({ snapshot_path => "../../snapshots", snapshot_suffix => "adjacently_tagged" }, { + assert_debug_snapshot!(schema); + }); +} + +#[test] +fn test_untagged_snapshot() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum Value { + Null, + Bool(bool), + Number(f64), + Text(String), + Object { key: String, value: String }, + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + with_settings!({ snapshot_path => "../../snapshots", snapshot_suffix => "untagged" }, { + assert_debug_snapshot!(schema); + }); +} + +// Edge case: Empty struct variant (empty properties/required) +#[test] +fn test_externally_tagged_empty_struct_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + enum Event { + /// Empty struct variant + Empty {}, + Data { value: i32 }, + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + let one_of = schema.clone().one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // Empty variant should have properties with Empty key pointing to object with no properties + if let SchemaRef::Inline(empty_variant) = &one_of[0] { + let props = empty_variant + .properties + .as_ref() + .expect("variant props missing"); + let SchemaRef::Inline(inner) = props.get("Empty").expect("Empty key missing") else { + panic!("Expected inline schema") + }; + // Empty struct should have properties: None and required: None + assert!(inner.properties.is_none()); + assert!(inner.required.is_none()); + } + + with_settings!({ snapshot_path => "../../snapshots", snapshot_suffix => "externally_tagged_empty_struct" }, { + assert_debug_snapshot!(schema); + }); +} + +// Edge case: Internally tagged enum with tuple variant +#[test] +fn test_internally_tagged_skips_tuple_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(tag = "type")] + enum Message { + Text { content: String }, + Number(i32), + Empty, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + // Tuple variant `Number(i32)` should be skipped, only 2 variants should remain + let one_of = schema.clone().one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); // Text and Empty only + + // Verify discriminator is present + let discriminator = schema + .discriminator + .as_ref() + .expect("discriminator missing"); + assert_eq!(discriminator.property_name, "type"); + + with_settings!({ snapshot_path => "../../snapshots", snapshot_suffix => "internally_tagged_skip_tuple" }, { + assert_debug_snapshot!(schema); + }); +} + +// Edge case: Untagged enum with tuple variant referencing a known schema +#[test] +fn test_untagged_tuple_variant_with_known_schema_ref() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum Payload { + User(UserData), + Simple(String), + } + ", + ) + .unwrap(); + + // Provide UserData as a known schema so it returns SchemaRef::Ref + let mut known_schemas = HashSet::new(); + known_schemas.insert("UserData".to_string()); + + let schema = parse_enum_to_schema( + &enum_item, + &known_schemas, + &HashMap::::new(), + ); + + assert!(schema.discriminator.is_none()); + + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 2); + + // First variant (UserData) should have all_of with a $ref since it's a known schema + if let SchemaRef::Inline(user_variant) = &one_of[0] { + // The schema should have all_of containing the reference + let all_of = user_variant + .all_of + .as_ref() + .expect("all_of missing for known schema ref"); + assert_eq!(all_of.len(), 1); + if let SchemaRef::Ref(reference) = &all_of[0] { + assert!(reference.ref_path.contains("UserData")); + } else { + panic!("Expected SchemaRef::Ref inside all_of"); + } + } else { + panic!("Expected inline schema"); + } + + // Second variant (String) should be inline string schema directly + if let SchemaRef::Inline(simple_variant) = &one_of[1] { + assert_eq!(simple_variant.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline schema"); + } +} + +// Edge case: Untagged enum with multi-field tuple variant +#[test] +fn test_untagged_multi_field_tuple_variant() { + let enum_item: syn::ItemEnum = syn::parse_str( + r" + #[serde(untagged)] + enum Message { + Text(String), + Pair(i32, String), + Triple(i32, String, bool), + } + ", + ) + .unwrap(); + + let schema = parse_enum_to_schema( + &enum_item, + &HashSet::::new(), + &HashMap::::new(), + ); + + assert!(schema.discriminator.is_none()); + + let one_of = schema.clone().one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 3); + + // Single-field tuple should be string schema directly + if let SchemaRef::Inline(text_variant) = &one_of[0] { + assert_eq!(text_variant.schema_type, Some(SchemaType::String)); + } + + // Multi-field tuple (Pair) should be array with prefixItems + if let SchemaRef::Inline(pair_variant) = &one_of[1] { + assert_eq!(pair_variant.schema_type, Some(SchemaType::Array)); + let prefix_items = pair_variant + .prefix_items + .as_ref() + .expect("prefix_items missing for Pair"); + assert_eq!(prefix_items.len(), 2); + assert_eq!(pair_variant.min_items, Some(2)); + assert_eq!(pair_variant.max_items, Some(2)); + } + + // Multi-field tuple (Triple) should be array with 3 prefixItems + if let SchemaRef::Inline(triple_variant) = &one_of[2] { + assert_eq!(triple_variant.schema_type, Some(SchemaType::Array)); + let prefix_items = triple_variant + .prefix_items + .as_ref() + .expect("prefix_items missing for Triple"); + assert_eq!(prefix_items.len(), 3); + assert_eq!(triple_variant.min_items, Some(3)); + assert_eq!(triple_variant.max_items, Some(3)); + } + + with_settings!({ snapshot_path => "../../snapshots", snapshot_suffix => "untagged_multi_field_tuple" }, { + assert_debug_snapshot!(schema); + }); +} diff --git a/crates/vespera_macro/src/parser/schema/enum_schema/unit.rs b/crates/vespera_macro/src/parser/schema/enum_schema/unit.rs new file mode 100644 index 00000000..8fe93565 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/enum_schema/unit.rs @@ -0,0 +1,40 @@ +use vespera_core::schema::{Schema, SchemaType}; + +use super::super::serde_attrs::{extract_field_rename, rename_field, strip_raw_prefix_owned}; + +/// Parse a simple enum (all unit variants) to a string schema with enum values. +pub(super) fn parse_unit_enum_to_schema( + enum_item: &syn::ItemEnum, + description: Option, + rename_all: Option<&str>, +) -> Schema { + let mut enum_values = Vec::with_capacity(enum_item.variants.len()); + + for variant in &enum_item.variants { + let variant_name = strip_raw_prefix_owned(variant.ident.to_string()); + + // Check for variant-level rename attribute first (takes precedence) + let enum_value = extract_field_rename(&variant.attrs) + .unwrap_or_else(|| rename_field(&variant_name, rename_all)); + + enum_values.push(serde_json::Value::String(enum_value)); + } + + Schema { + schema_type: Some(SchemaType::String), + description, + r#enum: if enum_values.is_empty() { + None + } else { + Some(enum_values) + }, + ..Schema::string() + } +} + +/// Get the variant key (name after rename transformations) +pub(super) fn get_variant_key(variant: &syn::Variant, rename_all: Option<&str>) -> String { + let variant_name = strip_raw_prefix_owned(variant.ident.to_string()); + + extract_field_rename(&variant.attrs).unwrap_or_else(|| rename_field(&variant_name, rename_all)) +} diff --git a/crates/vespera_macro/src/parser/schema/enum_schema/variant.rs b/crates/vespera_macro/src/parser/schema/enum_schema/variant.rs new file mode 100644 index 00000000..771f68ea --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/enum_schema/variant.rs @@ -0,0 +1,144 @@ +use std::{ + borrow::Borrow, + collections::{BTreeMap, HashMap, HashSet}, + hash::Hash, +}; + +use vespera_core::schema::{Schema, SchemaRef, SchemaType}; + +use super::super::{ + serde_attrs::{ + extract_doc_comment, extract_field_rename, extract_rename_all, rename_field, + strip_raw_prefix_owned, + }, + type_schema::parse_type_to_schema_ref, +}; +use crate::schema_macro::type_utils::is_option_type; + +/// Build properties for a struct variant's fields +pub(super) fn build_struct_variant_properties( + fields_named: &syn::FieldsNamed, + enum_rename_all: Option<&str>, + variant_attrs: &[syn::Attribute], + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, +) -> (BTreeMap, Vec) { + let mut variant_properties = BTreeMap::new(); + let mut variant_required = Vec::with_capacity(fields_named.named.len()); + let variant_rename_all = extract_rename_all(variant_attrs); + + for field in &fields_named.named { + let rust_field_name = field.ident.as_ref().map_or_else( + || "unknown".to_string(), + |i| strip_raw_prefix_owned(i.to_string()), + ); + + // Check for field-level rename attribute first (takes precedence) + let field_name = extract_field_rename(&field.attrs).unwrap_or_else(|| { + rename_field( + &rust_field_name, + variant_rename_all.as_deref().or(enum_rename_all), + ) + }); + + let field_type = &field.ty; + let mut schema_ref = + parse_type_to_schema_ref(field_type, known_schemas, struct_definitions); + + // Extract doc comment from field and set as description + if let Some(doc) = extract_doc_comment(&field.attrs) { + match &mut schema_ref { + SchemaRef::Inline(schema) => { + schema.description = Some(doc); + } + SchemaRef::Ref(_) => { + let ref_schema = std::mem::replace( + &mut schema_ref, + SchemaRef::Inline(Box::new(Schema::object())), + ); + if let SchemaRef::Ref(reference) = ref_schema { + schema_ref = SchemaRef::Inline(Box::new(Schema { + description: Some(doc), + all_of: Some(vec![SchemaRef::Ref(reference)]), + ..Default::default() + })); + } + } + } + } + + variant_properties.insert(field_name.clone(), schema_ref); + + // Check if field is Option + let is_optional = is_option_type(field_type); + + if !is_optional { + variant_required.push(field_name); + } + } + + (variant_properties, variant_required) +} + +/// Build a schema for a variant's data (tuple or struct fields) +pub(super) fn build_variant_data_schema( + variant: &syn::Variant, + enum_rename_all: Option<&str>, + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, +) -> Option { + match &variant.fields { + syn::Fields::Unit => None, + syn::Fields::Unnamed(fields_unnamed) => { + if fields_unnamed.unnamed.len() == 1 { + // Single field tuple variant - just the inner type + let inner_type = &fields_unnamed.unnamed[0].ty; + Some(parse_type_to_schema_ref( + inner_type, + known_schemas, + struct_definitions, + )) + } else { + // Multiple fields tuple variant - array with prefixItems + let mut tuple_item_schemas = Vec::with_capacity(fields_unnamed.unnamed.len()); + for field in &fields_unnamed.unnamed { + let field_schema = + parse_type_to_schema_ref(&field.ty, known_schemas, struct_definitions); + tuple_item_schemas.push(field_schema); + } + + let tuple_len = tuple_item_schemas.len(); + Some(SchemaRef::Inline(Box::new(Schema { + prefix_items: Some(tuple_item_schemas), + min_items: Some(tuple_len), + max_items: Some(tuple_len), + items: None, + ..Schema::new(SchemaType::Array) + }))) + } + } + syn::Fields::Named(fields_named) => { + let (properties, required) = build_struct_variant_properties( + fields_named, + enum_rename_all, + &variant.attrs, + known_schemas, + struct_definitions, + ); + + Some(SchemaRef::Inline(Box::new(Schema { + properties: if properties.is_empty() { + None + } else { + Some(properties) + }, + required: if required.is_empty() { + None + } else { + Some(required) + }, + ..Schema::object() + }))) + } + } +} diff --git a/crates/vespera_macro/src/parser/schema/mod.rs b/crates/vespera_macro/src/parser/schema/mod.rs index 55990ad9..0e857d25 100644 --- a/crates/vespera_macro/src/parser/schema/mod.rs +++ b/crates/vespera_macro/src/parser/schema/mod.rs @@ -24,7 +24,7 @@ //! //! # Key Functions //! -//! - [`parse_type_to_schema_ref`] - Convert any Rust type to `SchemaRef` +//! - `parse_type_to_schema_ref` - Convert any Rust type to `SchemaRef` //! - [`parse_struct_to_schema`] - Convert struct to JSON Schema object //! - [`parse_enum_to_schema`] - Convert enum to JSON Schema (oneOf or enum array) //! - [`extract_rename_all`] - Extract serde `rename_all` attribute @@ -39,10 +39,10 @@ mod type_schema; // Re-export public API pub use enum_schema::parse_enum_to_schema; pub use serde_attrs::{ - extract_default, extract_field_rename, extract_rename_all, extract_skip, - extract_skip_serializing_if, rename_field, strip_raw_prefix_owned, + extract_default, extract_field_rename, extract_rename_all, extract_skip, rename_field, + strip_raw_prefix_owned, }; pub use struct_schema::parse_struct_to_schema; -pub use type_schema::parse_type_to_schema_ref; -// Re-export for internal use within parser module +// Re-export for internal use within the parser module. `parse_type_to_schema_ref` +// is reached directly via the `type_schema` submodule path where needed. pub use type_schema::{is_primitive_type, parse_type_to_schema_ref_with_schemas}; diff --git a/crates/vespera_macro/src/parser/schema/schema_attrs.rs b/crates/vespera_macro/src/parser/schema/schema_attrs.rs index 3e84e0ec..2d37f7d3 100644 --- a/crates/vespera_macro/src/parser/schema/schema_attrs.rs +++ b/crates/vespera_macro/src/parser/schema/schema_attrs.rs @@ -140,15 +140,24 @@ impl SchemaConstraints { /// /// Unknown keys are **silently ignored** so that struct-level keys /// (`name`, `ref`, `nullable`) and future additions don't break this -/// parser when it walks a struct-level `#[schema(...)]` attribute. +/// parser when it walks a struct-level `#[schema(...)]` attribute. A +/// **recognized** key with a malformed value is an error: silently dropping +/// known constraints makes both OpenAPI output and generated garde validators +/// lie about user intent. #[must_use] pub fn extract_schema_constraints(attrs: &[Attribute]) -> SchemaConstraints { + try_extract_schema_constraints(attrs).unwrap_or_default() +} + +/// Fallible variant used by macro entry points to emit `compile_error!` for +/// malformed values of known `#[schema(...)]` keys. +pub fn try_extract_schema_constraints(attrs: &[Attribute]) -> syn::Result { let mut out = SchemaConstraints::default(); for attr in attrs { if !attr.path().is_ident("schema") { continue; } - let _ = attr.parse_nested_meta(|meta| { + attr.parse_nested_meta(|meta| { // ── string / array length ──────────────────────────────── if meta.path.is_ident("min_length") { out.min_length = Some(parse_usize(&meta)?); @@ -204,9 +213,9 @@ pub fn extract_schema_constraints(attrs: &[Attribute]) -> SchemaConstraints { } } Ok(()) - }); + })?; } - out + Ok(out) } // ── primitive value helpers ────────────────────────────────────────── @@ -336,7 +345,11 @@ mod tests { use syn::parse_quote; fn parse(attrs: &[Attribute]) -> SchemaConstraints { - extract_schema_constraints(attrs) + try_extract_schema_constraints(attrs).expect("schema attrs parse") + } + + fn parse_err(attrs: &[Attribute]) -> syn::Error { + try_extract_schema_constraints(attrs).expect_err("schema attrs must fail") } #[test] @@ -536,65 +549,47 @@ mod tests { } #[test] - fn negative_non_literal_minimum_is_silently_ignored() { - // parse_f64 rejects non-literal expressions after `-`. The - // outer parse_nested_meta swallows the syn::Error so the - // overall constraint set remains empty. - let c = parse(&[parse_quote!(#[schema(minimum = -CONST)])]); - assert_eq!(c.minimum, None); + fn negative_non_literal_minimum_is_rejected() { + let err = parse_err(&[parse_quote!(#[schema(minimum = -CONST)])]); + assert!(err.to_string().contains("expected a numeric literal")); } #[test] - fn non_unary_non_lit_minimum_expr_is_silently_ignored() { - // Anything that is neither a literal nor a unary `-` literal - // (here: a function call) goes to the `other => Err(...)` - // arm at the bottom of parse_f64. - let c = parse(&[parse_quote!(#[schema(minimum = foo())])]); - assert_eq!(c.minimum, None); + fn non_unary_non_lit_minimum_expr_is_rejected() { + let err = parse_err(&[parse_quote!(#[schema(minimum = foo())])]); + assert!(err.to_string().contains("expected a numeric literal")); } #[test] - fn non_neg_unary_minimum_expr_is_silently_ignored() { - // `!x` is a unary op but not `Neg` — hits the inner fallback - // inside the unary arm of parse_f64. - let c = parse(&[parse_quote!(#[schema(minimum = !5)])]); - assert_eq!(c.minimum, None); + fn non_neg_unary_minimum_expr_is_rejected() { + let err = parse_err(&[parse_quote!(#[schema(minimum = !5)])]); + assert!(err.to_string().contains("expected a numeric literal")); } #[test] - fn negative_non_numeric_literal_minimum_is_silently_ignored() { - // `-true` and `-"x"` are unary-neg of non-numeric literals. - // Drives the `other =>` arm inside parse_f64's unary branch - // (after the Int/Float arms). - let c1 = parse(&[parse_quote!(#[schema(minimum = -true)])]); - assert_eq!(c1.minimum, None); - let c2 = parse(&[parse_quote!(#[schema(minimum = -"x")])]); - assert_eq!(c2.minimum, None); + fn negative_non_numeric_literal_minimum_is_rejected() { + let c1 = parse_err(&[parse_quote!(#[schema(minimum = -true)])]); + assert!(c1.to_string().contains("expected a numeric literal")); + let c2 = parse_err(&[parse_quote!(#[schema(minimum = -"x")])]); + assert!(c2.to_string().contains("expected a numeric literal")); } #[test] - fn example_negative_non_lit_is_silently_ignored() { - // `example = -CONST` — the inner literal isn't a number, so - // expr_to_json_value's "negate a literal" branch falls - // through to the trailing Err. - let c = parse(&[parse_quote!(#[schema(example = -CONST)])]); - assert_eq!(c.example, None); + fn example_negative_non_lit_is_rejected() { + let err = parse_err(&[parse_quote!(#[schema(example = -CONST)])]); + assert!(err.to_string().contains("expected a literal")); } #[test] - fn example_non_lit_non_path_is_silently_ignored() { - // Function-call expression — neither a literal, a unary - // negation of a literal, nor the special `null` path. - let c = parse(&[parse_quote!(#[schema(example = some_fn())])]); - assert_eq!(c.example, None); + fn example_non_lit_non_path_is_rejected() { + let err = parse_err(&[parse_quote!(#[schema(example = some_fn())])]); + assert!(err.to_string().contains("expected a literal value")); } #[test] - fn example_byte_string_literal_is_silently_ignored() { - // Byte-string literals fall through `lit_to_json_value`'s - // explicit match arms to the `other => Err(...)` fallback. - let c = parse(&[parse_quote!(#[schema(example = b"bytes")])]); - assert_eq!(c.example, None); + fn example_byte_string_literal_is_rejected() { + let err = parse_err(&[parse_quote!(#[schema(example = b"bytes")])]); + assert!(err.to_string().contains("unsupported literal type")); } #[test] diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs.rs b/crates/vespera_macro/src/parser/schema/serde_attrs.rs index 1592d46d..a98b7ef1 100644 --- a/crates/vespera_macro/src/parser/schema/serde_attrs.rs +++ b/crates/vespera_macro/src/parser/schema/serde_attrs.rs @@ -1,2192 +1,17 @@ -//! Serde attribute extraction utilities for `OpenAPI` schema generation. -//! -//! This module provides functions to extract serde attributes from Rust types -//! to properly generate `OpenAPI` schemas that respect serialization rules. - -/// Extract doc comments from attributes. -/// Returns concatenated doc comment string or None if no doc comments. -pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { - let mut doc_lines = Vec::new(); - - for attr in attrs { - if attr.path().is_ident("doc") - && let syn::Meta::NameValue(meta_nv) = &attr.meta - && let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(lit_str), - .. - }) = &meta_nv.value - { - let line = lit_str.value(); - // Strip `" / "` or `"/ "` prefixes that can appear when doc-comment - // markers leak through TokenStream → string → parse roundtrips, - // then trim any remaining whitespace. - let trimmed = line - .strip_prefix(" / ") - .or_else(|| line.strip_prefix("/ ")) - .unwrap_or(&line) - .trim(); - doc_lines.push(trimmed.to_string()); - } - } - - if doc_lines.is_empty() { - None - } else { - Some(doc_lines.join("\n")) - } -} - -/// Strips the `r#` prefix from raw identifiers, returning an owned `String`. -/// For the 99% case (no `r#` prefix), returns the input directly with zero extra allocation. -#[allow(clippy::option_if_let_else)] // clippy suggestion doesn't compile: borrow-move conflict -pub fn strip_raw_prefix_owned(ident: String) -> String { - if let Some(stripped) = ident.strip_prefix("r#") { - stripped.to_string() - } else { - ident - } -} - -pub use crate::schema_macro::type_utils::capitalize_first; - -/// Extract a Schema name from a `SeaORM` Entity type path. -/// -/// Converts paths like: -/// - `super::user::Entity` -> "User" -/// - `crate::models::memo::Entity` -> "Memo" -/// -/// The schema name is derived from the module containing Entity, -/// converted to `PascalCase` (first letter uppercase). -pub fn extract_schema_name_from_entity(ty: &syn::Type) -> Option { - match ty { - syn::Type::Path(type_path) => { - let segments: Vec<_> = type_path.path.segments.iter().collect(); - - // Need at least 2 segments: module::Entity - if segments.len() < 2 { - return None; - } - - // Check if last segment is "Entity" - let last = segments.last()?; - if last.ident != "Entity" { - return None; - } - - // Get the second-to-last segment (module name) - let module_segment = segments.get(segments.len() - 2)?; - let module_name = module_segment.ident.to_string(); - - // Convert to PascalCase (capitalize first letter) - // Rust identifiers are guaranteed non-empty, so chars().next() always returns Some - let schema_name = capitalize_first(&module_name); - - Some(schema_name) - } - _ => None, - } -} - -pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { - // First check serde attrs (higher priority) - for attr in attrs { - if attr.path().is_ident("serde") { - // Try using parse_nested_meta for robust parsing - let mut found_rename_all = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("rename_all") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_rename_all = Some(s.value()); - } - Ok(()) - }); - if found_rename_all.is_some() { - return found_rename_all; - } - - // Fallback: manual token parsing for complex attribute combinations - let Ok(tokens) = attr.meta.require_list() else { - continue; - }; - let token_str = tokens.tokens.to_string(); - - // Look for rename_all = "..." pattern - if let Some(start) = token_str.find("rename_all") { - let remaining = &token_str[start + "rename_all".len()..]; - if let Some(equals_pos) = remaining.find('=') { - let value_part = remaining[equals_pos + 1..].trim(); - // Extract string value - find the closing quote - if let Some(quote_start) = value_part.find('"') { - let after_quote = &value_part[quote_start + 1..]; - if let Some(quote_end) = after_quote.find('"') { - let value = &after_quote[..quote_end]; - return Some(value.to_string()); - } - } - } - } - } - } - - // Fallback: check for #[try_from_multipart(rename_all = "...")] - for attr in attrs { - if attr.path().is_ident("try_from_multipart") { - let mut found_rename_all = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("rename_all") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_rename_all = Some(s.value()); - } - Ok(()) - }); - if found_rename_all.is_some() { - return found_rename_all; - } - } - } - - None -} - -/// Extract whether `#[serde(transparent)]` is present on a struct. -pub fn extract_transparent(attrs: &[syn::Attribute]) -> bool { - attrs.iter().any(|attr| { - if !attr.path().is_ident("serde") { - return false; - } - - let mut is_transparent = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("transparent") { - is_transparent = true; - } - Ok(()) - }); - is_transparent - }) -} - -/// Extract `#[schema(ref = "Name", nullable)]` override from a struct. -pub fn extract_schema_ref_override(attrs: &[syn::Attribute]) -> Option<(String, bool)> { - attrs.iter().find_map(|attr| { - if !attr.path().is_ident("schema") { - return None; - } - - let mut ref_name = None; - let mut nullable = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("ref") { - let value = meta.value()?; - let lit: syn::LitStr = value.parse()?; - ref_name = Some(lit.value()); - } else if meta.path.is_ident("nullable") { - nullable = true; - } - Ok(()) - }); - - ref_name.map(|name| (name, nullable)) - }) -} - -pub fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { - // First check serde attrs (higher priority) - for attr in attrs { - if attr.path().is_ident("serde") - && let syn::Meta::List(meta_list) = &attr.meta - { - // Use parse_nested_meta to parse nested attributes - let mut found_rename = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("rename") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_rename = Some(s.value()); - } - Ok(()) - }); - if let Some(rename_value) = found_rename { - return Some(rename_value); - } - - // Fallback: manual token parsing for complex attribute combinations - let tokens = meta_list.tokens.to_string(); - // Look for pattern: rename = "value" (with proper word boundaries) - if let Some(start) = tokens.find("rename") { - // Avoid false positives from rename_all - if tokens[start..].starts_with("rename_all") { - continue; - } - // Check that "rename" is a standalone word (not part of another word) - let before = if start > 0 { &tokens[..start] } else { "" }; - let after_start = start + "rename".len(); - let after = if after_start < tokens.len() { - &tokens[after_start..] - } else { - "" - }; - - let before_char = before.chars().last().unwrap_or(' '); - let after_char = after.chars().next().unwrap_or(' '); - - // Check if rename is a standalone word (preceded by space/comma/paren, followed by space/equals) - if (before_char == ' ' || before_char == ',' || before_char == '(') - && (after_char == ' ' || after_char == '=') - { - // Find the equals sign and extract the quoted value - if let Some(equals_pos) = after.find('=') { - let value_part = &after[equals_pos + 1..].trim(); - // Extract string value (remove quotes) - if let Some(quote_start) = value_part.find('"') { - let after_quote = &value_part[quote_start + 1..]; - if let Some(quote_end) = after_quote.find('"') { - let value = &after_quote[..quote_end]; - return Some(value.to_string()); - } - } - } - } - } - } - } - - // Fallback: check for #[form_data(field_name = "...")] - for attr in attrs { - if attr.path().is_ident("form_data") { - let mut found_field_name = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("field_name") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_field_name = Some(s.value()); - } - Ok(()) - }); - if found_field_name.is_some() { - return found_field_name; - } - } - } - - None -} - -/// Extract skip attribute from field attributes -/// Returns true if #[serde(skip)] is present -pub fn extract_skip(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("serde") - && let syn::Meta::List(meta_list) = &attr.meta - { - let tokens = meta_list.tokens.to_string(); - // Check for "skip" (not part of skip_serializing_if or skip_deserializing) - if tokens.contains("skip") { - // Make sure it's not skip_serializing_if or skip_deserializing - if !tokens.contains("skip_serializing_if") && !tokens.contains("skip_deserializing") - { - // Check if it's a standalone "skip" - let skip_pos = tokens.find("skip"); - if let Some(pos) = skip_pos { - let before = if pos > 0 { &tokens[..pos] } else { "" }; - let after = &tokens[pos + "skip".len()..]; - // Check if skip is not part of another word - let before_char = before.chars().last().unwrap_or(' '); - let after_char = after.chars().next().unwrap_or(' '); - if (before_char == ' ' || before_char == ',' || before_char == '(') - && (after_char == ' ' || after_char == ',' || after_char == ')') - { - return true; - } - } - } - } - } - } - false -} - -/// Extract flatten attribute from field attributes -/// Returns true if #[serde(flatten)] is present -pub fn extract_flatten(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("serde") { - // Try using parse_nested_meta for robust parsing - let mut found = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("flatten") { - found = true; - } - Ok(()) - }); - if found { - return true; - } - - // Fallback: manual token parsing for complex attribute combinations - if let syn::Meta::List(meta_list) = &attr.meta { - let tokens = meta_list.tokens.to_string(); - // Check for "flatten" as a standalone word - if let Some(pos) = tokens.find("flatten") { - let before = if pos > 0 { &tokens[..pos] } else { "" }; - let after = &tokens[pos + "flatten".len()..]; - let before_char = before.chars().last().unwrap_or(' '); - let after_char = after.chars().next().unwrap_or(' '); - if (before_char == ' ' || before_char == ',' || before_char == '(') - && (after_char == ' ' || after_char == ',' || after_char == ')') - { - return true; - } - } - } - } - } - false -} - -/// Extract `skip_serializing_if` attribute from field attributes -/// Returns true if #[`serde(skip_serializing_if` = "...")] is present -pub fn extract_skip_serializing_if(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("serde") - && let syn::Meta::List(meta_list) = &attr.meta - { - let mut found = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("skip_serializing_if") { - found = true; - } - Ok(()) - }); - if found { - return true; - } - - // Fallback: check tokens string for complex attribute combinations - let tokens = meta_list.tokens.to_string(); - if tokens.contains("skip_serializing_if") { - return true; - } - } - } - false -} - -/// Check whether the `"default"` substring at index `start` of `tokens` -/// is delimited by valid meta-list separators on both sides (whitespace, -/// `,`, `(`, or `)`). Pulled out of `extract_default` so the fallback -/// path gets its own basic block and shows up cleanly in coverage. -fn is_standalone_default(tokens: &str, start: usize, remaining: &str) -> bool { - let before = if start > 0 { &tokens[..start] } else { "" }; - let before_char = before.chars().last().unwrap_or(' '); - let after_char = remaining.chars().next().unwrap_or(' '); - let before_ok = before_char == ' ' || before_char == ',' || before_char == '('; - let after_ok = after_char == ' ' || after_char == ',' || after_char == ')'; - before_ok && after_ok -} - -/// Extract default attribute from field attributes -/// Returns: -/// - Some(None) if #[serde(default)] is present (no function) -/// - `Some(Some(function_name))` if #[serde(default = "`function_name`")] is present -/// - None if no default attribute is present -#[allow(clippy::option_option)] -pub fn extract_default(attrs: &[syn::Attribute]) -> Option> { - for attr in attrs { - if attr.path().is_ident("serde") - && let syn::Meta::List(meta_list) = &attr.meta - { - let mut found_default: Option> = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("default") { - // Check if it has a value (default = "function_name") - if let Ok(value) = meta.value() { - if let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_default = Some(Some(s.value())); - } - } else { - // Just "default" without value - found_default = Some(None); - } - } - Ok(()) - }); - if found_default.is_none() { - // Fallback: manual token parsing for complex attribute combinations - found_default = scan_default_from_raw_tokens(&meta_list.tokens.to_string()); - } - if let Some(default_value) = found_default { - return Some(default_value); - } - } - } - None -} - -/// Scan `tokens` (the raw `to_string()` rendering of a `#[serde(...)]` -/// argument list) for a `default` keyword that survived the -/// `parse_nested_meta` pass. Returns the same `Option>` -/// shape `extract_default` consumes: -/// - `Some(Some(fn_name))` for `default = "fn_name"` -/// - `Some(None)` for a bare standalone `default` -/// - `None` when no `default` keyword could be confidently identified -/// -/// Pulled out of `extract_default` so the fallback paths each get their -/// own basic block and show up in coverage. -#[allow(clippy::option_option)] -fn scan_default_from_raw_tokens(tokens: &str) -> Option> { - let start = tokens.find("default")?; - let remaining = &tokens[start + "default".len()..]; - if remaining.trim_start().starts_with('=') { - // default = "function_name" - let after_equals = remaining - .trim_start() - .strip_prefix('=') - .unwrap_or("") - .trim_start(); - let quote_start = after_equals.find('"')?; - let after_quote = &after_equals[quote_start + 1..]; - let quote_end = after_quote.find('"')?; - Some(Some(after_quote[..quote_end].to_string())) - } else if is_standalone_default(tokens, start, remaining) { - Some(None) - } else { - None - } -} - -#[allow(clippy::too_many_lines)] -pub fn rename_field(field_name: &str, rename_all: Option<&str>) -> String { - // "lowercase", "UPPERCASE", "PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE" - match rename_all { - Some("camelCase") => { - // Convert snake_case or PascalCase to camelCase - let mut result = String::new(); - let mut capitalize_next = false; - let mut in_first_word = true; - let chars: Vec = field_name.chars().collect(); - - for (i, &ch) in chars.iter().enumerate() { - if ch == '_' { - capitalize_next = true; - in_first_word = false; - continue; - } - if in_first_word { - // In first word: lowercase until we hit a word boundary - // Word boundary: uppercase char followed by lowercase (e.g., "XMLParser" -> "P" starts new word) - let next_is_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase()); - if ch.is_uppercase() && next_is_lower && i > 0 { - // This uppercase starts a new word (e.g., 'P' in "XMLParser") - in_first_word = false; - result.push(ch); - } else { - // Still in first word, lowercase it - result.push(ch.to_ascii_lowercase()); - } - continue; - } - if capitalize_next { - result.push(ch.to_ascii_uppercase()); - capitalize_next = false; - continue; - } - result.push(ch); - } - result - } - Some("snake_case") => { - // Convert camelCase to snake_case - let mut result = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() && i > 0 { - result.push('_'); - } - result.push(ch.to_ascii_lowercase()); - } - result - } - Some("kebab-case") => { - // Convert snake_case or Camel/PascalCase to kebab-case (lowercase with hyphens) - let mut result = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() { - if i > 0 && !result.ends_with('-') { - result.push('-'); - } - result.push(ch.to_ascii_lowercase()); - } else if ch == '_' { - result.push('-'); - } else { - result.push(ch); - } - } - result - } - Some("PascalCase") => { - // Convert snake_case to PascalCase - let mut result = String::new(); - let mut capitalize_next = true; - for ch in field_name.chars() { - if ch == '_' { - capitalize_next = true; - } else if capitalize_next { - result.push(ch.to_ascii_uppercase()); - capitalize_next = false; - } else { - result.push(ch); - } - } - result - } - Some("lowercase") => { - // Convert to lowercase - field_name.to_lowercase() - } - Some("UPPERCASE") => { - // Convert to UPPERCASE - field_name.to_uppercase() - } - Some("SCREAMING_SNAKE_CASE") => { - // Convert to SCREAMING_SNAKE_CASE - // If already in SCREAMING_SNAKE_CASE format, return as is - if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_') - { - return field_name.to_string(); - } - // First convert to snake_case if needed, then uppercase - let mut snake_case = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() && i > 0 && !snake_case.ends_with('_') { - snake_case.push('_'); - } - if ch != '_' && ch != '-' { - snake_case.push(ch.to_ascii_lowercase()); - } else if ch == '_' { - snake_case.push('_'); - } - } - snake_case.to_uppercase() - } - Some("SCREAMING-KEBAB-CASE") => { - // Convert to SCREAMING-KEBAB-CASE - // First convert to kebab-case if needed, then uppercase - let mut kebab_case = String::new(); - for (i, ch) in field_name.chars().enumerate() { - if ch.is_uppercase() - && i > 0 - && !kebab_case.ends_with('-') - && !kebab_case.ends_with('_') - { - kebab_case.push('-'); - } - if ch == '_' { - kebab_case.push('-'); - } else if ch != '-' { - kebab_case.push(ch.to_ascii_lowercase()); - } else { - kebab_case.push('-'); - } - } - kebab_case.to_uppercase() - } - _ => field_name.to_string(), - } -} - -/// Serde enum representation types -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SerdeEnumRepr { - /// Default externally tagged: `{"VariantName": {...}}` - ExternallyTagged, - /// Internally tagged: `{"type": "VariantName", ...fields...}` - /// Only valid for struct and unit variants - InternallyTagged { tag: String }, - /// Adjacently tagged: `{"type": "VariantName", "data": {...}}` - AdjacentlyTagged { tag: String, content: String }, - /// Untagged: `{...fields...}` (no tag, first matching variant wins) - Untagged, -} - -/// Extract serde enum representation from attributes. -/// -/// Detects the enum tagging strategy from serde attributes: -/// - `#[serde(tag = "type")]` → `InternallyTagged` -/// - `#[serde(tag = "type", content = "data")]` → `AdjacentlyTagged` -/// - `#[serde(untagged)]` → Untagged -/// - No relevant attributes → `ExternallyTagged` (default) -pub fn extract_enum_repr(attrs: &[syn::Attribute]) -> SerdeEnumRepr { - let tag = extract_tag(attrs); - let content = extract_content(attrs); - let untagged = extract_untagged(attrs); - - if untagged { - SerdeEnumRepr::Untagged - } else if let Some(tag_name) = tag { - if let Some(content_name) = content { - SerdeEnumRepr::AdjacentlyTagged { - tag: tag_name, - content: content_name, - } - } else { - SerdeEnumRepr::InternallyTagged { tag: tag_name } - } - } else { - SerdeEnumRepr::ExternallyTagged - } -} - -/// Extract tag attribute from serde container attributes -/// Returns the tag name if `#[serde(tag = "...")]` is present -pub fn extract_tag(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if attr.path().is_ident("serde") { - let mut found_tag = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("tag") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_tag = Some(s.value()); - } - Ok(()) - }); - if found_tag.is_some() { - return found_tag; - } - - // Fallback: manual token parsing - let Ok(tokens) = attr.meta.require_list() else { - continue; - }; - let token_str = tokens.tokens.to_string(); - - if let Some(start) = token_str.find("tag") { - // Ensure it's "tag" not "untagged" - let before = if start > 0 { &token_str[..start] } else { "" }; - let before_char = before.chars().last().unwrap_or(' '); - if before_char != 'n' { - // Not "untagged" - let remaining = &token_str[start + "tag".len()..]; - if let Some(equals_pos) = remaining.find('=') { - let value_part = remaining[equals_pos + 1..].trim(); - if let Some(quote_start) = value_part.find('"') { - let after_quote = &value_part[quote_start + 1..]; - if let Some(quote_end) = after_quote.find('"') { - let value = &after_quote[..quote_end]; - return Some(value.to_string()); - } - } - } - } - } - } - } - None -} - -/// Extract content attribute from serde container attributes -/// Returns the content name if `#[serde(content = "...")]` is present -pub fn extract_content(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if attr.path().is_ident("serde") { - let mut found_content = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("content") - && let Ok(value) = meta.value() - && let Ok(syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - })) = value.parse::() - { - found_content = Some(s.value()); - } - Ok(()) - }); - if found_content.is_some() { - return found_content; - } - - // Fallback: manual token parsing - let Ok(tokens) = attr.meta.require_list() else { - continue; - }; - let token_str = tokens.tokens.to_string(); - - if let Some(start) = token_str.find("content") { - let remaining = &token_str[start + "content".len()..]; - if let Some(equals_pos) = remaining.find('=') { - let value_part = remaining[equals_pos + 1..].trim(); - if let Some(quote_start) = value_part.find('"') { - let after_quote = &value_part[quote_start + 1..]; - if let Some(quote_end) = after_quote.find('"') { - let value = &after_quote[..quote_end]; - return Some(value.to_string()); - } - } - } - } - } - } - None -} - -/// Extract untagged attribute from serde container attributes -/// Returns true if `#[serde(untagged)]` is present -pub fn extract_untagged(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if attr.path().is_ident("serde") { - let mut found = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("untagged") { - found = true; - } - Ok(()) - }); - if found { - return true; - } - - // Fallback: manual token parsing - if let syn::Meta::List(meta_list) = &attr.meta { - let tokens = meta_list.tokens.to_string(); - if let Some(pos) = tokens.find("untagged") { - let before = if pos > 0 { &tokens[..pos] } else { "" }; - let after = &tokens[pos + "untagged".len()..]; - let before_char = before.chars().last().unwrap_or(' '); - let after_char = after.chars().next().unwrap_or(' '); - if (before_char == ' ' || before_char == ',' || before_char == '(') - && (after_char == ' ' || after_char == ',' || after_char == ')') - { - return true; - } - } - } - } - } - false -} - -#[cfg(test)] -mod tests { - #![allow(clippy::option_option)] - - use rstest::rstest; - - use super::*; - - #[rstest] - // camelCase tests (snake_case input) - #[case("user_name", Some("camelCase"), "userName")] - #[case("first_name", Some("camelCase"), "firstName")] - #[case("last_name", Some("camelCase"), "lastName")] - #[case("user_id", Some("camelCase"), "userId")] - #[case("api_key", Some("camelCase"), "apiKey")] - #[case("already_camel", Some("camelCase"), "alreadyCamel")] - // camelCase tests (PascalCase input) - #[case("UserName", Some("camelCase"), "userName")] - #[case("UserCreated", Some("camelCase"), "userCreated")] - #[case("FirstName", Some("camelCase"), "firstName")] - #[case("ID", Some("camelCase"), "id")] - #[case("XMLParser", Some("camelCase"), "xmlParser")] - #[case("HTTPSConnection", Some("camelCase"), "httpsConnection")] - // snake_case tests - #[case("userName", Some("snake_case"), "user_name")] - #[case("firstName", Some("snake_case"), "first_name")] - #[case("lastName", Some("snake_case"), "last_name")] - #[case("userId", Some("snake_case"), "user_id")] - #[case("apiKey", Some("snake_case"), "api_key")] - #[case("already_snake", Some("snake_case"), "already_snake")] - // kebab-case tests - #[case("user_name", Some("kebab-case"), "user-name")] - #[case("first_name", Some("kebab-case"), "first-name")] - #[case("last_name", Some("kebab-case"), "last-name")] - #[case("user_id", Some("kebab-case"), "user-id")] - #[case("api_key", Some("kebab-case"), "api-key")] - #[case("already-kebab", Some("kebab-case"), "already-kebab")] - // PascalCase tests - #[case("user_name", Some("PascalCase"), "UserName")] - #[case("first_name", Some("PascalCase"), "FirstName")] - #[case("last_name", Some("PascalCase"), "LastName")] - #[case("user_id", Some("PascalCase"), "UserId")] - #[case("api_key", Some("PascalCase"), "ApiKey")] - #[case("AlreadyPascal", Some("PascalCase"), "AlreadyPascal")] - // lowercase tests - #[case("UserName", Some("lowercase"), "username")] - #[case("FIRST_NAME", Some("lowercase"), "first_name")] - #[case("lastName", Some("lowercase"), "lastname")] - #[case("User_ID", Some("lowercase"), "user_id")] - #[case("API_KEY", Some("lowercase"), "api_key")] - #[case("already_lower", Some("lowercase"), "already_lower")] - // UPPERCASE tests - #[case("user_name", Some("UPPERCASE"), "USER_NAME")] - #[case("firstName", Some("UPPERCASE"), "FIRSTNAME")] - #[case("LastName", Some("UPPERCASE"), "LASTNAME")] - #[case("user_id", Some("UPPERCASE"), "USER_ID")] - #[case("apiKey", Some("UPPERCASE"), "APIKEY")] - #[case("ALREADY_UPPER", Some("UPPERCASE"), "ALREADY_UPPER")] - // SCREAMING_SNAKE_CASE tests - #[case("user_name", Some("SCREAMING_SNAKE_CASE"), "USER_NAME")] - #[case("firstName", Some("SCREAMING_SNAKE_CASE"), "FIRST_NAME")] - #[case("LastName", Some("SCREAMING_SNAKE_CASE"), "LAST_NAME")] - #[case("user_id", Some("SCREAMING_SNAKE_CASE"), "USER_ID")] - #[case("apiKey", Some("SCREAMING_SNAKE_CASE"), "API_KEY")] - #[case("ALREADY_SCREAMING", Some("SCREAMING_SNAKE_CASE"), "ALREADY_SCREAMING")] - // SCREAMING-KEBAB-CASE tests - #[case("user_name", Some("SCREAMING-KEBAB-CASE"), "USER-NAME")] - #[case("firstName", Some("SCREAMING-KEBAB-CASE"), "FIRST-NAME")] - #[case("LastName", Some("SCREAMING-KEBAB-CASE"), "LAST-NAME")] - #[case("user_id", Some("SCREAMING-KEBAB-CASE"), "USER-ID")] - #[case("apiKey", Some("SCREAMING-KEBAB-CASE"), "API-KEY")] - #[case("already-kebab", Some("SCREAMING-KEBAB-CASE"), "ALREADY-KEBAB")] - // None tests (no transformation) - #[case("user_name", None, "user_name")] - #[case("firstName", None, "firstName")] - #[case("LastName", None, "LastName")] - #[case("user-id", None, "user-id")] - fn test_rename_field( - #[case] field_name: &str, - #[case] rename_all: Option<&str>, - #[case] expected: &str, - ) { - assert_eq!(rename_field(field_name, rename_all), expected); - } - - #[rstest] - #[case(r#"#[serde(rename_all = "camelCase")] struct Foo;"#, Some("camelCase"))] - #[case( - r#"#[serde(rename_all = "snake_case")] struct Foo;"#, - Some("snake_case") - )] - #[case( - r#"#[serde(rename_all = "kebab-case")] struct Foo;"#, - Some("kebab-case") - )] - #[case( - r#"#[serde(rename_all = "PascalCase")] struct Foo;"#, - Some("PascalCase") - )] - // Multiple attributes - this is the bug case - #[case( - r#"#[serde(rename_all = "camelCase", default)] struct Foo;"#, - Some("camelCase") - )] - #[case( - r#"#[serde(default, rename_all = "snake_case")] struct Foo;"#, - Some("snake_case") - )] - #[case(r#"#[serde(rename_all = "kebab-case", skip_serializing_if = "Option::is_none")] struct Foo;"#, Some("kebab-case"))] - // No rename_all - #[case(r"#[serde(default)] struct Foo;", None)] - #[case(r"#[derive(Debug)] struct Foo;", None)] - fn test_extract_rename_all(#[case] item_src: &str, #[case] expected: Option<&str>) { - let item: syn::ItemStruct = syn::parse_str(item_src).unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result.as_deref(), expected); - } - - #[test] - fn test_extract_rename_all_enum_with_deny_unknown_fields() { - let enum_item: syn::ItemEnum = syn::parse_str( - r#" - #[serde(rename_all = "camelCase", deny_unknown_fields)] - enum Foo { A, B } - "#, - ) - .unwrap(); - let result = extract_rename_all(&enum_item.attrs); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - // Tests for extract_field_rename function - #[rstest] - #[case(r#"#[serde(rename = "custom_name")] field: i32"#, Some("custom_name"))] - #[case(r#"#[serde(rename = "userId")] field: i32"#, Some("userId"))] - #[case(r#"#[serde(rename = "ID")] field: i32"#, Some("ID"))] - #[case(r"#[serde(default)] field: i32", None)] - #[case(r"#[serde(skip)] field: i32", None)] - #[case(r"field: i32", None)] - // rename_all should NOT be extracted as rename - #[case(r#"#[serde(rename_all = "camelCase")] field: i32"#, None)] - // Multiple attributes - #[case(r#"#[serde(rename = "custom", default)] field: i32"#, Some("custom"))] - #[case( - r#"#[serde(default, rename = "my_field")] field: i32"#, - Some("my_field") - )] - fn test_extract_field_rename(#[case] field_src: &str, #[case] expected: Option<&str>) { - // Parse field from struct context - let struct_src = format!("struct Foo {{ {field_src} }}"); - let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_field_rename(&field.attrs); - assert_eq!(result.as_deref(), expected, "Failed for: {field_src}"); - } - } - - // Tests for extract_skip function - #[rstest] - #[case(r"#[serde(skip)] field: i32", true)] - #[case(r"#[serde(default)] field: i32", false)] - #[case(r#"#[serde(rename = "x")] field: i32"#, false)] - #[case(r"field: i32", false)] - // skip_serializing_if should NOT be treated as skip - #[case( - r#"#[serde(skip_serializing_if = "Option::is_none")] field: i32"#, - false - )] - // skip_deserializing should NOT be treated as skip - #[case(r"#[serde(skip_deserializing)] field: i32", false)] - // Combined attributes - #[case(r"#[serde(skip, default)] field: i32", true)] - #[case(r"#[serde(default, skip)] field: i32", true)] - fn test_extract_skip(#[case] field_src: &str, #[case] expected: bool) { - let struct_src = format!("struct Foo {{ {field_src} }}"); - let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_skip(&field.attrs); - assert_eq!(result, expected, "Failed for: {field_src}"); - } - } - - // Tests for extract_flatten function - #[rstest] - #[case(r"#[serde(flatten)] field: i32", true)] - #[case(r"#[serde(default)] field: i32", false)] - #[case(r#"#[serde(rename = "x")] field: i32"#, false)] - #[case(r"field: i32", false)] - // Combined attributes - #[case(r"#[serde(flatten, default)] field: i32", true)] - #[case(r"#[serde(default, flatten)] field: i32", true)] - fn test_extract_flatten(#[case] field_src: &str, #[case] expected: bool) { - let struct_src = format!("struct Foo {{ {field_src} }}"); - let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_flatten(&field.attrs); - assert_eq!(result, expected, "Failed for: {field_src}"); - } - } - - // Tests for extract_skip_serializing_if function - #[rstest] - #[case( - r#"#[serde(skip_serializing_if = "Option::is_none")] field: i32"#, - true - )] - #[case(r#"#[serde(skip_serializing_if = "is_zero")] field: i32"#, true)] - #[case(r"#[serde(default)] field: i32", false)] - #[case(r"#[serde(skip)] field: i32", false)] - #[case(r"field: i32", false)] - fn test_extract_skip_serializing_if(#[case] field_src: &str, #[case] expected: bool) { - let struct_src = format!("struct Foo {{ {field_src} }}"); - let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_skip_serializing_if(&field.attrs); - assert_eq!(result, expected, "Failed for: {field_src}"); - } - } - - // Tests for extract_default function - #[rstest] - // Simple default (no function) - #[case(r"#[serde(default)] field: i32", Some(None))] - // Default with function name - #[case( - r#"#[serde(default = "default_value")] field: i32"#, - Some(Some("default_value")) - )] - #[case( - r#"#[serde(default = "Default::default")] field: i32"#, - Some(Some("Default::default")) - )] - // No default - #[case(r"#[serde(skip)] field: i32", None)] - #[case(r#"#[serde(rename = "x")] field: i32"#, None)] - #[case(r"field: i32", None)] - // Combined attributes - #[case( - r#"#[serde(default, skip_serializing_if = "Option::is_none")] field: i32"#, - Some(None) - )] - #[case( - r#"#[serde(skip_serializing_if = "Option::is_none", default = "my_default")] field: i32"#, - Some(Some("my_default")) - )] - fn test_extract_default( - #[case] field_src: &str, - #[case] - #[allow(clippy::option_option)] - expected: Option>, - ) { - let struct_src = format!("struct Foo {{ {field_src} }}"); - let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_default(&field.attrs); - let expected_owned = expected.map(|o| o.map(std::string::ToString::to_string)); - assert_eq!(result, expected_owned, "Failed for: {field_src}"); - } - } - - // Test camelCase transformation with mixed characters - #[test] - fn test_rename_field_camelcase_with_digits() { - // Tests the regular character branch in camelCase - let result = rename_field("user_id_123", Some("camelCase")); - assert_eq!(result, "userId123"); - - let result = rename_field("get_user_by_id", Some("camelCase")); - assert_eq!(result, "getUserById"); - } - - // Tests for extract_doc_comment function - #[test] - fn test_extract_doc_comment_single_line() { - let attrs: Vec = syn::parse_quote! { - #[doc = " This is a doc comment"] - }; - let result = extract_doc_comment(&attrs); - assert_eq!(result, Some("This is a doc comment".to_string())); - } - - #[test] - fn test_extract_doc_comment_multi_line() { - let attrs: Vec = syn::parse_quote! { - #[doc = " First line"] - #[doc = " Second line"] - #[doc = " Third line"] - }; - let result = extract_doc_comment(&attrs); - assert_eq!( - result, - Some("First line\nSecond line\nThird line".to_string()) - ); - } - - #[test] - fn test_extract_doc_comment_no_leading_space() { - let attrs: Vec = syn::parse_quote! { - #[doc = "No leading space"] - }; - let result = extract_doc_comment(&attrs); - assert_eq!(result, Some("No leading space".to_string())); - } - - #[test] - fn test_extract_doc_comment_empty() { - let attrs: Vec = vec![]; - let result = extract_doc_comment(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_doc_comment_with_non_doc_attrs() { - let attrs: Vec = syn::parse_quote! { - #[derive(Debug)] - #[doc = " The doc comment"] - #[serde(rename = "test")] - }; - let result = extract_doc_comment(&attrs); - assert_eq!(result, Some("The doc comment".to_string())); - } - - // Tests for extract_schema_name_from_entity function - #[test] - fn test_extract_schema_name_from_entity_super_path() { - let ty: syn::Type = syn::parse_str("super::user::Entity").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, Some("User".to_string())); - } - - #[test] - fn test_extract_schema_name_from_entity_crate_path() { - let ty: syn::Type = syn::parse_str("crate::models::memo::Entity").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, Some("Memo".to_string())); - } - - #[test] - fn test_extract_schema_name_from_entity_not_entity() { - let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, None); - } - - #[test] - fn test_extract_schema_name_from_entity_single_segment() { - let ty: syn::Type = syn::parse_str("Entity").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, None); - } - - #[test] - fn test_extract_schema_name_from_entity_non_path_type() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, None); - } - - #[test] - fn test_extract_schema_name_from_entity_empty_module_name() { - // Tests the branch where module name has no characters (edge case) - let ty: syn::Type = syn::parse_str("super::some_module::Entity").unwrap(); - let result = extract_schema_name_from_entity(&ty); - assert_eq!(result, Some("Some_module".to_string())); - } - - // Test rename_field with unknown/invalid rename_all format - should return original field name - #[test] - fn test_rename_field_unknown_format() { - // Unknown format should return the original field name unchanged - let result = rename_field("my_field", Some("unknown_format")); - assert_eq!(result, "my_field"); - - let result = rename_field("myField", Some("invalid")); - assert_eq!(result, "myField"); - - let result = rename_field("test_name", Some("not_a_real_format")); - assert_eq!(result, "test_name"); - } - - /// Test strip_raw_prefix_owned function - #[test] - fn test_strip_raw_prefix_owned() { - assert_eq!(strip_raw_prefix_owned("r#type".to_string()), "type"); - assert_eq!(strip_raw_prefix_owned("r#match".to_string()), "match"); - assert_eq!(strip_raw_prefix_owned("normal".to_string()), "normal"); - assert_eq!(strip_raw_prefix_owned("r#".to_string()), ""); - } - - // Tests using programmatically created attributes - mod fallback_parsing_tests { - use proc_macro2::{Span, TokenStream}; - use quote::quote; - - use super::*; - - /// Helper to create attributes by parsing a struct with the given serde attributes - fn get_struct_attrs(serde_content: &str) -> Vec { - let src = format!(r"#[serde({serde_content})] struct Foo;"); - let item: syn::ItemStruct = syn::parse_str(&src).unwrap(); - item.attrs - } - - /// Helper to create field attributes by parsing a struct with the field - fn get_field_attrs(serde_content: &str) -> Vec { - let src = format!(r"struct Foo {{ #[serde({serde_content})] field: i32 }}"); - let item: syn::ItemStruct = syn::parse_str(&src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - fields.named.first().unwrap().attrs.clone() - } else { - vec![] - } - } - - /// Create a serde attribute with programmatic tokens - fn create_attr_with_raw_tokens(tokens: TokenStream) -> syn::Attribute { - syn::Attribute { - pound_token: syn::token::Pound::default(), - style: syn::AttrStyle::Outer, - bracket_token: syn::token::Bracket::default(), - meta: syn::Meta::List(syn::MetaList { - path: syn::Path::from(syn::Ident::new("serde", Span::call_site())), - delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), - tokens, - }), - } - } - - /// Test extract_rename_all fallback by creating an attribute where - /// parse_nested_meta succeeds but doesn't find rename_all in the expected format - #[test] - fn test_extract_rename_all_fallback_path() { - // Standard path - parse_nested_meta should work - let attrs = get_struct_attrs(r#"rename_all = "camelCase""#); - let result = extract_rename_all(&attrs); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test extract_field_rename fallback - #[test] - fn test_extract_field_rename_fallback_path() { - // Standard path - let attrs = get_field_attrs(r#"rename = "myField""#); - let result = extract_field_rename(&attrs); - assert_eq!(result.as_deref(), Some("myField")); - } - - /// Test extract_skip_serializing_if with fallback token check - #[test] - fn test_extract_skip_serializing_if_fallback_path() { - let attrs = get_field_attrs(r#"skip_serializing_if = "Option::is_none""#); - let result = extract_skip_serializing_if(&attrs); - assert!(result); - } - - /// Test extract_default standalone fallback - #[test] - fn test_extract_default_standalone_fallback_path() { - // Simple default without function - let attrs = get_field_attrs(r"default"); - let result = extract_default(&attrs); - assert_eq!(result, Some(None)); - } - - /// Test extract_default fallback when parse_nested_meta can't see `default` - /// at the top level — forces the manual token scan to catch it. - #[test] - fn test_extract_default_standalone_fallback_when_nested_meta_fails() { - // Construct an attribute whose token stream begins with garbage - // that `parse_nested_meta` will refuse to parse (a stray `@` - // before the first key). Because the parser bails immediately, - // the callback for `default` never fires, and the manual - // token-string fallback at the end of `extract_default` is the - // only path that detects the standalone `default` keyword. - let tokens: TokenStream = "@bogus, default".parse().expect("token stream parses"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_default(&[attr]); - assert_eq!( - result, - Some(None), - "fallback path must detect bare `default`" - ); - } - - /// Test that the fallback's "default appears as a substring inside - /// another identifier" branch returns None (no false-positive - /// match). Exercises the trailing `None` arm of - /// `scan_default_from_raw_tokens` (substring found, but neither - /// `=` follows nor delimiter chars surround it). - #[test] - fn test_extract_default_substring_in_identifier_is_not_a_match() { - // `field_default` contains "default" but as a suffix of an - // identifier — `before_char` is `_`, not one of the valid - // delimiters, so the standalone check fails. - let tokens: TokenStream = "@bogus, field_default" - .parse() - .expect("token stream parses"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_default(&[attr]); - assert_eq!( - result, None, - "embedded 'default' substring must not register as default" - ); - } - - /// Test extract_default with function fallback - #[test] - fn test_extract_default_with_function_fallback_path() { - let attrs = get_field_attrs(r#"default = "my_default_fn""#); - let result = extract_default(&attrs); - assert_eq!(result, Some(Some("my_default_fn".to_string()))); - } - - /// Test that rename_all is NOT confused with rename - #[test] - fn test_extract_field_rename_avoids_rename_all() { - let attrs = get_field_attrs(r#"rename_all = "camelCase""#); - let result = extract_field_rename(&attrs); - assert_eq!(result, None); // Should NOT extract rename_all as rename - } - - /// Test empty serde attribute - #[test] - fn test_extract_functions_with_empty_serde() { - let item: syn::ItemStruct = syn::parse_str(r"#[serde()] struct Foo;").unwrap(); - assert_eq!(extract_rename_all(&item.attrs), None); - } - - /// Test non-serde attribute is ignored - #[test] - fn test_extract_functions_ignore_non_serde() { - let item: syn::ItemStruct = syn::parse_str(r"#[derive(Debug)] struct Foo;").unwrap(); - assert_eq!(extract_rename_all(&item.attrs), None); - assert_eq!(extract_field_rename(&item.attrs), None); - } - - /// Test serde attribute that is not a list (e.g., #[serde]) - #[test] - fn test_extract_rename_all_non_list_serde() { - // #[serde] without parentheses - this should just be ignored - let item: syn::ItemStruct = syn::parse_str(r"#[serde] struct Foo;").unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result, None); - } - - /// Test extract_field_rename with complex attribute - #[test] - fn test_extract_field_rename_complex_attr() { - let attrs = get_field_attrs( - r#"default, rename = "field_name", skip_serializing_if = "Option::is_none""#, - ); - let result = extract_field_rename(&attrs); - assert_eq!(result.as_deref(), Some("field_name")); - } - - /// Test extract_rename_all with multiple serde attributes on same item - #[test] - fn test_extract_rename_all_multiple_serde_attrs() { - let item: syn::ItemStruct = syn::parse_str( - r#" - #[serde(default)] - #[serde(rename_all = "snake_case")] - struct Foo; - "#, - ) - .unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result.as_deref(), Some("snake_case")); - } - - /// Test edge case: rename_all with extra whitespace (manual parsing should handle) - #[test] - fn test_extract_rename_all_with_whitespace() { - // Note: syn normalizes whitespace in parsed tokens, so this tests the robust parsing - let attrs = get_struct_attrs(r#"rename_all = "PascalCase""#); - let result = extract_rename_all(&attrs); - assert_eq!(result.as_deref(), Some("PascalCase")); - } - - /// Test edge case: rename at various positions - #[test] - fn test_extract_field_rename_at_end() { - let attrs = get_field_attrs(r#"skip_serializing_if = "is_none", rename = "lastField""#); - let result = extract_field_rename(&attrs); - assert_eq!(result.as_deref(), Some("lastField")); - } - - /// Test extract_default when it appears with other attrs - #[test] - fn test_extract_default_among_other_attrs() { - let attrs = - get_field_attrs(r#"skip_serializing_if = "is_none", default, rename = "field""#); - let result = extract_default(&attrs); - assert_eq!(result, Some(None)); - } - - /// Test extract_skip - basic functionality - #[test] - fn test_extract_skip_basic() { - let attrs = get_field_attrs(r"skip"); - let result = extract_skip(&attrs); - assert!(result); - } - - /// Test extract_skip does not trigger for skip_serializing_if - #[test] - fn test_extract_skip_not_skip_serializing_if() { - let attrs = get_field_attrs(r#"skip_serializing_if = "Option::is_none""#); - let result = extract_skip(&attrs); - assert!(!result); - } - - /// Test extract_skip does not trigger for skip_deserializing - #[test] - fn test_extract_skip_not_skip_deserializing() { - let attrs = get_field_attrs(r"skip_deserializing"); - let result = extract_skip(&attrs); - assert!(!result); - } - - /// Test extract_skip with combined attrs - #[test] - fn test_extract_skip_with_other_attrs() { - let attrs = get_field_attrs(r"skip, default"); - let result = extract_skip(&attrs); - assert!(result); - } - - /// Test extract_default function with path containing colons - #[test] - fn test_extract_default_with_path() { - let attrs = get_field_attrs(r#"default = "Default::default""#); - let result = extract_default(&attrs); - assert_eq!(result, Some(Some("Default::default".to_string()))); - } - - /// Test extract_skip_serializing_if with complex path - #[test] - fn test_extract_skip_serializing_if_complex_path() { - let attrs = get_field_attrs(r#"skip_serializing_if = "Vec::is_empty""#); - let result = extract_skip_serializing_if(&attrs); - assert!(result); - } - - /// Test extract_rename_all with all supported formats - #[rstest] - #[case("camelCase")] - #[case("snake_case")] - #[case("kebab-case")] - #[case("PascalCase")] - #[case("lowercase")] - #[case("UPPERCASE")] - #[case("SCREAMING_SNAKE_CASE")] - #[case("SCREAMING-KEBAB-CASE")] - fn test_extract_rename_all_all_formats(#[case] format: &str) { - let attrs = get_struct_attrs(&format!(r#"rename_all = "{format}""#)); - let result = extract_rename_all(&attrs); - assert_eq!(result.as_deref(), Some(format)); - } - - /// Test non-serde attribute doesn't affect extraction - #[test] - fn test_mixed_attributes() { - let item: syn::ItemStruct = syn::parse_str( - r#" - #[derive(Debug, Clone)] - #[serde(rename_all = "camelCase")] - #[doc = "Some documentation"] - struct Foo; - "#, - ) - .unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test field with multiple serde attributes - #[test] - fn test_field_multiple_serde_attrs() { - let item: syn::ItemStruct = syn::parse_str( - r#" - struct Foo { - #[serde(default)] - #[serde(rename = "customName")] - field: i32 - } - "#, - ) - .unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let attrs = &fields.named.first().unwrap().attrs; - let rename = extract_field_rename(attrs); - let default = extract_default(attrs); - assert_eq!(rename.as_deref(), Some("customName")); - assert_eq!(default, Some(None)); - } - } - - /// Test extract_rename_all with programmatic tokens - #[test] - fn test_extract_rename_all_programmatic() { - let tokens = quote!(rename_all = "camelCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test extract_rename_all with invalid value (not a string) - #[test] - fn test_extract_rename_all_invalid_value() { - let tokens = quote!(rename_all = camelCase); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - // parse_nested_meta won't find a string literal - assert!(result.is_none()); - } - - /// Test extract_rename_all with missing equals sign - #[test] - fn test_extract_rename_all_no_equals() { - let tokens = quote!(rename_all "camelCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert!(result.is_none()); - } - - /// Test extract_field_rename with programmatic tokens - #[test] - fn test_extract_field_rename_programmatic() { - let tokens = quote!(rename = "customField"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_field_rename(&[attr]); - assert_eq!(result.as_deref(), Some("customField")); - } - - /// Test extract_default standalone with programmatic tokens - #[test] - fn test_extract_default_programmatic() { - let tokens = quote!(default); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_default(&[attr]); - assert_eq!(result, Some(None)); - } - - /// Test extract_default with function via programmatic tokens - #[test] - fn test_extract_default_with_fn_programmatic() { - let tokens = quote!(default = "my_fn"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_default(&[attr]); - assert_eq!(result, Some(Some("my_fn".to_string()))); - } - - /// Test extract_skip_serializing_if with programmatic tokens - #[test] - fn test_extract_skip_serializing_if_programmatic() { - let tokens = quote!(skip_serializing_if = "is_none"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_skip_serializing_if(&[attr]); - assert!(result); - } - - /// Test extract_skip via programmatic tokens - #[test] - fn test_extract_skip_programmatic() { - let tokens = quote!(skip); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_skip(&[attr]); - assert!(result); - } - - /// Test that rename_all is not confused with rename - #[test] - fn test_rename_all_not_rename() { - let tokens = quote!(rename_all = "camelCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_field_rename(&[attr]); - assert_eq!(result, None); - } - - /// Test multiple items in serde attribute - #[test] - fn test_multiple_items_programmatic() { - let tokens = quote!(default, rename = "myField", skip_serializing_if = "is_none"); - let attr = create_attr_with_raw_tokens(tokens); - - let rename_result = extract_field_rename(std::slice::from_ref(&attr)); - let default_result = extract_default(std::slice::from_ref(&attr)); - let skip_if_result = extract_skip_serializing_if(std::slice::from_ref(&attr)); - - assert_eq!(rename_result.as_deref(), Some("myField")); - assert_eq!(default_result, Some(None)); - assert!(skip_if_result); - } - - /// Test extract_rename_all fallback parsing - #[test] - fn test_extract_rename_all_fallback_manual_parsing() { - let tokens = quote!(rename_all = "kebab-case"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("kebab-case")); - } - - /// Test extract_rename_all with complex attribute that forces fallback - #[test] - fn test_extract_rename_all_complex_attribute_fallback() { - let tokens = quote!(default, rename_all = "SCREAMING_SNAKE_CASE", skip); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("SCREAMING_SNAKE_CASE")); - } - - /// Test extract_rename_all when value is not a string literal - #[test] - fn test_extract_rename_all_no_quote_start() { - let tokens = quote!(rename_all = snake_case); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert!(result.is_none()); - } - - /// Test extract_rename_all with unclosed quote - #[test] - fn test_extract_rename_all_unclosed_quote() { - let tokens = quote!(rename_all = "camelCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test extract_rename_all with empty string value - #[test] - fn test_extract_rename_all_empty_string() { - let tokens = quote!(rename_all = ""); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("")); - } - - /// Test extract_rename_all with QUALIFIED PATH to force fallback - #[test] - fn test_extract_rename_all_qualified_path_forces_fallback() { - let tokens = quote!(serde_with::rename_all = "camelCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test extract_rename_all with another qualified path variation - #[test] - fn test_extract_rename_all_module_qualified_forces_fallback() { - let tokens = quote!(my_module::rename_all = "snake_case"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("snake_case")); - } - - /// Test extract_rename_all with deeply qualified path - #[test] - fn test_extract_rename_all_deeply_qualified_forces_fallback() { - let tokens = quote!(a::b::rename_all = "PascalCase"); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("PascalCase")); - } - - /// CRITICAL TEST: This test MUST hit fallback path - #[test] - fn test_extract_rename_all_raw_tokens_force_fallback() { - let tokens: TokenStream = "__rename_all_prefix::rename_all = \"lowercase\"" - .parse() - .unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - - if let syn::Meta::List(list) = &attr.meta { - let token_str = list.tokens.to_string(); - assert!( - token_str.contains("rename_all"), - "Token string should contain rename_all: {token_str}" - ); - } - - let result = extract_rename_all(&[attr]); - assert_eq!( - result.as_deref(), - Some("lowercase"), - "Fallback parsing must extract the value" - ); - } - - /// Another critical test with different qualified path format - #[test] - fn test_extract_rename_all_crate_qualified_forces_fallback() { - let tokens: TokenStream = "crate::rename_all = \"UPPERCASE\"".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("UPPERCASE")); - } - - /// Test with self:: prefix - #[test] - fn test_extract_rename_all_self_qualified_forces_fallback() { - let tokens: TokenStream = "self::rename_all = \"kebab-case\"".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_rename_all(&[attr]); - assert_eq!(result.as_deref(), Some("kebab-case")); - } - - // ================================================================= - // FALLBACK PATH TESTS (Lines 173, 258-265, 573, 583-590, 626) - // ================================================================= - - /// Test extract_field_rename fallback path - Line 173 - /// Tests the word boundary check when "rename" appears with other attributes - /// This triggers the manual token parsing fallback when parse_nested_meta - /// doesn't extract the value in expected format - #[test] - fn test_extract_field_rename_fallback_word_boundary() { - // Create attribute with qualified path to force fallback - let tokens: TokenStream = "my_module::rename = \"value\"".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_field_rename(&[attr]); - assert_eq!(result.as_deref(), Some("value")); - } - - /// Test extract_field_rename fallback - complex combined attributes - /// Line 173: Tests the edge case of word boundary checking - #[test] - fn test_extract_field_rename_fallback_complex_attr() { - // Qualified path forces parse_nested_meta to not find "rename" - let tokens: TokenStream = "crate::other::rename = \"custom_field\", default" - .parse() - .unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_field_rename(&[attr]); - assert_eq!(result.as_deref(), Some("custom_field")); - } - - /// Test extract_field_rename - ensure rename_all is not matched as rename - /// Test the word boundary logic - #[test] - fn test_extract_field_rename_fallback_avoids_rename_all() { - let tokens: TokenStream = "some::rename_all = \"camelCase\"".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_field_rename(&[attr]); - // Should NOT match rename_all as rename - assert_eq!(result, None); - } - - /// Test extract_flatten fallback path - Lines 258-265 - /// Forces manual token parsing by using qualified path - #[test] - fn test_extract_flatten_fallback_path() { - let tokens: TokenStream = "my_module::flatten".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_flatten(&[attr]); - assert!(result, "Fallback should find 'flatten' in token string"); - } - - /// Test extract_flatten fallback with complex attributes - /// Lines 258-263: Tests word boundary checking in fallback - #[test] - fn test_extract_flatten_fallback_complex() { - let tokens: TokenStream = "crate::flatten, default = \"my_fn\"".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_flatten(&[attr]); - assert!(result, "Fallback should detect flatten with other attrs"); - } - - /// Test extract_flatten fallback with flatten at different positions - /// Line 265: Tests the return true path in fallback - #[test] - fn test_extract_flatten_fallback_at_end() { - let tokens: TokenStream = "default, some::flatten".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_flatten(&[attr]); - assert!(result); - } - - /// Test extract_flatten fallback doesn't match partial words - #[test] - fn test_extract_flatten_fallback_no_partial_match() { - // "flattened" should not match "flatten" - let tokens: TokenStream = "flattened".parse().unwrap(); - let attr = create_attr_with_raw_tokens(tokens); - let result = extract_flatten(&[attr]); - assert!(!result, "Should not match 'flattened' as 'flatten'"); - } - // ================================================================= - // MULTIPART FALLBACK TESTS (form_data / try_from_multipart) - // ================================================================= - - /// Test extract_field_rename falls back to #[form_data(field_name = "...")] - #[test] - fn test_extract_field_rename_form_data_fallback() { - let struct_src = r#"struct Foo { #[form_data(field_name = "my_file")] field: i32 }"#; - let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_field_rename(&field.attrs); - assert_eq!(result.as_deref(), Some("my_file")); - } - } - - /// Test serde rename takes priority over form_data field_name - #[test] - fn test_extract_field_rename_serde_over_form_data() { - let struct_src = r#"struct Foo { #[serde(rename = "serde_name")] #[form_data(field_name = "form_name")] field: i32 }"#; - let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_field_rename(&field.attrs); - assert_eq!(result.as_deref(), Some("serde_name")); - } - } - - /// Test extract_field_rename with form_data but no field_name key - #[test] - fn test_extract_field_rename_form_data_no_field_name() { - let struct_src = r#"struct Foo { #[form_data(limit = "10MiB")] field: i32 }"#; - let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); - if let syn::Fields::Named(fields) = &item.fields { - let field = fields.named.first().unwrap(); - let result = extract_field_rename(&field.attrs); - assert_eq!(result, None); - } - } - - /// Test extract_rename_all falls back to #[try_from_multipart(rename_all = "...")] - #[test] - fn test_extract_rename_all_try_from_multipart_fallback() { - let item: syn::ItemStruct = - syn::parse_str(r#"#[try_from_multipart(rename_all = "camelCase")] struct Foo;"#) - .unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result.as_deref(), Some("camelCase")); - } - - /// Test serde rename_all takes priority over try_from_multipart rename_all - #[test] - fn test_extract_rename_all_serde_over_try_from_multipart() { - let item: syn::ItemStruct = syn::parse_str(r#"#[serde(rename_all = "snake_case")] #[try_from_multipart(rename_all = "camelCase")] struct Foo;"#).unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result.as_deref(), Some("snake_case")); - } - - /// Test extract_rename_all with try_from_multipart but no rename_all key - #[test] - fn test_extract_rename_all_try_from_multipart_no_rename_all() { - let item: syn::ItemStruct = - syn::parse_str(r"#[try_from_multipart(strict)] struct Foo;").unwrap(); - let result = extract_rename_all(&item.attrs); - assert_eq!(result, None); - } - } - - // Tests for enum representation extraction (tag, content, untagged) - mod enum_repr_tests { - use super::*; - - fn get_enum_attrs(serde_content: &str) -> Vec { - let src = format!(r"#[serde({serde_content})] enum Foo {{ A, B }}"); - let item: syn::ItemEnum = syn::parse_str(&src).unwrap(); - item.attrs - } - - // extract_tag tests - #[rstest] - #[case(r#"tag = "type""#, Some("type"))] - #[case(r#"tag = "kind""#, Some("kind"))] - #[case(r#"tag = "variant""#, Some("variant"))] - #[case(r#"tag = "type", content = "data""#, Some("type"))] - #[case(r#"rename_all = "camelCase""#, None)] - #[case(r"untagged", None)] - #[case(r"default", None)] - fn test_extract_tag(#[case] serde_content: &str, #[case] expected: Option<&str>) { - let attrs = get_enum_attrs(serde_content); - let result = extract_tag(&attrs); - assert_eq!(result.as_deref(), expected, "Failed for: {serde_content}"); - } - - // extract_content tests - #[rstest] - #[case(r#"content = "data""#, Some("data"))] - #[case(r#"content = "payload""#, Some("payload"))] - #[case(r#"tag = "type", content = "data""#, Some("data"))] - #[case(r#"tag = "type""#, None)] - #[case(r"untagged", None)] - #[case(r#"rename_all = "camelCase""#, None)] - fn test_extract_content(#[case] serde_content: &str, #[case] expected: Option<&str>) { - let attrs = get_enum_attrs(serde_content); - let result = extract_content(&attrs); - assert_eq!(result.as_deref(), expected, "Failed for: {serde_content}"); - } - - // extract_untagged tests - #[rstest] - #[case(r"untagged", true)] - #[case(r#"untagged, rename_all = "camelCase""#, true)] - #[case(r#"rename_all = "camelCase", untagged"#, true)] - #[case(r#"tag = "type""#, false)] - #[case(r#"rename_all = "camelCase""#, false)] - #[case(r"default", false)] - fn test_extract_untagged(#[case] serde_content: &str, #[case] expected: bool) { - let attrs = get_enum_attrs(serde_content); - let result = extract_untagged(&attrs); - assert_eq!(result, expected, "Failed for: {serde_content}"); - } - - // extract_enum_repr comprehensive tests - #[test] - fn test_extract_enum_repr_externally_tagged() { - // No serde tag attributes - default is externally tagged - let attrs = get_enum_attrs(r#"rename_all = "camelCase""#); - let repr = extract_enum_repr(&attrs); - assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); - } - - #[test] - fn test_extract_enum_repr_internally_tagged() { - let attrs = get_enum_attrs(r#"tag = "type""#); - let repr = extract_enum_repr(&attrs); - assert_eq!( - repr, - SerdeEnumRepr::InternallyTagged { - tag: "type".to_string() - } - ); - } - - #[test] - fn test_extract_enum_repr_internally_tagged_custom_name() { - let attrs = get_enum_attrs(r#"tag = "kind""#); - let repr = extract_enum_repr(&attrs); - assert_eq!( - repr, - SerdeEnumRepr::InternallyTagged { - tag: "kind".to_string() - } - ); - } - - #[test] - fn test_extract_enum_repr_adjacently_tagged() { - let attrs = get_enum_attrs(r#"tag = "type", content = "data""#); - let repr = extract_enum_repr(&attrs); - assert_eq!( - repr, - SerdeEnumRepr::AdjacentlyTagged { - tag: "type".to_string(), - content: "data".to_string() - } - ); - } - - #[test] - fn test_extract_enum_repr_adjacently_tagged_custom_names() { - let attrs = get_enum_attrs(r#"tag = "kind", content = "payload""#); - let repr = extract_enum_repr(&attrs); - assert_eq!( - repr, - SerdeEnumRepr::AdjacentlyTagged { - tag: "kind".to_string(), - content: "payload".to_string() - } - ); - } - - #[test] - fn test_extract_enum_repr_untagged() { - let attrs = get_enum_attrs(r"untagged"); - let repr = extract_enum_repr(&attrs); - assert_eq!(repr, SerdeEnumRepr::Untagged); - } - - #[test] - fn test_extract_enum_repr_untagged_with_other_attrs() { - let attrs = get_enum_attrs(r#"untagged, rename_all = "camelCase""#); - let repr = extract_enum_repr(&attrs); - assert_eq!(repr, SerdeEnumRepr::Untagged); - } - - #[test] - fn test_extract_enum_repr_no_serde_attrs() { - let item: syn::ItemEnum = syn::parse_str("enum Foo { A, B }").unwrap(); - let repr = extract_enum_repr(&item.attrs); - assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); - } - - // Test that content without tag is still externally tagged (content alone is meaningless) - #[test] - fn test_extract_enum_repr_content_without_tag() { - let attrs = get_enum_attrs(r#"content = "data""#); - let repr = extract_enum_repr(&attrs); - // Content without tag should be externally tagged (content is ignored) - assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); - } - - // ================================================================= - // FALLBACK PATH TESTS FOR TAG/CONTENT (Lines 573, 583-590, 626) - // ================================================================= - - use proc_macro2::{Span, TokenStream}; - - /// Helper to create a serde attribute with raw tokens - fn create_enum_attr_with_raw_tokens(tokens: TokenStream) -> syn::Attribute { - syn::Attribute { - pound_token: syn::token::Pound::default(), - style: syn::AttrStyle::Outer, - bracket_token: syn::token::Bracket::default(), - meta: syn::Meta::List(syn::MetaList { - path: syn::Path::from(syn::Ident::new("serde", Span::call_site())), - delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), - tokens, - }), - } - } - - /// Test extract_tag fallback path - Lines 573, 583-590 - /// Forces manual token parsing by using qualified path - #[test] - fn test_extract_tag_fallback_path() { - let tokens: TokenStream = "my_module::tag = \"type\"".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_tag(&[attr]); - assert_eq!( - result.as_deref(), - Some("type"), - "Fallback should extract tag value" - ); - } - - /// Test extract_tag fallback with complex attributes - /// Lines 583-590: Tests the value extraction in fallback - #[test] - fn test_extract_tag_fallback_complex() { - let tokens: TokenStream = "crate::tag = \"kind\", rename_all = \"camelCase\"" - .parse() - .unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_tag(&[attr]); - assert_eq!(result.as_deref(), Some("kind")); - } - - /// Test extract_tag fallback doesn't match "untagged" - /// Line 581: before_char != 'n' check - #[test] - fn test_extract_tag_fallback_avoids_untagged() { - // "untagged" contains "tag" but should not be matched as tag = "..." - let tokens: TokenStream = "untagged".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_tag(&[attr]); - assert_eq!(result, None, "Should not extract tag from 'untagged'"); - } - - /// Test extract_tag fallback with tag after other attributes - #[test] - fn test_extract_tag_fallback_at_end() { - let tokens: TokenStream = "default, some_module::tag = \"variant\"".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_tag(&[attr]); - assert_eq!(result.as_deref(), Some("variant")); - } - - /// Test extract_content fallback path - Line 626 - /// Forces manual token parsing by using qualified path - #[test] - fn test_extract_content_fallback_path() { - let tokens: TokenStream = "my_module::content = \"data\"".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_content(&[attr]); - assert_eq!( - result.as_deref(), - Some("data"), - "Fallback should extract content value" - ); - } - - /// Test extract_content fallback with complex attributes - /// Line 626+: Tests the fallback token parsing branch - #[test] - fn test_extract_content_fallback_complex() { - let tokens: TokenStream = "crate::tag = \"type\", other::content = \"payload\"" - .parse() - .unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_content(&[attr]); - assert_eq!(result.as_deref(), Some("payload")); - } - - /// Test extract_content fallback with content at different position - #[test] - fn test_extract_content_fallback_at_start() { - let tokens: TokenStream = "some::content = \"body\", tag = \"kind\"".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let result = extract_content(&[attr]); - assert_eq!(result.as_deref(), Some("body")); - } - - /// Test adjacently tagged using fallback paths for both tag and content - #[test] - fn test_extract_enum_repr_adjacently_tagged_fallback() { - let tokens: TokenStream = "mod1::tag = \"type\", mod2::content = \"data\"" - .parse() - .unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let repr = extract_enum_repr(&[attr]); - assert_eq!( - repr, - SerdeEnumRepr::AdjacentlyTagged { - tag: "type".to_string(), - content: "data".to_string() - } - ); - } - - /// Test internally tagged using fallback path - #[test] - fn test_extract_enum_repr_internally_tagged_fallback() { - let tokens: TokenStream = "qualified::tag = \"discriminator\"".parse().unwrap(); - let attr = create_enum_attr_with_raw_tokens(tokens); - let repr = extract_enum_repr(&[attr]); - assert_eq!( - repr, - SerdeEnumRepr::InternallyTagged { - tag: "discriminator".to_string() - } - ); - } - - /// Helper to create a path-only serde attribute (#[serde] without parentheses) - /// This format causes require_list() to fail (returns Err) - fn create_path_only_serde_attr() -> syn::Attribute { - syn::Attribute { - pound_token: syn::token::Pound::default(), - style: syn::AttrStyle::Outer, - bracket_token: syn::token::Bracket::default(), - meta: syn::Meta::Path(syn::Path::from(syn::Ident::new("serde", Span::call_site()))), - } - } - - /// Test extract_tag with non-list serde attribute - /// When require_list() fails, extract_tag should continue to next attribute - #[test] - fn test_extract_tag_non_list_attr_continues() { - // First attr is path-only (#[serde]), second has the actual tag - let path_attr = create_path_only_serde_attr(); - let list_attr = { - let src = r#"#[serde(tag = "type")] enum Foo { A }"#; - let item: syn::ItemEnum = syn::parse_str(src).unwrap(); - item.attrs.into_iter().next().unwrap() - }; - - // extract_tag should skip the path-only attr and find tag in second attr - let result = extract_tag(&[path_attr, list_attr]); - assert_eq!(result.as_deref(), Some("type")); - } - - /// Test extract_tag with only non-list serde attribute returns None - #[test] - fn test_extract_tag_only_non_list_attr_returns_none() { - let path_attr = create_path_only_serde_attr(); - let result = extract_tag(&[path_attr]); - assert_eq!(result, None); - } - - /// Test extract_content with non-list serde attribute - /// When require_list() fails, extract_content should continue to next attribute - #[test] - fn test_extract_content_non_list_attr_continues() { - // First attr is path-only (#[serde]), second has the actual content - let path_attr = create_path_only_serde_attr(); - let list_attr = { - let src = r#"#[serde(content = "data")] enum Foo { A }"#; - let item: syn::ItemEnum = syn::parse_str(src).unwrap(); - item.attrs.into_iter().next().unwrap() - }; - - // extract_content should skip the path-only attr and find content in second attr - let result = extract_content(&[path_attr, list_attr]); - assert_eq!(result.as_deref(), Some("data")); - } - - /// Test extract_content with only non-list serde attribute returns None - #[test] - fn test_extract_content_only_non_list_attr_returns_none() { - let path_attr = create_path_only_serde_attr(); - let result = extract_content(&[path_attr]); - assert_eq!(result, None); - } - } -} +//! Serde attribute extraction utilities for OpenAPI schema generation. + +mod common; +mod enum_repr; +mod extract; +mod fallback; +mod rename_case; + +pub use common::{ + capitalize_first, extract_doc_comment, extract_schema_name_from_entity, + extract_schema_ref_override, extract_transparent, strip_raw_prefix_owned, +}; +pub use enum_repr::{SerdeEnumRepr, extract_enum_repr}; +pub use extract::{ + extract_default, extract_field_rename, extract_flatten, extract_rename_all, extract_skip, +}; +pub use rename_case::rename_field; diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs/common.rs b/crates/vespera_macro/src/parser/schema/serde_attrs/common.rs new file mode 100644 index 00000000..caa6f8e5 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/common.rs @@ -0,0 +1,237 @@ +//! Serde attribute extraction utilities for `OpenAPI` schema generation. +//! +//! This module provides functions to extract serde attributes from Rust types +//! to properly generate `OpenAPI` schemas that respect serialization rules. + +/// Extract doc comments from attributes. +/// Returns concatenated doc comment string or None if no doc comments. +pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { + let mut doc_lines = Vec::new(); + + for attr in attrs { + if attr.path().is_ident("doc") + && let syn::Meta::NameValue(meta_nv) = &attr.meta + && let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &meta_nv.value + { + let line = lit_str.value(); + // Strip `" / "` or `"/ "` prefixes that can appear when doc-comment + // markers leak through TokenStream → string → parse roundtrips, + // then trim any remaining whitespace. + let trimmed = line + .strip_prefix(" / ") + .or_else(|| line.strip_prefix("/ ")) + .unwrap_or(&line) + .trim(); + doc_lines.push(trimmed.to_string()); + } + } + + if doc_lines.is_empty() { + None + } else { + Some(doc_lines.join("\n")) + } +} + +/// Strips the `r#` prefix from raw identifiers, returning an owned `String`. +/// For the 99% case (no `r#` prefix), returns the input directly with zero extra allocation. +#[allow(clippy::option_if_let_else)] // clippy suggestion doesn't compile: borrow-move conflict +pub fn strip_raw_prefix_owned(ident: String) -> String { + if let Some(stripped) = ident.strip_prefix("r#") { + stripped.to_string() + } else { + ident + } +} + +pub use crate::schema_macro::type_utils::capitalize_first; + +/// Extract a Schema name from a `SeaORM` Entity type path. +/// +/// Converts paths like: +/// - `super::user::Entity` -> "User" +/// - `crate::models::memo::Entity` -> "Memo" +/// +/// The schema name is derived from the module containing Entity, +/// converted to `PascalCase` (first letter uppercase). +pub fn extract_schema_name_from_entity(ty: &syn::Type) -> Option { + match ty { + syn::Type::Path(type_path) => { + let segments: Vec<_> = type_path.path.segments.iter().collect(); + + // Need at least 2 segments: module::Entity + if segments.len() < 2 { + return None; + } + + // Check if last segment is "Entity" + let last = segments.last()?; + if last.ident != "Entity" { + return None; + } + + // Get the second-to-last segment (module name) + let module_segment = segments.get(segments.len() - 2)?; + let module_name = module_segment.ident.to_string(); + + // Convert to PascalCase (capitalize first letter) + // Rust identifiers are guaranteed non-empty, so chars().next() always returns Some + let schema_name = capitalize_first(&module_name); + + Some(schema_name) + } + _ => None, + } +} + +/// Extract whether `#[serde(transparent)]` is present on a struct. +pub fn extract_transparent(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| { + if !attr.path().is_ident("serde") { + return false; + } + + let mut is_transparent = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("transparent") { + is_transparent = true; + } + Ok(()) + }); + is_transparent + }) +} + +/// Extract `#[schema(ref = "Name", nullable)]` override from a struct. +pub fn extract_schema_ref_override(attrs: &[syn::Attribute]) -> Option<(String, bool)> { + attrs.iter().find_map(|attr| { + if !attr.path().is_ident("schema") { + return None; + } + + let mut ref_name = None; + let mut nullable = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("ref") { + let value = meta.value()?; + let lit: syn::LitStr = value.parse()?; + ref_name = Some(lit.value()); + } else if meta.path.is_ident("nullable") { + nullable = true; + } + Ok(()) + }); + + ref_name.map(|name| (name, nullable)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + // Tests for extract_doc_comment function + #[test] + fn test_extract_doc_comment_single_line() { + let attrs: Vec = syn::parse_quote! { + #[doc = " This is a doc comment"] + }; + let result = extract_doc_comment(&attrs); + assert_eq!(result, Some("This is a doc comment".to_string())); + } + + #[test] + fn test_extract_doc_comment_multi_line() { + let attrs: Vec = syn::parse_quote! { + #[doc = " First line"] + #[doc = " Second line"] + #[doc = " Third line"] + }; + let result = extract_doc_comment(&attrs); + assert_eq!( + result, + Some("First line\nSecond line\nThird line".to_string()) + ); + } + + #[test] + fn test_extract_doc_comment_no_leading_space() { + let attrs: Vec = syn::parse_quote! { + #[doc = "No leading space"] + }; + let result = extract_doc_comment(&attrs); + assert_eq!(result, Some("No leading space".to_string())); + } + + #[test] + fn test_extract_doc_comment_empty() { + let attrs: Vec = vec![]; + let result = extract_doc_comment(&attrs); + assert_eq!(result, None); + } + + #[test] + fn test_extract_doc_comment_with_non_doc_attrs() { + let attrs: Vec = syn::parse_quote! { + #[derive(Debug)] + #[doc = " The doc comment"] + #[serde(rename = "test")] + }; + let result = extract_doc_comment(&attrs); + assert_eq!(result, Some("The doc comment".to_string())); + } + + // Tests for extract_schema_name_from_entity function + #[test] + fn test_extract_schema_name_from_entity_super_path() { + let ty: syn::Type = syn::parse_str("super::user::Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, Some("User".to_string())); + } + + #[test] + fn test_extract_schema_name_from_entity_crate_path() { + let ty: syn::Type = syn::parse_str("crate::models::memo::Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, Some("Memo".to_string())); + } + + #[test] + fn test_extract_schema_name_from_entity_not_entity() { + let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_from_entity_single_segment() { + let ty: syn::Type = syn::parse_str("Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_from_entity_non_path_type() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, None); + } + + #[test] + fn test_extract_schema_name_from_entity_empty_module_name() { + // Tests the branch where module name has no characters (edge case) + let ty: syn::Type = syn::parse_str("super::some_module::Entity").unwrap(); + let result = extract_schema_name_from_entity(&ty); + assert_eq!(result, Some("Some_module".to_string())); + } + /// Test strip_raw_prefix_owned function + #[test] + fn test_strip_raw_prefix_owned() { + assert_eq!(strip_raw_prefix_owned("r#type".to_string()), "type"); + assert_eq!(strip_raw_prefix_owned("r#match".to_string()), "match"); + assert_eq!(strip_raw_prefix_owned("normal".to_string()), "normal"); + assert_eq!(strip_raw_prefix_owned("r#".to_string()), ""); + } +} diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs/enum_repr.rs b/crates/vespera_macro/src/parser/schema/serde_attrs/enum_repr.rs new file mode 100644 index 00000000..85d71803 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/enum_repr.rs @@ -0,0 +1,512 @@ +/// Serde enum representation types +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SerdeEnumRepr { + /// Default externally tagged: `{"VariantName": {...}}` + ExternallyTagged, + /// Internally tagged: `{"type": "VariantName", ...fields...}` + /// Only valid for struct and unit variants + InternallyTagged { tag: String }, + /// Adjacently tagged: `{"type": "VariantName", "data": {...}}` + AdjacentlyTagged { tag: String, content: String }, + /// Untagged: `{...fields...}` (no tag, first matching variant wins) + Untagged, +} + +/// Extract serde enum representation from attributes. +/// +/// Detects the enum tagging strategy from serde attributes: +/// - `#[serde(tag = "type")]` → `InternallyTagged` +/// - `#[serde(tag = "type", content = "data")]` → `AdjacentlyTagged` +/// - `#[serde(untagged)]` → Untagged +/// - No relevant attributes → `ExternallyTagged` (default) +pub fn extract_enum_repr(attrs: &[syn::Attribute]) -> SerdeEnumRepr { + let tag = extract_tag(attrs); + let content = extract_content(attrs); + let untagged = extract_untagged(attrs); + + if untagged { + SerdeEnumRepr::Untagged + } else if let Some(tag_name) = tag { + if let Some(content_name) = content { + SerdeEnumRepr::AdjacentlyTagged { + tag: tag_name, + content: content_name, + } + } else { + SerdeEnumRepr::InternallyTagged { tag: tag_name } + } + } else { + SerdeEnumRepr::ExternallyTagged + } +} + +/// Extract tag attribute from serde container attributes +/// Returns the tag name if `#[serde(tag = "...")]` is present +pub fn extract_tag(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("serde") { + let mut found_tag = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("tag") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_tag = Some(s.value()); + } + Ok(()) + }); + if found_tag.is_some() { + return found_tag; + } + + // Fallback: manual token parsing + let Ok(tokens) = attr.meta.require_list() else { + continue; + }; + let token_str = tokens.tokens.to_string(); + + if let Some(start) = token_str.find("tag") { + // Ensure it's "tag" not "untagged" + let before = if start > 0 { &token_str[..start] } else { "" }; + let before_char = before.chars().last().unwrap_or(' '); + if before_char != 'n' { + // Not "untagged" + let remaining = &token_str[start + "tag".len()..]; + if let Some(equals_pos) = remaining.find('=') { + let value_part = remaining[equals_pos + 1..].trim(); + if let Some(quote_start) = value_part.find('"') { + let after_quote = &value_part[quote_start + 1..]; + if let Some(quote_end) = after_quote.find('"') { + let value = &after_quote[..quote_end]; + return Some(value.to_string()); + } + } + } + } + } + } + } + None +} + +/// Extract content attribute from serde container attributes +/// Returns the content name if `#[serde(content = "...")]` is present +pub fn extract_content(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("serde") { + let mut found_content = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("content") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_content = Some(s.value()); + } + Ok(()) + }); + if found_content.is_some() { + return found_content; + } + + // Fallback: manual token parsing + let Ok(tokens) = attr.meta.require_list() else { + continue; + }; + let token_str = tokens.tokens.to_string(); + + if let Some(start) = token_str.find("content") { + let remaining = &token_str[start + "content".len()..]; + if let Some(equals_pos) = remaining.find('=') { + let value_part = remaining[equals_pos + 1..].trim(); + if let Some(quote_start) = value_part.find('"') { + let after_quote = &value_part[quote_start + 1..]; + if let Some(quote_end) = after_quote.find('"') { + let value = &after_quote[..quote_end]; + return Some(value.to_string()); + } + } + } + } + } + } + None +} + +/// Extract untagged attribute from serde container attributes +/// Returns true if `#[serde(untagged)]` is present +pub fn extract_untagged(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("serde") { + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("untagged") { + found = true; + } + Ok(()) + }); + if found { + return true; + } + + // Fallback: manual token parsing + if let syn::Meta::List(meta_list) = &attr.meta { + let tokens = meta_list.tokens.to_string(); + if let Some(pos) = tokens.find("untagged") { + let before = if pos > 0 { &tokens[..pos] } else { "" }; + let after = &tokens[pos + "untagged".len()..]; + let before_char = before.chars().last().unwrap_or(' '); + let after_char = after.chars().next().unwrap_or(' '); + if (before_char == ' ' || before_char == ',' || before_char == '(') + && (after_char == ' ' || after_char == ',' || after_char == ')') + { + return true; + } + } + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + fn get_enum_attrs(serde_content: &str) -> Vec { + let src = format!(r"#[serde({serde_content})] enum Foo {{ A, B }}"); + let item: syn::ItemEnum = syn::parse_str(&src).unwrap(); + item.attrs + } + + // extract_tag tests + #[rstest] + #[case(r#"tag = "type""#, Some("type"))] + #[case(r#"tag = "kind""#, Some("kind"))] + #[case(r#"tag = "variant""#, Some("variant"))] + #[case(r#"tag = "type", content = "data""#, Some("type"))] + #[case(r#"rename_all = "camelCase""#, None)] + #[case(r"untagged", None)] + #[case(r"default", None)] + fn test_extract_tag(#[case] serde_content: &str, #[case] expected: Option<&str>) { + let attrs = get_enum_attrs(serde_content); + let result = extract_tag(&attrs); + assert_eq!(result.as_deref(), expected, "Failed for: {serde_content}"); + } + + // extract_content tests + #[rstest] + #[case(r#"content = "data""#, Some("data"))] + #[case(r#"content = "payload""#, Some("payload"))] + #[case(r#"tag = "type", content = "data""#, Some("data"))] + #[case(r#"tag = "type""#, None)] + #[case(r"untagged", None)] + #[case(r#"rename_all = "camelCase""#, None)] + fn test_extract_content(#[case] serde_content: &str, #[case] expected: Option<&str>) { + let attrs = get_enum_attrs(serde_content); + let result = extract_content(&attrs); + assert_eq!(result.as_deref(), expected, "Failed for: {serde_content}"); + } + + // extract_untagged tests + #[rstest] + #[case(r"untagged", true)] + #[case(r#"untagged, rename_all = "camelCase""#, true)] + #[case(r#"rename_all = "camelCase", untagged"#, true)] + #[case(r#"tag = "type""#, false)] + #[case(r#"rename_all = "camelCase""#, false)] + #[case(r"default", false)] + fn test_extract_untagged(#[case] serde_content: &str, #[case] expected: bool) { + let attrs = get_enum_attrs(serde_content); + let result = extract_untagged(&attrs); + assert_eq!(result, expected, "Failed for: {serde_content}"); + } + + // extract_enum_repr comprehensive tests + #[test] + fn test_extract_enum_repr_externally_tagged() { + // No serde tag attributes - default is externally tagged + let attrs = get_enum_attrs(r#"rename_all = "camelCase""#); + let repr = extract_enum_repr(&attrs); + assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); + } + + #[test] + fn test_extract_enum_repr_internally_tagged() { + let attrs = get_enum_attrs(r#"tag = "type""#); + let repr = extract_enum_repr(&attrs); + assert_eq!( + repr, + SerdeEnumRepr::InternallyTagged { + tag: "type".to_string() + } + ); + } + + #[test] + fn test_extract_enum_repr_internally_tagged_custom_name() { + let attrs = get_enum_attrs(r#"tag = "kind""#); + let repr = extract_enum_repr(&attrs); + assert_eq!( + repr, + SerdeEnumRepr::InternallyTagged { + tag: "kind".to_string() + } + ); + } + + #[test] + fn test_extract_enum_repr_adjacently_tagged() { + let attrs = get_enum_attrs(r#"tag = "type", content = "data""#); + let repr = extract_enum_repr(&attrs); + assert_eq!( + repr, + SerdeEnumRepr::AdjacentlyTagged { + tag: "type".to_string(), + content: "data".to_string() + } + ); + } + + #[test] + fn test_extract_enum_repr_adjacently_tagged_custom_names() { + let attrs = get_enum_attrs(r#"tag = "kind", content = "payload""#); + let repr = extract_enum_repr(&attrs); + assert_eq!( + repr, + SerdeEnumRepr::AdjacentlyTagged { + tag: "kind".to_string(), + content: "payload".to_string() + } + ); + } + + #[test] + fn test_extract_enum_repr_untagged() { + let attrs = get_enum_attrs(r"untagged"); + let repr = extract_enum_repr(&attrs); + assert_eq!(repr, SerdeEnumRepr::Untagged); + } + + #[test] + fn test_extract_enum_repr_untagged_with_other_attrs() { + let attrs = get_enum_attrs(r#"untagged, rename_all = "camelCase""#); + let repr = extract_enum_repr(&attrs); + assert_eq!(repr, SerdeEnumRepr::Untagged); + } + + #[test] + fn test_extract_enum_repr_no_serde_attrs() { + let item: syn::ItemEnum = syn::parse_str("enum Foo { A, B }").unwrap(); + let repr = extract_enum_repr(&item.attrs); + assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); + } + + // Test that content without tag is still externally tagged (content alone is meaningless) + #[test] + fn test_extract_enum_repr_content_without_tag() { + let attrs = get_enum_attrs(r#"content = "data""#); + let repr = extract_enum_repr(&attrs); + // Content without tag should be externally tagged (content is ignored) + assert_eq!(repr, SerdeEnumRepr::ExternallyTagged); + } + + // ================================================================= + // FALLBACK PATH TESTS FOR TAG/CONTENT (Lines 573, 583-590, 626) + // ================================================================= + + use proc_macro2::{Span, TokenStream}; + + /// Helper to create a serde attribute with raw tokens + fn create_enum_attr_with_raw_tokens(tokens: TokenStream) -> syn::Attribute { + syn::Attribute { + pound_token: syn::token::Pound::default(), + style: syn::AttrStyle::Outer, + bracket_token: syn::token::Bracket::default(), + meta: syn::Meta::List(syn::MetaList { + path: syn::Path::from(syn::Ident::new("serde", Span::call_site())), + delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), + tokens, + }), + } + } + + /// Test extract_tag fallback path - Lines 573, 583-590 + /// Forces manual token parsing by using qualified path + #[test] + fn test_extract_tag_fallback_path() { + let tokens: TokenStream = "my_module::tag = \"type\"".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_tag(&[attr]); + assert_eq!( + result.as_deref(), + Some("type"), + "Fallback should extract tag value" + ); + } + + /// Test extract_tag fallback with complex attributes + /// Lines 583-590: Tests the value extraction in fallback + #[test] + fn test_extract_tag_fallback_complex() { + let tokens: TokenStream = "crate::tag = \"kind\", rename_all = \"camelCase\"" + .parse() + .unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_tag(&[attr]); + assert_eq!(result.as_deref(), Some("kind")); + } + + /// Test extract_tag fallback doesn't match "untagged" + /// Line 581: before_char != 'n' check + #[test] + fn test_extract_tag_fallback_avoids_untagged() { + // "untagged" contains "tag" but should not be matched as tag = "..." + let tokens: TokenStream = "untagged".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_tag(&[attr]); + assert_eq!(result, None, "Should not extract tag from 'untagged'"); + } + + /// Test extract_tag fallback with tag after other attributes + #[test] + fn test_extract_tag_fallback_at_end() { + let tokens: TokenStream = "default, some_module::tag = \"variant\"".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_tag(&[attr]); + assert_eq!(result.as_deref(), Some("variant")); + } + + /// Test extract_content fallback path - Line 626 + /// Forces manual token parsing by using qualified path + #[test] + fn test_extract_content_fallback_path() { + let tokens: TokenStream = "my_module::content = \"data\"".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_content(&[attr]); + assert_eq!( + result.as_deref(), + Some("data"), + "Fallback should extract content value" + ); + } + + /// Test extract_content fallback with complex attributes + /// Line 626+: Tests the fallback token parsing branch + #[test] + fn test_extract_content_fallback_complex() { + let tokens: TokenStream = "crate::tag = \"type\", other::content = \"payload\"" + .parse() + .unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_content(&[attr]); + assert_eq!(result.as_deref(), Some("payload")); + } + + /// Test extract_content fallback with content at different position + #[test] + fn test_extract_content_fallback_at_start() { + let tokens: TokenStream = "some::content = \"body\", tag = \"kind\"".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let result = extract_content(&[attr]); + assert_eq!(result.as_deref(), Some("body")); + } + + /// Test adjacently tagged using fallback paths for both tag and content + #[test] + fn test_extract_enum_repr_adjacently_tagged_fallback() { + let tokens: TokenStream = "mod1::tag = \"type\", mod2::content = \"data\"" + .parse() + .unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let repr = extract_enum_repr(&[attr]); + assert_eq!( + repr, + SerdeEnumRepr::AdjacentlyTagged { + tag: "type".to_string(), + content: "data".to_string() + } + ); + } + + /// Test internally tagged using fallback path + #[test] + fn test_extract_enum_repr_internally_tagged_fallback() { + let tokens: TokenStream = "qualified::tag = \"discriminator\"".parse().unwrap(); + let attr = create_enum_attr_with_raw_tokens(tokens); + let repr = extract_enum_repr(&[attr]); + assert_eq!( + repr, + SerdeEnumRepr::InternallyTagged { + tag: "discriminator".to_string() + } + ); + } + + /// Helper to create a path-only serde attribute (#[serde] without parentheses) + /// This format causes require_list() to fail (returns Err) + fn create_path_only_serde_attr() -> syn::Attribute { + syn::Attribute { + pound_token: syn::token::Pound::default(), + style: syn::AttrStyle::Outer, + bracket_token: syn::token::Bracket::default(), + meta: syn::Meta::Path(syn::Path::from(syn::Ident::new("serde", Span::call_site()))), + } + } + + /// Test extract_tag with non-list serde attribute + /// When require_list() fails, extract_tag should continue to next attribute + #[test] + fn test_extract_tag_non_list_attr_continues() { + // First attr is path-only (#[serde]), second has the actual tag + let path_attr = create_path_only_serde_attr(); + let list_attr = { + let src = r#"#[serde(tag = "type")] enum Foo { A }"#; + let item: syn::ItemEnum = syn::parse_str(src).unwrap(); + item.attrs.into_iter().next().unwrap() + }; + + // extract_tag should skip the path-only attr and find tag in second attr + let result = extract_tag(&[path_attr, list_attr]); + assert_eq!(result.as_deref(), Some("type")); + } + + /// Test extract_tag with only non-list serde attribute returns None + #[test] + fn test_extract_tag_only_non_list_attr_returns_none() { + let path_attr = create_path_only_serde_attr(); + let result = extract_tag(&[path_attr]); + assert_eq!(result, None); + } + + /// Test extract_content with non-list serde attribute + /// When require_list() fails, extract_content should continue to next attribute + #[test] + fn test_extract_content_non_list_attr_continues() { + // First attr is path-only (#[serde]), second has the actual content + let path_attr = create_path_only_serde_attr(); + let list_attr = { + let src = r#"#[serde(content = "data")] enum Foo { A }"#; + let item: syn::ItemEnum = syn::parse_str(src).unwrap(); + item.attrs.into_iter().next().unwrap() + }; + + // extract_content should skip the path-only attr and find content in second attr + let result = extract_content(&[path_attr, list_attr]); + assert_eq!(result.as_deref(), Some("data")); + } + + /// Test extract_content with only non-list serde attribute returns None + #[test] + fn test_extract_content_only_non_list_attr_returns_none() { + let path_attr = create_path_only_serde_attr(); + let result = extract_content(&[path_attr]); + assert_eq!(result, None); + } +} diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs/extract.rs b/crates/vespera_macro/src/parser/schema/serde_attrs/extract.rs new file mode 100644 index 00000000..6fbc7441 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/extract.rs @@ -0,0 +1,454 @@ +use super::fallback::{ + contains_standalone_word, quoted_value_after_key, scan_default_from_raw_tokens, +}; + +pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { + // First check serde attrs (higher priority) + for attr in attrs { + if attr.path().is_ident("serde") { + // Try using parse_nested_meta for robust parsing + let mut found_rename_all = None; + let parsed = attr.parse_nested_meta(|meta| { + if meta + .path + .segments + .last() + .is_some_and(|seg| seg.ident == "rename_all") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_rename_all = Some(s.value()); + } + Ok(()) + }); + if found_rename_all.is_some() { + return found_rename_all; + } + + // Fallback ONLY when structured parsing FAILED: a successful + // parse_nested_meta visited every nested meta, so it cannot have + // missed a present `rename_all` — skip the throwaway token-string + // allocation + scan in that (common) case. An `Err` means an + // unhandled key/value aborted the walk early (e.g. + // `skip_serializing_if = "..."` before `rename_all`), which is + // exactly when the manual scan is still needed. + if parsed.is_err() { + let Ok(tokens) = attr.meta.require_list() else { + continue; + }; + let token_str = tokens.tokens.to_string(); + + if let Some(value) = quoted_value_after_key(&token_str, "rename_all") { + return Some(value); + } + } + } + } + + // Fallback: check for #[try_from_multipart(rename_all = "...")] + for attr in attrs { + if attr.path().is_ident("try_from_multipart") { + let mut found_rename_all = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_rename_all = Some(s.value()); + } + Ok(()) + }); + if found_rename_all.is_some() { + return found_rename_all; + } + } + } + + None +} + +pub fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { + // First check serde attrs (higher priority) + for attr in attrs { + if attr.path().is_ident("serde") + && let syn::Meta::List(meta_list) = &attr.meta + { + // Use parse_nested_meta to parse nested attributes + let mut found_rename = None; + let parsed = attr.parse_nested_meta(|meta| { + if meta + .path + .segments + .last() + .is_some_and(|seg| seg.ident == "rename") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_rename = Some(s.value()); + } + Ok(()) + }); + if let Some(rename_value) = found_rename { + return Some(rename_value); + } + + // Fallback ONLY when structured parsing FAILED (see extract_rename_all): + // a successful walk cannot have missed a present `rename`, so skip the + // throwaway token-string allocation + scan in the common case. + if parsed.is_err() { + let tokens = meta_list.tokens.to_string(); + if let Some(value) = quoted_value_after_key(&tokens, "rename") { + return Some(value); + } + } + } + } + + // Fallback: check for #[form_data(field_name = "...")] + for attr in attrs { + if attr.path().is_ident("form_data") { + let mut found_field_name = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("field_name") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_field_name = Some(s.value()); + } + Ok(()) + }); + if found_field_name.is_some() { + return found_field_name; + } + } + } + + None +} + +/// Extract skip attribute from field attributes +/// Returns true if #[serde(skip)] is present +pub fn extract_skip(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("serde") { + let mut has_skip = false; + let mut has_skip_serializing = false; + let mut has_skip_deserializing = false; + let parsed = attr.parse_nested_meta(|meta| { + // Match by the path's LAST segment (see extract_flatten) so a + // qualified `module::skip` is caught by the structured parser, + // leaving the fallback as a pure parse-error recovery path. + let last = meta.path.segments.last().map(|seg| &seg.ident); + if last.is_some_and(|id| id == "skip") { + has_skip = true; + } else if last.is_some_and(|id| id == "skip_serializing") { + has_skip_serializing = true; + } else if last.is_some_and(|id| id == "skip_deserializing") { + has_skip_deserializing = true; + } + Ok(()) + }); + if has_skip || (has_skip_serializing && has_skip_deserializing) { + return true; + } + + // Fallback ONLY when structured parsing FAILED (see extract_rename_all): + // a successful walk already determined skip is absent, so skip the + // throwaway token-string allocation + scan in the common case. + if parsed.is_err() { + let syn::Meta::List(meta_list) = &attr.meta else { + continue; + }; + let tokens = meta_list.tokens.to_string(); + if contains_standalone_word(&tokens, "skip") + || (contains_standalone_word(&tokens, "skip_serializing") + && contains_standalone_word(&tokens, "skip_deserializing")) + { + return true; + } + } + } + } + false +} + +/// Extract flatten attribute from field attributes +/// Returns true if #[serde(flatten)] is present +pub fn extract_flatten(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("serde") { + // Try using parse_nested_meta for robust parsing + let mut found = false; + let parsed = attr.parse_nested_meta(|meta| { + // Match the keyword by the path's LAST segment so a qualified + // `module::flatten` is recognised by the structured parser + // itself; the manual fallback below then only covers the genuine + // parse-error case (an unhandled `key = value` aborting the + // walk), not "key present but written as a qualified path". + if meta + .path + .segments + .last() + .is_some_and(|seg| seg.ident == "flatten") + { + found = true; + } + Ok(()) + }); + if found { + return true; + } + + // Fallback ONLY when structured parsing FAILED (see extract_rename_all): + // a successful walk already determined flatten is absent, so skip the + // throwaway token-string allocation + scan in the common case. + if parsed.is_err() + && let syn::Meta::List(meta_list) = &attr.meta + { + let tokens = meta_list.tokens.to_string(); + if contains_standalone_word(&tokens, "flatten") { + return true; + } + } + } + } + false +} + +/// Check whether the `"default"` substring at index `start` of `tokens` +/// Extract default attribute from field attributes +/// Returns: +/// - Some(None) if #[serde(default)] is present (no function) +/// - `Some(Some(function_name))` if #[serde(default = "`function_name`")] is present +/// - None if no default attribute is present +#[allow(clippy::option_option)] +pub fn extract_default(attrs: &[syn::Attribute]) -> Option> { + for attr in attrs { + if attr.path().is_ident("serde") + && let syn::Meta::List(meta_list) = &attr.meta + { + let mut found_default: Option> = None; + let parsed = attr.parse_nested_meta(|meta| { + // Match by the path's LAST segment (see extract_flatten) so a + // qualified `module::default` is caught by the structured parser. + if meta + .path + .segments + .last() + .is_some_and(|seg| seg.ident == "default") + { + // Check if it has a value (default = "function_name") + if let Ok(value) = meta.value() { + if let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_default = Some(Some(s.value())); + } + } else { + // Just "default" without value + found_default = Some(None); + } + } + Ok(()) + }); + // Fallback ONLY when structured parsing FAILED (see extract_rename_all): + // a successful walk already determined whether `default` is present, so + // skip the throwaway token-string allocation + scan in the common case. + if found_default.is_none() && parsed.is_err() { + found_default = scan_default_from_raw_tokens(&meta_list.tokens.to_string()); + } + if let Some(default_value) = found_default { + return Some(default_value); + } + } + } + None +} + +#[cfg(test)] +mod tests { + #![allow(clippy::option_option)] + use super::*; + use rstest::rstest; + #[rstest] + #[case(r#"#[serde(rename_all = "camelCase")] struct Foo;"#, Some("camelCase"))] + #[case( + r#"#[serde(rename_all = "snake_case")] struct Foo;"#, + Some("snake_case") + )] + #[case( + r#"#[serde(rename_all = "kebab-case")] struct Foo;"#, + Some("kebab-case") + )] + #[case( + r#"#[serde(rename_all = "PascalCase")] struct Foo;"#, + Some("PascalCase") + )] + // Multiple attributes - this is the bug case + #[case( + r#"#[serde(rename_all = "camelCase", default)] struct Foo;"#, + Some("camelCase") + )] + #[case( + r#"#[serde(default, rename_all = "snake_case")] struct Foo;"#, + Some("snake_case") + )] + #[case( + r#"#[serde(rename_all = "kebab-case", skip_serializing_if = "Option::is_none")] struct Foo;"#, + Some("kebab-case") +)] + // No rename_all + #[case(r"#[serde(default)] struct Foo;", None)] + #[case(r"#[derive(Debug)] struct Foo;", None)] + fn test_extract_rename_all(#[case] item_src: &str, #[case] expected: Option<&str>) { + let item: syn::ItemStruct = syn::parse_str(item_src).unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), expected); + } + + #[test] + fn test_extract_rename_all_enum_with_deny_unknown_fields() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(rename_all = "camelCase", deny_unknown_fields)] + enum Foo { A, B } + "#, + ) + .unwrap(); + let result = extract_rename_all(&enum_item.attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + // Tests for extract_field_rename function + #[rstest] + #[case(r#"#[serde(rename = "custom_name")] field: i32"#, Some("custom_name"))] + #[case(r#"#[serde(rename = "userId")] field: i32"#, Some("userId"))] + #[case(r#"#[serde(rename = "ID")] field: i32"#, Some("ID"))] + #[case(r"#[serde(default)] field: i32", None)] + #[case(r"#[serde(skip)] field: i32", None)] + #[case(r"field: i32", None)] + // rename_all should NOT be extracted as rename + #[case(r#"#[serde(rename_all = "camelCase")] field: i32"#, None)] + // Multiple attributes + #[case(r#"#[serde(rename = "custom", default)] field: i32"#, Some("custom"))] + #[case( + r#"#[serde(default, rename = "my_field")] field: i32"#, + Some("my_field") + )] + fn test_extract_field_rename(#[case] field_src: &str, #[case] expected: Option<&str>) { + // Parse field from struct context + let struct_src = format!("struct Foo {{ {field_src} }}"); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result.as_deref(), expected, "Failed for: {field_src}"); + } + } + + // Tests for extract_skip function + #[rstest] + #[case(r"#[serde(skip)] field: i32", true)] + #[case( + r#"#[serde(skip, skip_serializing_if = "Option::is_none")] field: Option"#, + true + )] + #[case(r"#[serde(skip_serializing, skip_deserializing)] field: String", true)] + #[case(r"#[serde(default)] field: i32", false)] + #[case(r#"#[serde(rename = "x")] field: i32"#, false)] + #[case(r"field: i32", false)] + // skip_serializing_if should NOT be treated as skip + #[case( + r#"#[serde(skip_serializing_if = "Option::is_none")] field: i32"#, + false + )] + // skip_deserializing should NOT be treated as skip + #[case(r"#[serde(skip_deserializing)] field: i32", false)] + // Combined attributes + #[case(r"#[serde(skip, default)] field: i32", true)] + #[case(r"#[serde(default, skip)] field: i32", true)] + fn test_extract_skip(#[case] field_src: &str, #[case] expected: bool) { + let struct_src = format!("struct Foo {{ {field_src} }}"); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_skip(&field.attrs); + assert_eq!(result, expected, "Failed for: {field_src}"); + } + } + + // Tests for extract_flatten function + #[rstest] + #[case(r"#[serde(flatten)] field: i32", true)] + #[case(r"#[serde(default)] field: i32", false)] + #[case(r#"#[serde(rename = "x")] field: i32"#, false)] + #[case(r"field: i32", false)] + // Combined attributes + #[case(r"#[serde(flatten, default)] field: i32", true)] + #[case(r"#[serde(default, flatten)] field: i32", true)] + fn test_extract_flatten(#[case] field_src: &str, #[case] expected: bool) { + let struct_src = format!("struct Foo {{ {field_src} }}"); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_flatten(&field.attrs); + assert_eq!(result, expected, "Failed for: {field_src}"); + } + } + + // Tests for extract_default function + #[rstest] + // Simple default (no function) + #[case(r"#[serde(default)] field: i32", Some(None))] + // Default with function name + #[case( + r#"#[serde(default = "default_value")] field: i32"#, + Some(Some("default_value")) + )] + #[case( + r#"#[serde(default = "Default::default")] field: i32"#, + Some(Some("Default::default")) + )] + // No default + #[case(r"#[serde(skip)] field: i32", None)] + #[case(r#"#[serde(rename = "x")] field: i32"#, None)] + #[case(r"field: i32", None)] + // Combined attributes + #[case( + r#"#[serde(default, skip_serializing_if = "Option::is_none")] field: i32"#, + Some(None) + )] + #[case( + r#"#[serde(skip_serializing_if = "Option::is_none", default = "my_default")] field: i32"#, + Some(Some("my_default")) + )] + fn test_extract_default( + #[case] field_src: &str, + #[case] + #[allow(clippy::option_option)] + expected: Option>, + ) { + let struct_src = format!("struct Foo {{ {field_src} }}"); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_default(&field.attrs); + let expected_owned = expected.map(|o| o.map(std::string::ToString::to_string)); + assert_eq!(result, expected_owned, "Failed for: {field_src}"); + } + } +} diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs/fallback.rs b/crates/vespera_macro/src/parser/schema/serde_attrs/fallback.rs new file mode 100644 index 00000000..2b668355 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/fallback.rs @@ -0,0 +1,706 @@ +pub(super) fn quoted_value_after_key(tokens: &str, key: &str) -> Option { + for (start, _) in tokens.match_indices(key) { + if key == "rename" && tokens[start..].starts_with("rename_all") { + continue; + } + if !is_standalone_word_at(tokens, start, key) && !is_qualified_key(tokens, start) { + continue; + } + let remaining = &tokens[start + key.len()..]; + let Some(equals_pos) = remaining.find('=') else { + continue; + }; + let value_part = remaining[equals_pos + 1..].trim(); + let Some(quote_start) = value_part.find('"') else { + continue; + }; + let after_quote = &value_part[quote_start + 1..]; + let Some(quote_end) = after_quote.find('"') else { + continue; + }; + return Some(after_quote[..quote_end].to_string()); + } + None +} + +pub(super) fn contains_standalone_word(tokens: &str, word: &str) -> bool { + tokens.match_indices(word).any(|(start, _)| { + is_standalone_word_at(tokens, start, word) || is_qualified_key(tokens, start) + }) +} + +fn is_qualified_key(tokens: &str, start: usize) -> bool { + start >= 2 && &tokens[start - 2..start] == "::" +} + +fn is_standalone_word_at(tokens: &str, start: usize, word: &str) -> bool { + let before = if start > 0 { &tokens[..start] } else { "" }; + let after = &tokens[start + word.len()..]; + let before_char = before.chars().last().unwrap_or(' '); + let after_char = after.chars().next().unwrap_or(' '); + let before_ok = before_char == ' ' || before_char == ',' || before_char == '('; + let after_ok = after_char == ' ' || after_char == ',' || after_char == ')' || after_char == '='; + before_ok && after_ok +} + +#[allow(clippy::option_option)] +pub(super) fn scan_default_from_raw_tokens(tokens: &str) -> Option> { + let start = tokens.find("default")?; + let remaining = &tokens[start + "default".len()..]; + if remaining.trim_start().starts_with('=') { + let after_equals = remaining + .trim_start() + .strip_prefix('=') + .unwrap_or("") + .trim_start(); + let quote_start = after_equals.find('"')?; + let after_quote = &after_equals[quote_start + 1..]; + let quote_end = after_quote.find('"')?; + Some(Some(after_quote[..quote_end].to_string())) + } else if is_standalone_word_at(tokens, start, "default") { + Some(None) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use crate::parser::schema::serde_attrs::*; + use proc_macro2::{Span, TokenStream}; + use quote::quote; + use rstest::rstest; + + /// Helper to create attributes by parsing a struct with the given serde attributes + fn get_struct_attrs(serde_content: &str) -> Vec { + let src = format!(r"#[serde({serde_content})] struct Foo;"); + let item: syn::ItemStruct = syn::parse_str(&src).unwrap(); + item.attrs + } + + /// Helper to create field attributes by parsing a struct with the field + fn get_field_attrs(serde_content: &str) -> Vec { + let src = format!(r"struct Foo {{ #[serde({serde_content})] field: i32 }}"); + let item: syn::ItemStruct = syn::parse_str(&src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + fields.named.first().unwrap().attrs.clone() + } else { + vec![] + } + } + + /// Create a serde attribute with programmatic tokens + fn create_attr_with_raw_tokens(tokens: TokenStream) -> syn::Attribute { + syn::Attribute { + pound_token: syn::token::Pound::default(), + style: syn::AttrStyle::Outer, + bracket_token: syn::token::Bracket::default(), + meta: syn::Meta::List(syn::MetaList { + path: syn::Path::from(syn::Ident::new("serde", Span::call_site())), + delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), + tokens, + }), + } + } + + /// Test extract_rename_all fallback by creating an attribute where + /// parse_nested_meta succeeds but doesn't find rename_all in the expected format + #[test] + fn test_extract_rename_all_fallback_path() { + // Standard path - parse_nested_meta should work + let attrs = get_struct_attrs(r#"rename_all = "camelCase""#); + let result = extract_rename_all(&attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_field_rename fallback + #[test] + fn test_extract_field_rename_fallback_path() { + // Standard path + let attrs = get_field_attrs(r#"rename = "myField""#); + let result = extract_field_rename(&attrs); + assert_eq!(result.as_deref(), Some("myField")); + } + + /// Test extract_default standalone fallback + #[test] + fn test_extract_default_standalone_fallback_path() { + // Simple default without function + let attrs = get_field_attrs(r"default"); + let result = extract_default(&attrs); + assert_eq!(result, Some(None)); + } + + /// Test extract_default fallback when parse_nested_meta can't see `default` + /// at the top level — forces the manual token scan to catch it. + #[test] + fn test_extract_default_standalone_fallback_when_nested_meta_fails() { + // Construct an attribute whose token stream begins with garbage + // that `parse_nested_meta` will refuse to parse (a stray `@` + // before the first key). Because the parser bails immediately, + // the callback for `default` never fires, and the manual + // token-string fallback at the end of `extract_default` is the + // only path that detects the standalone `default` keyword. + let tokens: TokenStream = "@bogus, default".parse().expect("token stream parses"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_default(&[attr]); + assert_eq!( + result, + Some(None), + "fallback path must detect bare `default`" + ); + } + + /// Test that the fallback's "default appears as a substring inside + /// another identifier" branch returns None (no false-positive + /// match). Exercises the trailing `None` arm of + /// `scan_default_from_raw_tokens` (substring found, but neither + /// `=` follows nor delimiter chars surround it). + #[test] + fn test_extract_default_substring_in_identifier_is_not_a_match() { + // `field_default` contains "default" but as a suffix of an + // identifier — `before_char` is `_`, not one of the valid + // delimiters, so the standalone check fails. + let tokens: TokenStream = "@bogus, field_default" + .parse() + .expect("token stream parses"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_default(&[attr]); + assert_eq!( + result, None, + "embedded 'default' substring must not register as default" + ); + } + + /// Test extract_default with function fallback + #[test] + fn test_extract_default_with_function_fallback_path() { + let attrs = get_field_attrs(r#"default = "my_default_fn""#); + let result = extract_default(&attrs); + assert_eq!(result, Some(Some("my_default_fn".to_string()))); + } + + /// Test that rename_all is NOT confused with rename + #[test] + fn test_extract_field_rename_avoids_rename_all() { + let attrs = get_field_attrs(r#"rename_all = "camelCase""#); + let result = extract_field_rename(&attrs); + assert_eq!(result, None); // Should NOT extract rename_all as rename + } + + /// Test empty serde attribute + #[test] + fn test_extract_functions_with_empty_serde() { + let item: syn::ItemStruct = syn::parse_str(r"#[serde()] struct Foo;").unwrap(); + assert_eq!(extract_rename_all(&item.attrs), None); + } + + /// Test non-serde attribute is ignored + #[test] + fn test_extract_functions_ignore_non_serde() { + let item: syn::ItemStruct = syn::parse_str(r"#[derive(Debug)] struct Foo;").unwrap(); + assert_eq!(extract_rename_all(&item.attrs), None); + assert_eq!(extract_field_rename(&item.attrs), None); + } + + /// Test serde attribute that is not a list (e.g., #[serde]) + #[test] + fn test_extract_rename_all_non_list_serde() { + // #[serde] without parentheses - this should just be ignored + let item: syn::ItemStruct = syn::parse_str(r"#[serde] struct Foo;").unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result, None); + } + + /// Test extract_field_rename with complex attribute + #[test] + fn test_extract_field_rename_complex_attr() { + let attrs = get_field_attrs( + r#"default, rename = "field_name", skip_serializing_if = "Option::is_none""#, + ); + let result = extract_field_rename(&attrs); + assert_eq!(result.as_deref(), Some("field_name")); + } + + /// Test extract_rename_all with multiple serde attributes on same item + #[test] + fn test_extract_rename_all_multiple_serde_attrs() { + let item: syn::ItemStruct = syn::parse_str( + r#" + #[serde(default)] + #[serde(rename_all = "snake_case")] + struct Foo; + "#, + ) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("snake_case")); + } + + /// Test edge case: rename_all with extra whitespace (manual parsing should handle) + #[test] + fn test_extract_rename_all_with_whitespace() { + // Note: syn normalizes whitespace in parsed tokens, so this tests the robust parsing + let attrs = get_struct_attrs(r#"rename_all = "PascalCase""#); + let result = extract_rename_all(&attrs); + assert_eq!(result.as_deref(), Some("PascalCase")); + } + + /// Test edge case: rename at various positions + #[test] + fn test_extract_field_rename_at_end() { + let attrs = get_field_attrs(r#"skip_serializing_if = "is_none", rename = "lastField""#); + let result = extract_field_rename(&attrs); + assert_eq!(result.as_deref(), Some("lastField")); + } + + /// Test extract_default when it appears with other attrs + #[test] + fn test_extract_default_among_other_attrs() { + let attrs = + get_field_attrs(r#"skip_serializing_if = "is_none", default, rename = "field""#); + let result = extract_default(&attrs); + assert_eq!(result, Some(None)); + } + + /// Test extract_skip - basic functionality + #[test] + fn test_extract_skip_basic() { + let attrs = get_field_attrs(r"skip"); + let result = extract_skip(&attrs); + assert!(result); + } + + /// Test extract_skip does not trigger for skip_serializing_if + #[test] + fn test_extract_skip_not_skip_serializing_if() { + let attrs = get_field_attrs(r#"skip_serializing_if = "Option::is_none""#); + let result = extract_skip(&attrs); + assert!(!result); + } + + /// Test extract_skip does not trigger for skip_deserializing + #[test] + fn test_extract_skip_not_skip_deserializing() { + let attrs = get_field_attrs(r"skip_deserializing"); + let result = extract_skip(&attrs); + assert!(!result); + } + + /// Test extract_skip with combined attrs + #[test] + fn test_extract_skip_with_other_attrs() { + let attrs = get_field_attrs(r"skip, default"); + let result = extract_skip(&attrs); + assert!(result); + } + + /// Test extract_default function with path containing colons + #[test] + fn test_extract_default_with_path() { + let attrs = get_field_attrs(r#"default = "Default::default""#); + let result = extract_default(&attrs); + assert_eq!(result, Some(Some("Default::default".to_string()))); + } + + /// Test extract_rename_all with all supported formats + #[rstest] + #[case("camelCase")] + #[case("snake_case")] + #[case("kebab-case")] + #[case("PascalCase")] + #[case("lowercase")] + #[case("UPPERCASE")] + #[case("SCREAMING_SNAKE_CASE")] + #[case("SCREAMING-KEBAB-CASE")] + fn test_extract_rename_all_all_formats(#[case] format: &str) { + let attrs = get_struct_attrs(&format!(r#"rename_all = "{format}""#)); + let result = extract_rename_all(&attrs); + assert_eq!(result.as_deref(), Some(format)); + } + + /// Test non-serde attribute doesn't affect extraction + #[test] + fn test_mixed_attributes() { + let item: syn::ItemStruct = syn::parse_str( + r#" + #[derive(Debug, Clone)] + #[serde(rename_all = "camelCase")] + #[doc = "Some documentation"] + struct Foo; + "#, + ) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test field with multiple serde attributes + #[test] + fn test_field_multiple_serde_attrs() { + let item: syn::ItemStruct = syn::parse_str( + r#" + struct Foo { + #[serde(default)] + #[serde(rename = "customName")] + field: i32 + } + "#, + ) + .unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let attrs = &fields.named.first().unwrap().attrs; + let rename = extract_field_rename(attrs); + let default = extract_default(attrs); + assert_eq!(rename.as_deref(), Some("customName")); + assert_eq!(default, Some(None)); + } + } + + /// Test extract_rename_all with programmatic tokens + #[test] + fn test_extract_rename_all_programmatic() { + let tokens = quote!(rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_rename_all with invalid value (not a string) + #[test] + fn test_extract_rename_all_invalid_value() { + let tokens = quote!(rename_all = camelCase); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + // parse_nested_meta won't find a string literal + assert!(result.is_none()); + } + + /// Test extract_rename_all with missing equals sign + #[test] + fn test_extract_rename_all_no_equals() { + let tokens = quote!(rename_all "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert!(result.is_none()); + } + + /// Test extract_field_rename with programmatic tokens + #[test] + fn test_extract_field_rename_programmatic() { + let tokens = quote!(rename = "customField"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + assert_eq!(result.as_deref(), Some("customField")); + } + + /// Test extract_default standalone with programmatic tokens + #[test] + fn test_extract_default_programmatic() { + let tokens = quote!(default); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_default(&[attr]); + assert_eq!(result, Some(None)); + } + + /// Test extract_default with function via programmatic tokens + #[test] + fn test_extract_default_with_fn_programmatic() { + let tokens = quote!(default = "my_fn"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_default(&[attr]); + assert_eq!(result, Some(Some("my_fn".to_string()))); + } + + /// Test extract_skip via programmatic tokens + #[test] + fn test_extract_skip_programmatic() { + let tokens = quote!(skip); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_skip(&[attr]); + assert!(result); + } + + /// Test that rename_all is not confused with rename + #[test] + fn test_rename_all_not_rename() { + let tokens = quote!(rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + assert_eq!(result, None); + } + + /// Test multiple items in serde attribute + #[test] + fn test_multiple_items_programmatic() { + let tokens = quote!(default, rename = "myField", skip_serializing_if = "is_none"); + let attr = create_attr_with_raw_tokens(tokens); + + let rename_result = extract_field_rename(std::slice::from_ref(&attr)); + let default_result = extract_default(std::slice::from_ref(&attr)); + + assert_eq!(rename_result.as_deref(), Some("myField")); + assert_eq!(default_result, Some(None)); + } + + /// Test extract_rename_all fallback parsing + #[test] + fn test_extract_rename_all_fallback_manual_parsing() { + let tokens = quote!(rename_all = "kebab-case"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("kebab-case")); + } + + /// Test extract_rename_all with complex attribute that forces fallback + #[test] + fn test_extract_rename_all_complex_attribute_fallback() { + let tokens = quote!(default, rename_all = "SCREAMING_SNAKE_CASE", skip); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("SCREAMING_SNAKE_CASE")); + } + + /// Test extract_rename_all when value is not a string literal + #[test] + fn test_extract_rename_all_no_quote_start() { + let tokens = quote!(rename_all = snake_case); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert!(result.is_none()); + } + + /// Test extract_rename_all with unclosed quote + #[test] + fn test_extract_rename_all_unclosed_quote() { + let tokens = quote!(rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_rename_all with empty string value + #[test] + fn test_extract_rename_all_empty_string() { + let tokens = quote!(rename_all = ""); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("")); + } + + /// Test extract_rename_all with QUALIFIED PATH to force fallback + #[test] + fn test_extract_rename_all_qualified_path_forces_fallback() { + let tokens = quote!(serde_with::rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_rename_all with another qualified path variation + #[test] + fn test_extract_rename_all_module_qualified_forces_fallback() { + let tokens = quote!(my_module::rename_all = "snake_case"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("snake_case")); + } + + /// Test extract_rename_all with deeply qualified path + #[test] + fn test_extract_rename_all_deeply_qualified_forces_fallback() { + let tokens = quote!(a::b::rename_all = "PascalCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("PascalCase")); + } + + /// CRITICAL TEST: This test MUST hit fallback path + #[test] + fn test_extract_rename_all_raw_tokens_force_fallback() { + let tokens: TokenStream = "__rename_all_prefix::rename_all = \"lowercase\"" + .parse() + .unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + + if let syn::Meta::List(list) = &attr.meta { + let token_str = list.tokens.to_string(); + assert!( + token_str.contains("rename_all"), + "Token string should contain rename_all: {token_str}" + ); + } + + let result = extract_rename_all(&[attr]); + assert_eq!( + result.as_deref(), + Some("lowercase"), + "Fallback parsing must extract the value" + ); + } + + /// Another critical test with different qualified path format + #[test] + fn test_extract_rename_all_crate_qualified_forces_fallback() { + let tokens: TokenStream = "crate::rename_all = \"UPPERCASE\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("UPPERCASE")); + } + + /// Test with self:: prefix + #[test] + fn test_extract_rename_all_self_qualified_forces_fallback() { + let tokens: TokenStream = "self::rename_all = \"kebab-case\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("kebab-case")); + } + + // ================================================================= + // FALLBACK PATH TESTS (Lines 173, 258-265, 573, 583-590, 626) + // ================================================================= + + /// Test extract_field_rename fallback path - Line 173 + /// Tests the word boundary check when "rename" appears with other attributes + /// This triggers the manual token parsing fallback when parse_nested_meta + /// doesn't extract the value in expected format + #[test] + fn test_extract_field_rename_fallback_word_boundary() { + // Create attribute with qualified path to force fallback + let tokens: TokenStream = "my_module::rename = \"value\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + assert_eq!(result.as_deref(), Some("value")); + } + + /// Test extract_field_rename fallback - complex combined attributes + /// Line 173: Tests the edge case of word boundary checking + #[test] + fn test_extract_field_rename_fallback_complex_attr() { + // Qualified path forces parse_nested_meta to not find "rename" + let tokens: TokenStream = "crate::other::rename = \"custom_field\", default" + .parse() + .unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + assert_eq!(result.as_deref(), Some("custom_field")); + } + + /// Test extract_field_rename - ensure rename_all is not matched as rename + /// Test the word boundary logic + #[test] + fn test_extract_field_rename_fallback_avoids_rename_all() { + let tokens: TokenStream = "some::rename_all = \"camelCase\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + // Should NOT match rename_all as rename + assert_eq!(result, None); + } + + /// Test extract_flatten fallback path - Lines 258-265 + /// Forces manual token parsing by using qualified path + #[test] + fn test_extract_flatten_fallback_path() { + let tokens: TokenStream = "my_module::flatten".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_flatten(&[attr]); + assert!(result, "Fallback should find 'flatten' in token string"); + } + + /// Test extract_flatten fallback with complex attributes + /// Lines 258-263: Tests word boundary checking in fallback + #[test] + fn test_extract_flatten_fallback_complex() { + let tokens: TokenStream = "crate::flatten, default = \"my_fn\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_flatten(&[attr]); + assert!(result, "Fallback should detect flatten with other attrs"); + } + + /// Test extract_flatten fallback with flatten at different positions + /// Line 265: Tests the return true path in fallback + #[test] + fn test_extract_flatten_fallback_at_end() { + let tokens: TokenStream = "default, some::flatten".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_flatten(&[attr]); + assert!(result); + } + + /// Test extract_flatten fallback doesn't match partial words + #[test] + fn test_extract_flatten_fallback_no_partial_match() { + // "flattened" should not match "flatten" + let tokens: TokenStream = "flattened".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_flatten(&[attr]); + assert!(!result, "Should not match 'flattened' as 'flatten'"); + } + // ================================================================= + // MULTIPART FALLBACK TESTS (form_data / try_from_multipart) + // ================================================================= + + /// Test extract_field_rename falls back to #[form_data(field_name = "...")] + #[test] + fn test_extract_field_rename_form_data_fallback() { + let struct_src = r#"struct Foo { #[form_data(field_name = "my_file")] field: i32 }"#; + let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result.as_deref(), Some("my_file")); + } + } + + /// Test serde rename takes priority over form_data field_name + #[test] + fn test_extract_field_rename_serde_over_form_data() { + let struct_src = r#"struct Foo { #[serde(rename = "serde_name")] #[form_data(field_name = "form_name")] field: i32 }"#; + let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result.as_deref(), Some("serde_name")); + } + } + + /// Test extract_field_rename with form_data but no field_name key + #[test] + fn test_extract_field_rename_form_data_no_field_name() { + let struct_src = r#"struct Foo { #[form_data(limit = "10MiB")] field: i32 }"#; + let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result, None); + } + } + + /// Test extract_rename_all falls back to #[try_from_multipart(rename_all = "...")] + #[test] + fn test_extract_rename_all_try_from_multipart_fallback() { + let item: syn::ItemStruct = + syn::parse_str(r#"#[try_from_multipart(rename_all = "camelCase")] struct Foo;"#) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test serde rename_all takes priority over try_from_multipart rename_all + #[test] + fn test_extract_rename_all_serde_over_try_from_multipart() { + let item: syn::ItemStruct = syn::parse_str(r#"#[serde(rename_all = "snake_case")] #[try_from_multipart(rename_all = "camelCase")] struct Foo;"#).unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("snake_case")); + } + + /// Test extract_rename_all with try_from_multipart but no rename_all key + #[test] + fn test_extract_rename_all_try_from_multipart_no_rename_all() { + let item: syn::ItemStruct = + syn::parse_str(r"#[try_from_multipart(strict)] struct Foo;").unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result, None); + } +} diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs/rename_case.rs b/crates/vespera_macro/src/parser/schema/serde_attrs/rename_case.rs new file mode 100644 index 00000000..a6020fd9 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/rename_case.rs @@ -0,0 +1,243 @@ +#[allow(clippy::too_many_lines)] +pub fn rename_field(field_name: &str, rename_all: Option<&str>) -> String { + // "lowercase", "UPPERCASE", "PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE" + match rename_all { + Some("camelCase") => { + // Convert snake_case or PascalCase to camelCase + let mut result = String::new(); + let mut capitalize_next = false; + let mut in_first_word = true; + let chars: Vec = field_name.chars().collect(); + + for (i, &ch) in chars.iter().enumerate() { + if ch == '_' { + capitalize_next = true; + in_first_word = false; + continue; + } + if in_first_word { + // In first word: lowercase until we hit a word boundary + // Word boundary: uppercase char followed by lowercase (e.g., "XMLParser" -> "P" starts new word) + let next_is_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase()); + if ch.is_uppercase() && next_is_lower && i > 0 { + // This uppercase starts a new word (e.g., 'P' in "XMLParser") + in_first_word = false; + result.push(ch); + } else { + // Still in first word, lowercase it + result.push(ch.to_ascii_lowercase()); + } + continue; + } + if capitalize_next { + result.push(ch.to_ascii_uppercase()); + capitalize_next = false; + continue; + } + result.push(ch); + } + result + } + Some("snake_case") => { + // Convert camelCase to snake_case + let mut result = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + result.push('_'); + } + result.push(ch.to_ascii_lowercase()); + } + result + } + Some("kebab-case") => { + // Convert snake_case or Camel/PascalCase to kebab-case (lowercase with hyphens) + let mut result = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() { + if i > 0 && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_ascii_lowercase()); + } else if ch == '_' { + result.push('-'); + } else { + result.push(ch); + } + } + result + } + Some("PascalCase") => { + // Convert snake_case to PascalCase + let mut result = String::new(); + let mut capitalize_next = true; + for ch in field_name.chars() { + if ch == '_' { + capitalize_next = true; + } else if capitalize_next { + result.push(ch.to_ascii_uppercase()); + capitalize_next = false; + } else { + result.push(ch); + } + } + result + } + Some("lowercase") => { + // Convert to lowercase + field_name.to_lowercase() + } + Some("UPPERCASE") => { + // Convert to UPPERCASE + field_name.to_uppercase() + } + Some("SCREAMING_SNAKE_CASE") => { + // Convert to SCREAMING_SNAKE_CASE + // If already in SCREAMING_SNAKE_CASE format, return as is + if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_') + { + return field_name.to_string(); + } + // First convert to snake_case if needed, then uppercase + let mut snake_case = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() && i > 0 && !snake_case.ends_with('_') { + snake_case.push('_'); + } + if ch != '_' && ch != '-' { + snake_case.push(ch.to_ascii_lowercase()); + } else if ch == '_' { + snake_case.push('_'); + } + } + snake_case.to_uppercase() + } + Some("SCREAMING-KEBAB-CASE") => { + // Convert to SCREAMING-KEBAB-CASE + // First convert to kebab-case if needed, then uppercase + let mut kebab_case = String::new(); + for (i, ch) in field_name.chars().enumerate() { + if ch.is_uppercase() + && i > 0 + && !kebab_case.ends_with('-') + && !kebab_case.ends_with('_') + { + kebab_case.push('-'); + } + if ch == '_' { + kebab_case.push('-'); + } else if ch != '-' { + kebab_case.push(ch.to_ascii_lowercase()); + } else { + kebab_case.push('-'); + } + } + kebab_case.to_uppercase() + } + _ => field_name.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + #[rstest] + // camelCase tests (snake_case input) + #[case("user_name", Some("camelCase"), "userName")] + #[case("first_name", Some("camelCase"), "firstName")] + #[case("last_name", Some("camelCase"), "lastName")] + #[case("user_id", Some("camelCase"), "userId")] + #[case("api_key", Some("camelCase"), "apiKey")] + #[case("already_camel", Some("camelCase"), "alreadyCamel")] + // camelCase tests (PascalCase input) + #[case("UserName", Some("camelCase"), "userName")] + #[case("UserCreated", Some("camelCase"), "userCreated")] + #[case("FirstName", Some("camelCase"), "firstName")] + #[case("ID", Some("camelCase"), "id")] + #[case("XMLParser", Some("camelCase"), "xmlParser")] + #[case("HTTPSConnection", Some("camelCase"), "httpsConnection")] + // snake_case tests + #[case("userName", Some("snake_case"), "user_name")] + #[case("firstName", Some("snake_case"), "first_name")] + #[case("lastName", Some("snake_case"), "last_name")] + #[case("userId", Some("snake_case"), "user_id")] + #[case("apiKey", Some("snake_case"), "api_key")] + #[case("already_snake", Some("snake_case"), "already_snake")] + // kebab-case tests + #[case("user_name", Some("kebab-case"), "user-name")] + #[case("first_name", Some("kebab-case"), "first-name")] + #[case("last_name", Some("kebab-case"), "last-name")] + #[case("user_id", Some("kebab-case"), "user-id")] + #[case("api_key", Some("kebab-case"), "api-key")] + #[case("already-kebab", Some("kebab-case"), "already-kebab")] + // PascalCase tests + #[case("user_name", Some("PascalCase"), "UserName")] + #[case("first_name", Some("PascalCase"), "FirstName")] + #[case("last_name", Some("PascalCase"), "LastName")] + #[case("user_id", Some("PascalCase"), "UserId")] + #[case("api_key", Some("PascalCase"), "ApiKey")] + #[case("AlreadyPascal", Some("PascalCase"), "AlreadyPascal")] + // lowercase tests + #[case("UserName", Some("lowercase"), "username")] + #[case("FIRST_NAME", Some("lowercase"), "first_name")] + #[case("lastName", Some("lowercase"), "lastname")] + #[case("User_ID", Some("lowercase"), "user_id")] + #[case("API_KEY", Some("lowercase"), "api_key")] + #[case("already_lower", Some("lowercase"), "already_lower")] + // UPPERCASE tests + #[case("user_name", Some("UPPERCASE"), "USER_NAME")] + #[case("firstName", Some("UPPERCASE"), "FIRSTNAME")] + #[case("LastName", Some("UPPERCASE"), "LASTNAME")] + #[case("user_id", Some("UPPERCASE"), "USER_ID")] + #[case("apiKey", Some("UPPERCASE"), "APIKEY")] + #[case("ALREADY_UPPER", Some("UPPERCASE"), "ALREADY_UPPER")] + // SCREAMING_SNAKE_CASE tests + #[case("user_name", Some("SCREAMING_SNAKE_CASE"), "USER_NAME")] + #[case("firstName", Some("SCREAMING_SNAKE_CASE"), "FIRST_NAME")] + #[case("LastName", Some("SCREAMING_SNAKE_CASE"), "LAST_NAME")] + #[case("user_id", Some("SCREAMING_SNAKE_CASE"), "USER_ID")] + #[case("apiKey", Some("SCREAMING_SNAKE_CASE"), "API_KEY")] + #[case("ALREADY_SCREAMING", Some("SCREAMING_SNAKE_CASE"), "ALREADY_SCREAMING")] + // SCREAMING-KEBAB-CASE tests + #[case("user_name", Some("SCREAMING-KEBAB-CASE"), "USER-NAME")] + #[case("firstName", Some("SCREAMING-KEBAB-CASE"), "FIRST-NAME")] + #[case("LastName", Some("SCREAMING-KEBAB-CASE"), "LAST-NAME")] + #[case("user_id", Some("SCREAMING-KEBAB-CASE"), "USER-ID")] + #[case("apiKey", Some("SCREAMING-KEBAB-CASE"), "API-KEY")] + #[case("already-kebab", Some("SCREAMING-KEBAB-CASE"), "ALREADY-KEBAB")] + // None tests (no transformation) + #[case("user_name", None, "user_name")] + #[case("firstName", None, "firstName")] + #[case("LastName", None, "LastName")] + #[case("user-id", None, "user-id")] + fn test_rename_field( + #[case] field_name: &str, + #[case] rename_all: Option<&str>, + #[case] expected: &str, + ) { + assert_eq!(rename_field(field_name, rename_all), expected); + } + // Test camelCase transformation with mixed characters + #[test] + fn test_rename_field_camelcase_with_digits() { + // Tests the regular character branch in camelCase + let result = rename_field("user_id_123", Some("camelCase")); + assert_eq!(result, "userId123"); + + let result = rename_field("get_user_by_id", Some("camelCase")); + assert_eq!(result, "getUserById"); + } + // Test rename_field with unknown/invalid rename_all format - should return original field name + #[test] + fn test_rename_field_unknown_format() { + // Unknown format should return the original field name unchanged + let result = rename_field("my_field", Some("unknown_format")); + assert_eq!(result, "my_field"); + + let result = rename_field("myField", Some("invalid")); + assert_eq!(result, "myField"); + + let result = rename_field("test_name", Some("not_a_real_format")); + assert_eq!(result, "test_name"); + } +} diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__adjacently_tagged_snapshot@adjacently_tagged.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__adjacently_tagged_snapshot@adjacently_tagged.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__adjacently_tagged_snapshot@adjacently_tagged.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__adjacently_tagged_snapshot@adjacently_tagged.snap diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__externally_tagged_empty_struct_variant@externally_tagged_empty_struct.snap diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_skips_tuple_variant@internally_tagged_skip_tuple.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__internally_tagged_skips_tuple_variant@internally_tagged_skip_tuple.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_skips_tuple_variant@internally_tagged_skip_tuple.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__internally_tagged_skips_tuple_variant@internally_tagged_skip_tuple.snap diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_snapshot@internally_tagged.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__internally_tagged_snapshot@internally_tagged.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__internally_tagged_snapshot@internally_tagged.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__internally_tagged_snapshot@internally_tagged.snap diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__untagged_multi_field_tuple_variant@untagged_multi_field_tuple.snap diff --git a/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_snapshot@untagged.snap b/crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__untagged_snapshot@untagged.snap similarity index 100% rename from crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__tests__enum_repr_tests__untagged_snapshot@untagged.snap rename to crates/vespera_macro/src/parser/schema/snapshots/vespera_macro__parser__schema__enum_schema__representations__tests__untagged_snapshot@untagged.snap diff --git a/crates/vespera_macro/src/parser/schema/struct_schema.rs b/crates/vespera_macro/src/parser/schema/struct_schema.rs index 2f6f2266..83e13f3e 100644 --- a/crates/vespera_macro/src/parser/schema/struct_schema.rs +++ b/crates/vespera_macro/src/parser/schema/struct_schema.rs @@ -3,9 +3,13 @@ //! This module handles the conversion of Rust structs (as parsed by syn) //! into OpenAPI-compatible JSON Schema definitions. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::{ + borrow::Borrow, + collections::{BTreeMap, HashMap, HashSet}, + hash::Hash, +}; -use syn::{Fields, Type}; +use syn::Fields; use vespera_core::schema::{Schema, SchemaRef, SchemaType}; use super::{ @@ -17,12 +21,14 @@ use super::{ }, type_schema::parse_type_to_schema_ref, }; +use crate::schema_macro::type_utils::is_option_type; /// Parses a Rust struct into an `OpenAPI` Schema. /// /// This function extracts: /// - Field names and types as properties -/// - Required fields (non-Option types without defaults) +/// - Required fields (non-`Option` types; `#[serde(default)]` does NOT relax +/// `required`, since this schema is shared by request and response bodies) /// - Doc comments as descriptions /// - Serde attributes (rename, `rename_all`, skip, default) /// @@ -33,8 +39,8 @@ use super::{ #[allow(clippy::too_many_lines)] pub fn parse_struct_to_schema( struct_item: &syn::ItemStruct, - known_schemas: &HashSet, - struct_definitions: &HashMap, + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, ) -> Schema { let mut properties = BTreeMap::new(); let mut required = Vec::with_capacity(8); @@ -158,16 +164,12 @@ pub fn parse_struct_to_schema( // Required is determined solely by nullability (Option). // Fields with #[serde(default)] still have defaults applied in - // openapi_generator, but that does NOT affect required status. - let is_optional = matches!( - field_type, - Type::Path(type_path) - if type_path - .path - .segments - .first() - .is_some_and(|s| s.ident == "Option") - ); + // openapi_generator, but that does NOT affect required status: + // this schema is shared by request AND response bodies, and a + // defaulted field is always present on output, so it stays + // required (deliberate, documented in README; the query + // extractor differs because query params are input-only). + let is_optional = is_option_type(field_type); if !is_optional { required.push(field_name.clone()); @@ -270,11 +272,11 @@ fn apply_constraints(schema: &mut Schema, c: &SchemaConstraints) { if let Some(v) = c.maximum { schema.maximum = Some(v); } - if let Some(v) = c.exclusive_minimum { - schema.exclusive_minimum = Some(v); + if c.exclusive_minimum == Some(true) { + schema.exclusive_minimum = c.minimum; } - if let Some(v) = c.exclusive_maximum { - schema.exclusive_maximum = Some(v); + if c.exclusive_maximum == Some(true) { + schema.exclusive_maximum = c.maximum; } if let Some(v) = c.multiple_of { schema.multiple_of = Some(v); @@ -303,599 +305,4 @@ fn apply_constraints(schema: &mut Schema, c: &SchemaConstraints) { } #[cfg(test)] -mod tests { - use rstest::rstest; - - use super::*; - - #[test] - fn test_parse_struct_to_schema_required_optional() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct User { - id: i32, - name: Option, - } - ", - ) - .unwrap(); - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - let props = schema.properties.as_ref().unwrap(); - assert!(props.contains_key("id")); - assert!(props.contains_key("name")); - assert!( - schema - .required - .as_ref() - .unwrap() - .contains(&"id".to_string()) - ); - assert!( - !schema - .required - .as_ref() - .unwrap() - .contains(&"name".to_string()) - ); - } - - #[test] - fn test_parse_struct_to_schema_rename_all_and_field_rename() { - let struct_item: syn::ItemStruct = syn::parse_str( - r#" - #[serde(rename_all = "camelCase")] - struct Profile { - #[serde(rename = "id")] - user_id: i32, - display_name: Option, - } - "#, - ) - .unwrap(); - - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - let props = schema.properties.as_ref().expect("props missing"); - assert!(props.contains_key("id")); // field-level rename wins - assert!(props.contains_key("displayName")); // rename_all applied - let required = schema.required.as_ref().expect("required missing"); - assert!(required.contains(&"id".to_string())); - assert!(!required.contains(&"displayName".to_string())); // Option makes it optional - } - - #[rstest] - #[case("struct Wrapper(i32);")] - #[case("struct Empty;")] - fn test_parse_struct_to_schema_tuple_and_unit_structs(#[case] struct_src: &str) { - let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - assert!(schema.properties.is_none()); - assert!(schema.required.is_none()); - } - - #[test] - fn test_parse_struct_to_schema_serde_transparent_named_wrapper_uses_inner_schema() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - #[serde(transparent)] - struct Wrapper { - value: Box, - } - ", - ) - .unwrap(); - - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - assert_eq!(schema.schema_type, Some(SchemaType::String)); - assert!(schema.properties.is_none()); - } - - #[test] - fn test_parse_struct_to_schema_schema_ref_override() { - let struct_item: syn::ItemStruct = syn::parse_str( - r#" - #[schema(ref = "UserSchema", nullable)] - struct Wrapper { - value: Option, - } - "#, - ) - .unwrap(); - - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - assert_eq!( - schema.ref_path.as_deref(), - Some("#/components/schemas/UserSchema") - ); - assert_eq!(schema.nullable, Some(true)); - } - - // Test struct with skip field - #[test] - fn test_parse_struct_to_schema_with_skip_field() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct User { - id: i32, - #[serde(skip)] - internal_data: String, - name: String, - } - ", - ) - .unwrap(); - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - let props = schema.properties.as_ref().unwrap(); - assert!(props.contains_key("id")); - assert!(props.contains_key("name")); - assert!(!props.contains_key("internal_data")); // Should be skipped - } - - // Test struct with default and skip_serializing_if - // Required is determined solely by nullability (Option), not by defaults. - #[test] - fn test_parse_struct_to_schema_with_default_fields() { - let struct_item: syn::ItemStruct = syn::parse_str( - r#" - struct Config { - required_field: i32, - #[serde(default)] - with_default: String, - #[serde(skip_serializing_if = "Option::is_none")] - maybe_skip: Option, - } - "#, - ) - .unwrap(); - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - let props = schema.properties.as_ref().unwrap(); - assert!(props.contains_key("required_field")); - assert!(props.contains_key("with_default")); - assert!(props.contains_key("maybe_skip")); - - let required = schema.required.as_ref().unwrap(); - assert!(required.contains(&"required_field".to_string())); - // Non-nullable fields are always required, even with #[serde(default)] - assert!(required.contains(&"with_default".to_string())); - // Option fields are not required (nullable) - assert!(!required.contains(&"maybe_skip".to_string())); - } - - // Tests for struct with doc comments - #[test] - fn test_parse_struct_to_schema_with_description() { - let struct_src = r" - /// User struct description - struct User { - /// User ID - id: i32, - /// User name - name: String, - } - "; - let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - assert_eq!( - schema.description, - Some("User struct description".to_string()) - ); - // Check field descriptions - let props = schema.properties.unwrap(); - if let SchemaRef::Inline(id_schema) = props.get("id").unwrap() { - assert_eq!(id_schema.description, Some("User ID".to_string())); - } - if let SchemaRef::Inline(name_schema) = props.get("name").unwrap() { - assert_eq!(name_schema.description, Some("User name".to_string())); - } - } - - #[test] - fn test_parse_struct_to_schema_field_with_ref_and_description() { - let struct_src = r" - struct Container { - /// The user reference - user: User, - } - "; - let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); - let mut struct_defs = HashMap::new(); - struct_defs.insert("User".to_string(), "struct User { id: i32 }".to_string()); - let mut known = HashSet::new(); - known.insert("User".to_string()); - let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs); - let props = schema.properties.unwrap(); - // Field with $ref and description should use allOf - if let SchemaRef::Inline(user_schema) = props.get("user").unwrap() { - assert_eq!( - user_schema.description, - Some("The user reference".to_string()) - ); - assert!(user_schema.all_of.is_some()); - } - } - - #[test] - fn test_parse_struct_to_schema_description_strips_slash_prefix() { - // When doc attributes have "/ " prefix (without leading space), descriptions should be clean. - // This can happen in certain TokenStream roundtrip scenarios. - let struct_item: syn::ItemStruct = syn::parse_str( - r#" - #[doc = "/ Struct description"] - struct Admin { - #[doc = "/ Field description"] - id: i32, - } - "#, - ) - .unwrap(); - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - assert_eq!(schema.description, Some("Struct description".to_string())); - let props = schema.properties.unwrap(); - if let SchemaRef::Inline(id_schema) = props.get("id").unwrap() { - assert_eq!(id_schema.description, Some("Field description".to_string())); - } - } - - #[test] - fn test_parse_struct_to_schema_with_flatten() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct UserListRequest { - filter: String, - #[serde(flatten)] - pagination: Pagination, - } - ", - ) - .unwrap(); - - let mut struct_defs = HashMap::new(); - struct_defs.insert( - "Pagination".to_string(), - "struct Pagination { page: i32 }".to_string(), - ); - let mut known = HashSet::new(); - known.insert("Pagination".to_string()); - - let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs); - - // Should have allOf - assert!( - schema.all_of.is_some(), - "Schema should have allOf for flatten" - ); - let all_of = schema.all_of.as_ref().unwrap(); - assert_eq!(all_of.len(), 2, "allOf should have 2 elements"); - - // First element should be the object with non-flattened properties - if let SchemaRef::Inline(obj_schema) = &all_of[0] { - let props = obj_schema.properties.as_ref().unwrap(); - assert!(props.contains_key("filter"), "Should have filter property"); - assert!( - !props.contains_key("pagination"), - "Should NOT have pagination property" - ); - } else { - panic!("First allOf element should be inline schema"); - } - - // Second element should be $ref to Pagination - if let SchemaRef::Ref(reference) = &all_of[1] { - assert_eq!(reference.ref_path, "#/components/schemas/Pagination"); - } else { - panic!("Second allOf element should be $ref"); - } - } - - #[test] - fn test_parse_struct_to_schema_with_multiple_flatten() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct Combined { - name: String, - #[serde(flatten)] - pagination: Pagination, - #[serde(flatten)] - metadata: Metadata, - } - ", - ) - .unwrap(); - - let mut struct_defs = HashMap::new(); - struct_defs.insert("Pagination".to_string(), "struct Pagination {}".to_string()); - struct_defs.insert("Metadata".to_string(), "struct Metadata {}".to_string()); - let mut known = HashSet::new(); - known.insert("Pagination".to_string()); - known.insert("Metadata".to_string()); - - let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs); - - assert!(schema.all_of.is_some()); - let all_of = schema.all_of.as_ref().unwrap(); - assert_eq!( - all_of.len(), - 3, - "allOf should have 3 elements (1 inline + 2 refs)" - ); - } - - #[test] - fn test_parse_struct_to_schema_no_flatten() { - // Existing struct without flatten should NOT use allOf - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct Simple { - name: String, - age: i32, - } - ", - ) - .unwrap(); - - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - assert!( - schema.all_of.is_none(), - "Simple struct should not have allOf" - ); - assert!(schema.properties.is_some()); - } - - #[test] - fn test_parse_struct_to_schema_transparent_tuple_wrapper_uses_ref_schema() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - #[serde(transparent)] - struct Wrapper(User); - ", - ) - .unwrap(); - - let mut struct_defs = HashMap::new(); - struct_defs.insert("User".to_string(), "struct User { id: i32 }".to_string()); - let mut known = HashSet::new(); - known.insert("User".to_string()); - - let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs); - assert!(schema.all_of.is_some()); - let all_of = schema.all_of.unwrap(); - assert_eq!(all_of.len(), 1); - match &all_of[0] { - SchemaRef::Ref(reference) => { - assert_eq!(reference.ref_path, "#/components/schemas/User"); - } - SchemaRef::Inline(_) => { - panic!("expected $ref wrapper for transparent tuple known schema") - } - } - } - - #[test] - fn test_parse_struct_to_schema_transparent_multi_field_tuple_falls_back() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - #[serde(transparent)] - struct Wrapper(String, String); - ", - ) - .unwrap(); - - let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); - assert_eq!(schema.schema_type, Some(SchemaType::Object)); - assert!(schema.properties.is_none()); - assert!(schema.all_of.is_none()); - } - - // ── field-level `#[schema(...)]` constraint propagation ───────── - - fn field_schema<'a>(schema: &'a Schema, field: &str) -> &'a Schema { - let props = schema.properties.as_ref().expect("properties missing"); - let entry = props.get(field).expect("field missing"); - match entry { - SchemaRef::Inline(boxed) => boxed.as_ref(), - SchemaRef::Ref(_) => panic!("expected inline schema for field '{field}'"), - } - } - - #[test] - fn schema_constraints_min_max_length_and_pattern_on_string_field() { - let s: syn::ItemStruct = syn::parse_str( - r#" - struct CreateUser { - #[schema(min_length = 3, max_length = 32, pattern = "^[a-z]+$")] - username: String, - } - "#, - ) - .unwrap(); - let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); - let field = field_schema(&schema, "username"); - assert_eq!(field.min_length, Some(3)); - assert_eq!(field.max_length, Some(32)); - assert_eq!(field.pattern.as_deref(), Some("^[a-z]+$")); - } - - #[test] - fn schema_constraints_minimum_maximum_on_numeric_field() { - let s: syn::ItemStruct = syn::parse_str( - r" - struct Profile { - #[schema(minimum = 0, maximum = 150)] - age: u32, - } - ", - ) - .unwrap(); - let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); - let field = field_schema(&schema, "age"); - assert_eq!(field.minimum, Some(0.0)); - assert_eq!(field.maximum, Some(150.0)); - } - - #[test] - fn schema_constraints_format_email_on_string_field() { - let s: syn::ItemStruct = syn::parse_str( - r#" - struct Contact { - #[schema(format = "email")] - email: String, - } - "#, - ) - .unwrap(); - let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); - let field = field_schema(&schema, "email"); - assert_eq!(field.format.as_deref(), Some("email")); - } - - #[test] - fn schema_constraints_read_only_write_only_example() { - let s: syn::ItemStruct = syn::parse_str( - r#" - struct User { - #[schema(read_only, example = "abc-123")] - id: String, - #[schema(write_only)] - password: String, - } - "#, - ) - .unwrap(); - let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); - let id_field = field_schema(&schema, "id"); - assert_eq!(id_field.read_only, Some(true)); - assert_eq!(id_field.example, Some(serde_json::json!("abc-123"))); - let pw_field = field_schema(&schema, "password"); - assert_eq!(pw_field.write_only, Some(true)); - } - - #[test] - fn schema_constraints_min_max_items_unique_on_vec_field() { - let s: syn::ItemStruct = syn::parse_str( - r" - struct Post { - #[schema(min_items = 1, max_items = 5, unique_items)] - tags: Vec, - } - ", - ) - .unwrap(); - let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); - let field = field_schema(&schema, "tags"); - assert_eq!(field.min_items, Some(1)); - assert_eq!(field.max_items, Some(5)); - assert_eq!(field.unique_items, Some(true)); - } - - #[test] - fn schema_constraints_exclusive_bounds_and_multiple_of() { - let s: syn::ItemStruct = syn::parse_str( - r" - struct Price { - #[schema(minimum = 0, exclusive_minimum, multiple_of = 0.01)] - amount: f64, - } - ", - ) - .unwrap(); - let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); - let field = field_schema(&schema, "amount"); - assert_eq!(field.minimum, Some(0.0)); - assert_eq!(field.exclusive_minimum, Some(true)); - assert_eq!(field.multiple_of, Some(0.01)); - } - - #[test] - fn schema_constraints_on_ref_field_promote_to_allof_wrapper() { - // A field referencing a known component schema must keep its - // `$ref` but gain the constraints via an `allOf` wrapper so the - // OpenAPI consumer still sees the reference. - let mut known = HashSet::new(); - known.insert("Address".to_string()); - let s: syn::ItemStruct = syn::parse_str( - r" - struct Order { - #[schema(read_only)] - shipping: Address, - } - ", - ) - .unwrap(); - let schema = parse_struct_to_schema(&s, &known, &HashMap::new()); - let field = field_schema(&schema, "shipping"); - assert_eq!(field.read_only, Some(true)); - let all_of = field.all_of.as_ref().expect("allOf wrap missing"); - assert_eq!(all_of.len(), 1); - assert!(matches!(all_of[0], SchemaRef::Ref(_))); - } - - #[test] - fn schema_constraints_coexist_with_doc_comment_on_ref_field() { - // When BOTH a doc comment AND constraints are present on a - // `$ref` field, the doc comment converts it to allOf first, then - // constraints are layered onto the same wrapper. - let mut known = HashSet::new(); - known.insert("Address".to_string()); - let s: syn::ItemStruct = syn::parse_str( - r" - struct Order { - /// Shipping address — must be present. - #[schema(read_only, write_only = false)] - shipping: Address, - } - ", - ) - .unwrap(); - let schema = parse_struct_to_schema(&s, &known, &HashMap::new()); - let field = field_schema(&schema, "shipping"); - assert!(field.description.is_some(), "doc comment lost"); - assert_eq!(field.read_only, Some(true)); - assert_eq!(field.write_only, Some(false)); - assert!(field.all_of.is_some(), "allOf wrap lost"); - } - - #[test] - fn schema_constraints_unknown_keys_on_field_are_silently_ignored() { - // Struct-level keys (e.g. `name`) accidentally placed on a field - // attribute should not trip the parser nor produce constraints. - let s: syn::ItemStruct = syn::parse_str( - r#" - struct Account { - #[schema(name = "Stray", min_length = 4)] - pin: String, - } - "#, - ) - .unwrap(); - let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); - let field = field_schema(&schema, "pin"); - assert_eq!(field.min_length, Some(4)); - } - - #[test] - fn schema_exclusive_maximum_and_minimum_land_on_emitted_field_schema() { - // `exclusive_minimum` / `exclusive_maximum` / `multiple_of` / - // `unique_items` are OpenAPI-only annotations (no garde rule - // counterpart). The struct-schema parser still propagates them - // onto the per-field `Schema` so the resulting `openapi.json` - // carries them verbatim. - let s: syn::ItemStruct = syn::parse_str( - r" - struct Price { - #[schema(minimum = 0, maximum = 100, exclusive_minimum, exclusive_maximum, multiple_of = 0.5)] - amount: f64, - - #[schema(min_items = 1, max_items = 5, unique_items)] - tags: Vec, - } - ", - ) - .unwrap(); - let schema = parse_struct_to_schema(&s, &HashSet::new(), &HashMap::new()); - let amount = field_schema(&schema, "amount"); - assert_eq!(amount.exclusive_minimum, Some(true)); - assert_eq!(amount.exclusive_maximum, Some(true)); - assert_eq!(amount.multiple_of, Some(0.5)); - let tags = field_schema(&schema, "tags"); - assert_eq!(tags.unique_items, Some(true)); - } -} +mod tests; diff --git a/crates/vespera_macro/src/parser/schema/struct_schema/tests.rs b/crates/vespera_macro/src/parser/schema/struct_schema/tests.rs new file mode 100644 index 00000000..d851bf19 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/struct_schema/tests.rs @@ -0,0 +1,694 @@ +use rstest::rstest; + +use super::*; + +#[test] +fn test_parse_struct_to_schema_required_optional() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + struct User { + id: i32, + name: Option, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + let props = schema.properties.as_ref().unwrap(); + assert!(props.contains_key("id")); + assert!(props.contains_key("name")); + assert!( + schema + .required + .as_ref() + .unwrap() + .contains(&"id".to_string()) + ); + assert!( + !schema + .required + .as_ref() + .unwrap() + .contains(&"name".to_string()) + ); +} + +#[test] +fn test_parse_struct_to_schema_rename_all_and_field_rename() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[serde(rename_all = "camelCase")] + struct Profile { + #[serde(rename = "id")] + user_id: i32, + display_name: Option, + } + "#, + ) + .unwrap(); + + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + let props = schema.properties.as_ref().expect("props missing"); + assert!(props.contains_key("id")); // field-level rename wins + assert!(props.contains_key("displayName")); // rename_all applied + let required = schema.required.as_ref().expect("required missing"); + assert!(required.contains(&"id".to_string())); + assert!(!required.contains(&"displayName".to_string())); // Option makes it optional +} + +#[rstest] +#[case("struct Wrapper(i32);")] +#[case("struct Empty;")] +fn test_parse_struct_to_schema_tuple_and_unit_structs(#[case] struct_src: &str) { + let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + assert!(schema.properties.is_none()); + assert!(schema.required.is_none()); +} + +#[test] +fn test_parse_struct_to_schema_serde_transparent_named_wrapper_uses_inner_schema() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + #[serde(transparent)] + struct Wrapper { + value: Box, + } + ", + ) + .unwrap(); + + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + assert_eq!(schema.schema_type, Some(SchemaType::String)); + assert!(schema.properties.is_none()); +} + +#[test] +fn test_parse_struct_to_schema_schema_ref_override() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[schema(ref = "UserSchema", nullable)] + struct Wrapper { + value: Option, + } + "#, + ) + .unwrap(); + + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + assert_eq!( + schema.ref_path.as_deref(), + Some("#/components/schemas/UserSchema") + ); + assert_eq!(schema.nullable, Some(true)); +} + +// Test struct with skip field +#[test] +fn test_parse_struct_to_schema_with_skip_field() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + struct User { + id: i32, + #[serde(skip)] + internal_data: String, + name: String, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + let props = schema.properties.as_ref().unwrap(); + assert!(props.contains_key("id")); + assert!(props.contains_key("name")); + assert!(!props.contains_key("internal_data")); // Should be skipped +} + +#[test] +fn test_parse_struct_to_schema_skip_takes_precedence_over_skip_serializing_if() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + struct User { + id: i32, + #[serde(skip, skip_serializing_if = "Option::is_none")] + email2: Option, + name: String, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + let props = schema.properties.as_ref().unwrap(); + assert!(props.contains_key("id")); + assert!(props.contains_key("name")); + assert!(!props.contains_key("email2")); +} + +// Test struct with default and skip_serializing_if +// Required is determined solely by nullability (Option), not by defaults. +#[test] +fn test_parse_struct_to_schema_with_default_fields() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + struct Config { + required_field: i32, + #[serde(default)] + with_default: String, + #[serde(skip_serializing_if = "Option::is_none")] + maybe_skip: Option, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + let props = schema.properties.as_ref().unwrap(); + assert!(props.contains_key("required_field")); + assert!(props.contains_key("with_default")); + assert!(props.contains_key("maybe_skip")); + + let required = schema.required.as_ref().unwrap(); + assert!(required.contains(&"required_field".to_string())); + // Non-nullable fields are always required, even with #[serde(default)] + assert!(required.contains(&"with_default".to_string())); + // Option fields are not required (nullable) + assert!(!required.contains(&"maybe_skip".to_string())); +} + +// Tests for struct with doc comments +#[test] +fn test_parse_struct_to_schema_with_description() { + let struct_src = r" + /// User struct description + struct User { + /// User ID + id: i32, + /// User name + name: String, + } + "; + let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + assert_eq!( + schema.description, + Some("User struct description".to_string()) + ); + // Check field descriptions + let props = schema.properties.unwrap(); + if let SchemaRef::Inline(id_schema) = props.get("id").unwrap() { + assert_eq!(id_schema.description, Some("User ID".to_string())); + } + if let SchemaRef::Inline(name_schema) = props.get("name").unwrap() { + assert_eq!(name_schema.description, Some("User name".to_string())); + } +} + +#[test] +fn test_parse_struct_to_schema_field_with_ref_and_description() { + let struct_src = r" + struct Container { + /// The user reference + user: User, + } + "; + let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap(); + let mut struct_defs = HashMap::new(); + struct_defs.insert("User".to_string(), "struct User { id: i32 }".to_string()); + let mut known = HashSet::new(); + known.insert("User".to_string()); + let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs); + let props = schema.properties.unwrap(); + // Field with $ref and description should use allOf + if let SchemaRef::Inline(user_schema) = props.get("user").unwrap() { + assert_eq!( + user_schema.description, + Some("The user reference".to_string()) + ); + assert!(user_schema.all_of.is_some()); + } +} + +#[test] +fn test_parse_struct_to_schema_description_strips_slash_prefix() { + // When doc attributes have "/ " prefix (without leading space), descriptions should be clean. + // This can happen in certain TokenStream roundtrip scenarios. + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[doc = "/ Struct description"] + struct Admin { + #[doc = "/ Field description"] + id: i32, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + assert_eq!(schema.description, Some("Struct description".to_string())); + let props = schema.properties.unwrap(); + if let SchemaRef::Inline(id_schema) = props.get("id").unwrap() { + assert_eq!(id_schema.description, Some("Field description".to_string())); + } +} + +#[test] +fn test_parse_struct_to_schema_with_flatten() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + struct UserListRequest { + filter: String, + #[serde(flatten)] + pagination: Pagination, + } + ", + ) + .unwrap(); + + let mut struct_defs = HashMap::new(); + struct_defs.insert( + "Pagination".to_string(), + "struct Pagination { page: i32 }".to_string(), + ); + let mut known = HashSet::new(); + known.insert("Pagination".to_string()); + + let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs); + + // Should have allOf + assert!( + schema.all_of.is_some(), + "Schema should have allOf for flatten" + ); + let all_of = schema.all_of.as_ref().unwrap(); + assert_eq!(all_of.len(), 2, "allOf should have 2 elements"); + + // First element should be the object with non-flattened properties + if let SchemaRef::Inline(obj_schema) = &all_of[0] { + let props = obj_schema.properties.as_ref().unwrap(); + assert!(props.contains_key("filter"), "Should have filter property"); + assert!( + !props.contains_key("pagination"), + "Should NOT have pagination property" + ); + } else { + panic!("First allOf element should be inline schema"); + } + + // Second element should be $ref to Pagination + if let SchemaRef::Ref(reference) = &all_of[1] { + assert_eq!(reference.ref_path, "#/components/schemas/Pagination"); + } else { + panic!("Second allOf element should be $ref"); + } +} + +#[test] +fn test_parse_struct_to_schema_with_multiple_flatten() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + struct Combined { + name: String, + #[serde(flatten)] + pagination: Pagination, + #[serde(flatten)] + metadata: Metadata, + } + ", + ) + .unwrap(); + + let mut struct_defs = HashMap::new(); + struct_defs.insert("Pagination".to_string(), "struct Pagination {}".to_string()); + struct_defs.insert("Metadata".to_string(), "struct Metadata {}".to_string()); + let mut known = HashSet::new(); + known.insert("Pagination".to_string()); + known.insert("Metadata".to_string()); + + let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs); + + assert!(schema.all_of.is_some()); + let all_of = schema.all_of.as_ref().unwrap(); + assert_eq!( + all_of.len(), + 3, + "allOf should have 3 elements (1 inline + 2 refs)" + ); +} + +#[test] +fn test_parse_struct_to_schema_no_flatten() { + // Existing struct without flatten should NOT use allOf + let struct_item: syn::ItemStruct = syn::parse_str( + r" + struct Simple { + name: String, + age: i32, + } + ", + ) + .unwrap(); + + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + assert!( + schema.all_of.is_none(), + "Simple struct should not have allOf" + ); + assert!(schema.properties.is_some()); +} + +#[test] +fn test_parse_struct_to_schema_transparent_tuple_wrapper_uses_ref_schema() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + #[serde(transparent)] + struct Wrapper(User); + ", + ) + .unwrap(); + + let mut struct_defs = HashMap::new(); + struct_defs.insert("User".to_string(), "struct User { id: i32 }".to_string()); + let mut known = HashSet::new(); + known.insert("User".to_string()); + + let schema = parse_struct_to_schema(&struct_item, &known, &struct_defs); + assert!(schema.all_of.is_some()); + let all_of = schema.all_of.unwrap(); + assert_eq!(all_of.len(), 1); + match &all_of[0] { + SchemaRef::Ref(reference) => { + assert_eq!(reference.ref_path, "#/components/schemas/User"); + } + SchemaRef::Inline(_) => { + panic!("expected $ref wrapper for transparent tuple known schema") + } + } +} + +#[test] +fn test_parse_struct_to_schema_transparent_multi_field_tuple_falls_back() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + #[serde(transparent)] + struct Wrapper(String, String); + ", + ) + .unwrap(); + + let schema = parse_struct_to_schema( + &struct_item, + &HashSet::::new(), + &HashMap::::new(), + ); + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + assert!(schema.properties.is_none()); + assert!(schema.all_of.is_none()); +} + +// ── field-level `#[schema(...)]` constraint propagation ───────── + +fn field_schema<'a>(schema: &'a Schema, field: &str) -> &'a Schema { + let props = schema.properties.as_ref().expect("properties missing"); + let entry = props.get(field).expect("field missing"); + match entry { + SchemaRef::Inline(boxed) => boxed.as_ref(), + SchemaRef::Ref(_) => panic!("expected inline schema for field '{field}'"), + } +} + +#[test] +fn schema_constraints_min_max_length_and_pattern_on_string_field() { + let s: syn::ItemStruct = syn::parse_str( + r#" + struct CreateUser { + #[schema(min_length = 3, max_length = 32, pattern = "^[a-z]+$")] + username: String, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema( + &s, + &HashSet::::new(), + &HashMap::::new(), + ); + let field = field_schema(&schema, "username"); + assert_eq!(field.min_length, Some(3)); + assert_eq!(field.max_length, Some(32)); + assert_eq!(field.pattern.as_deref(), Some("^[a-z]+$")); +} + +#[test] +fn schema_constraints_minimum_maximum_on_numeric_field() { + let s: syn::ItemStruct = syn::parse_str( + r" + struct Profile { + #[schema(minimum = 0, maximum = 150)] + age: u32, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema( + &s, + &HashSet::::new(), + &HashMap::::new(), + ); + let field = field_schema(&schema, "age"); + assert_eq!(field.minimum, Some(0.0)); + assert_eq!(field.maximum, Some(150.0)); +} + +#[test] +fn schema_constraints_format_email_on_string_field() { + let s: syn::ItemStruct = syn::parse_str( + r#" + struct Contact { + #[schema(format = "email")] + email: String, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema( + &s, + &HashSet::::new(), + &HashMap::::new(), + ); + let field = field_schema(&schema, "email"); + assert_eq!(field.format.as_deref(), Some("email")); +} + +#[test] +fn schema_constraints_read_only_write_only_example() { + let s: syn::ItemStruct = syn::parse_str( + r#" + struct User { + #[schema(read_only, example = "abc-123")] + id: String, + #[schema(write_only)] + password: String, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema( + &s, + &HashSet::::new(), + &HashMap::::new(), + ); + let id_field = field_schema(&schema, "id"); + assert_eq!(id_field.read_only, Some(true)); + assert_eq!(id_field.example, Some(serde_json::json!("abc-123"))); + let pw_field = field_schema(&schema, "password"); + assert_eq!(pw_field.write_only, Some(true)); +} + +#[test] +fn schema_constraints_min_max_items_unique_on_vec_field() { + let s: syn::ItemStruct = syn::parse_str( + r" + struct Post { + #[schema(min_items = 1, max_items = 5, unique_items)] + tags: Vec, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema( + &s, + &HashSet::::new(), + &HashMap::::new(), + ); + let field = field_schema(&schema, "tags"); + assert_eq!(field.min_items, Some(1)); + assert_eq!(field.max_items, Some(5)); + assert_eq!(field.unique_items, Some(true)); +} + +#[test] +fn schema_constraints_exclusive_bounds_and_multiple_of() { + let s: syn::ItemStruct = syn::parse_str( + r" + struct Price { + #[schema(minimum = 0, exclusive_minimum, multiple_of = 0.01)] + amount: f64, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema( + &s, + &HashSet::::new(), + &HashMap::::new(), + ); + let field = field_schema(&schema, "amount"); + assert_eq!(field.minimum, Some(0.0)); + assert_eq!(field.exclusive_minimum, Some(0.0)); + assert_eq!(field.multiple_of, Some(0.01)); +} + +#[test] +fn schema_constraints_on_ref_field_promote_to_allof_wrapper() { + // A field referencing a known component schema must keep its + // `$ref` but gain the constraints via an `allOf` wrapper so the + // OpenAPI consumer still sees the reference. + let mut known = HashSet::new(); + known.insert("Address".to_string()); + let s: syn::ItemStruct = syn::parse_str( + r" + struct Order { + #[schema(read_only)] + shipping: Address, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &known, &HashMap::::new()); + let field = field_schema(&schema, "shipping"); + assert_eq!(field.read_only, Some(true)); + let all_of = field.all_of.as_ref().expect("allOf wrap missing"); + assert_eq!(all_of.len(), 1); + assert!(matches!(all_of[0], SchemaRef::Ref(_))); +} + +#[test] +fn schema_constraints_coexist_with_doc_comment_on_ref_field() { + // When BOTH a doc comment AND constraints are present on a + // `$ref` field, the doc comment converts it to allOf first, then + // constraints are layered onto the same wrapper. + let mut known = HashSet::new(); + known.insert("Address".to_string()); + let s: syn::ItemStruct = syn::parse_str( + r" + struct Order { + /// Shipping address — must be present. + #[schema(read_only, write_only = false)] + shipping: Address, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema(&s, &known, &HashMap::::new()); + let field = field_schema(&schema, "shipping"); + assert!(field.description.is_some(), "doc comment lost"); + assert_eq!(field.read_only, Some(true)); + assert_eq!(field.write_only, Some(false)); + assert!(field.all_of.is_some(), "allOf wrap lost"); +} + +#[test] +fn schema_constraints_unknown_keys_on_field_are_silently_ignored() { + // Struct-level keys (e.g. `name`) accidentally placed on a field + // attribute should not trip the parser nor produce constraints. + let s: syn::ItemStruct = syn::parse_str( + r#" + struct Account { + #[schema(name = "Stray", min_length = 4)] + pin: String, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema( + &s, + &HashSet::::new(), + &HashMap::::new(), + ); + let field = field_schema(&schema, "pin"); + assert_eq!(field.min_length, Some(4)); +} + +#[test] +fn schema_exclusive_maximum_and_minimum_land_on_emitted_field_schema() { + // `exclusive_minimum` / `exclusive_maximum` / `multiple_of` / + // `unique_items` are OpenAPI-only annotations (no garde rule + // counterpart). The struct-schema parser still propagates them + // onto the per-field `Schema` so the resulting `openapi.json` + // carries them verbatim. + let s: syn::ItemStruct = syn::parse_str( + r" + struct Price { + #[schema(minimum = 0, maximum = 100, exclusive_minimum, exclusive_maximum, multiple_of = 0.5)] + amount: f64, + + #[schema(min_items = 1, max_items = 5, unique_items)] + tags: Vec, + } + ", + ) + .unwrap(); + let schema = parse_struct_to_schema( + &s, + &HashSet::::new(), + &HashMap::::new(), + ); + let amount = field_schema(&schema, "amount"); + assert_eq!(amount.exclusive_minimum, Some(0.0)); + assert_eq!(amount.exclusive_maximum, Some(100.0)); + assert_eq!(amount.multiple_of, Some(0.5)); + let tags = field_schema(&schema, "tags"); + assert_eq!(tags.unique_items, Some(true)); +} diff --git a/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index 707e7c38..ed028a46 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -1,424 +1,32 @@ -//! Type to JSON Schema conversion for `OpenAPI` generation. -//! -//! This module handles the conversion of Rust types (as parsed by syn) -//! into OpenAPI-compatible JSON Schema references and inline schemas. - -use std::{ - cell::Cell, - collections::{HashMap, HashSet}, -}; - -use syn::Type; -use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType}; - -/// Maximum recursion depth for type-to-schema conversion. -/// Prevents stack overflow from deeply nested or circular type references. -const MAX_SCHEMA_RECURSION_DEPTH: usize = 32; +//! Type to JSON Schema conversion for OpenAPI generation. -thread_local! { - static SCHEMA_RECURSION_DEPTH: Cell = const { Cell::new(0) }; -} +mod conversion; -use super::{ - generics::substitute_type, - serde_attrs::{capitalize_first, extract_schema_name_from_entity, extract_schema_ref_override}, - struct_schema::parse_struct_to_schema, +pub use conversion::{ + is_primitive_type, parse_type_to_schema_ref, parse_type_to_schema_ref_with_schemas, }; -/// Check if a type is a primitive Rust type that maps directly to a JSON Schema type. -/// Inline integer schema with an OpenAPI format string. -fn integer_with_format(format: &str) -> SchemaRef { - SchemaRef::Inline(Box::new(Schema { - format: Some(format.to_string()), - ..Schema::integer() - })) -} - -/// Inline number schema with an OpenAPI format string. -fn number_with_format(format: &str) -> SchemaRef { - SchemaRef::Inline(Box::new(Schema { - format: Some(format.to_string()), - ..Schema::number() - })) -} - -/// Inline string schema with an OpenAPI format string. -fn string_with_format(format: &str) -> SchemaRef { - SchemaRef::Inline(Box::new(Schema { - format: Some(format.to_string()), - ..Schema::string() - })) -} - -pub fn is_primitive_type(ty: &Type) -> bool { - match ty { - Type::Path(type_path) => { - let path = &type_path.path; - if path.segments.len() == 1 { - let ident = path.segments[0].ident.to_string(); - ident == "str" - || crate::schema_macro::type_utils::PRIMITIVE_TYPE_NAMES - .contains(&ident.as_str()) - } else { - false - } - } - _ => false, - } -} - -/// Converts a Rust type to an `OpenAPI` `SchemaRef`. -/// -/// This is the main entry point for type-to-schema conversion. -pub fn parse_type_to_schema_ref( - ty: &Type, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> SchemaRef { - parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions) -} - -/// Type-to-schema conversion with depth-guarded recursion. -/// -/// Handles: -/// - Primitive types (i32, String, bool, etc.) -/// - Generic wrappers (Vec, Option, Box) -/// - `SeaORM` relations (`HasOne`, `HasMany`) -/// - Map types (`HashMap`, `BTreeMap`) -/// - Date/time types (`DateTime`, `NaiveDate`, etc.) -/// - Known schema references -/// - Generic type instantiation -pub fn parse_type_to_schema_ref_with_schemas( - ty: &Type, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> SchemaRef { - SCHEMA_RECURSION_DEPTH.with(|depth| { - let current = depth.get(); - if current >= MAX_SCHEMA_RECURSION_DEPTH { - return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); - } - depth.set(current + 1); - let result = parse_type_impl(ty, known_schemas, struct_definitions); - depth.set(current); - result - }) -} - -/// Core type-to-schema logic (called within depth guard). -#[allow(clippy::too_many_lines)] -fn parse_type_impl( - ty: &Type, - known_schemas: &HashSet, - struct_definitions: &HashMap, -) -> SchemaRef { - match ty { - Type::Path(type_path) => { - let path = &type_path.path; - if path.segments.is_empty() { - return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); - } - - // Get the last segment as the type name (handles paths like crate::TestStruct) - let segment = path.segments.last().unwrap(); - let ident_str = segment.ident.to_string(); - - // Handle generic types - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - match ident_str.as_str() { - // Box -> T's schema (Box is just heap allocation, transparent for schema) - "Box" => { - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - return parse_type_to_schema_ref( - inner_ty, - known_schemas, - struct_definitions, - ); - } - } - "Vec" | "HashSet" | "BTreeSet" | "Option" => { - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - let inner_schema = parse_type_to_schema_ref( - inner_ty, - known_schemas, - struct_definitions, - ); - if ident_str == "Vec" { - return SchemaRef::Inline(Box::new(Schema::array(inner_schema))); - } - if ident_str == "HashSet" || ident_str == "BTreeSet" { - let mut schema = Schema::array(inner_schema); - schema.unique_items = Some(true); - return SchemaRef::Inline(Box::new(schema)); - } - // Option -> nullable schema - match inner_schema { - SchemaRef::Inline(mut schema) => { - schema.nullable = Some(true); - return SchemaRef::Inline(schema); - } - SchemaRef::Ref(reference) => { - // Wrap reference in an inline schema to attach nullable flag - return SchemaRef::Inline(Box::new(Schema { - ref_path: Some(reference.ref_path), - schema_type: None, - nullable: Some(true), - ..Schema::new(SchemaType::Object) - })); - } - } - } - } - // SeaORM relation types: convert Entity to Schema reference - "HasOne" => { - // HasOne -> nullable reference to corresponding Schema - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - && let Some(schema_name) = extract_schema_name_from_entity(inner_ty) - { - return SchemaRef::Inline(Box::new(Schema { - ref_path: Some(format!("#/components/schemas/{schema_name}")), - schema_type: None, - nullable: Some(true), - ..Schema::new(SchemaType::Object) - })); - } - // Fallback: generic object - return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); - } - "HasMany" => { - // HasMany -> array of references to corresponding Schema - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - && let Some(schema_name) = extract_schema_name_from_entity(inner_ty) - { - let inner_ref = SchemaRef::Ref(Reference::new(format!( - "#/components/schemas/{schema_name}" - ))); - return SchemaRef::Inline(Box::new(Schema::array(inner_ref))); - } - // Fallback: array of generic objects - return SchemaRef::Inline(Box::new(Schema::array(SchemaRef::Inline( - Box::new(Schema::new(SchemaType::Object)), - )))); - } - "HashMap" | "BTreeMap" => { - // HashMap or BTreeMap -> object with additionalProperties - // K is typically String, we use V as the value type - if args.args.len() >= 2 - && let ( - Some(syn::GenericArgument::Type(_key_ty)), - Some(syn::GenericArgument::Type(value_ty)), - ) = (args.args.get(0), args.args.get(1)) - { - let value_schema = parse_type_to_schema_ref( - value_ty, - known_schemas, - struct_definitions, - ); - // Convert SchemaRef to serde_json::Value for additional_properties - let additional_props_value = match value_schema { - SchemaRef::Ref(ref_ref) => { - serde_json::json!({ "$ref": ref_ref.ref_path }) - } - SchemaRef::Inline(schema) => serde_json::to_value(&*schema) - .unwrap_or_else(|_| serde_json::json!({})), - }; - return SchemaRef::Inline(Box::new(Schema { - schema_type: Some(SchemaType::Object), - additional_properties: Some(additional_props_value), - ..Schema::object() - })); - } - } - _ => {} - } - } - - // Handle primitive types - // For standard OpenAPI format types (i32, i64, f32, f64), use `format` - // per the OAS 3.1 Data Type Format spec. For non-standard types, fall - // back to `minimum`/`maximum` constraints. - match ident_str.as_str() { - // Signed integers: use OpenAPI format registry - // https://spec.openapis.org/registry/format/index.html - "i8" => integer_with_format("int8"), - "i16" => integer_with_format("int16"), - "i32" => integer_with_format("int32"), - "i64" => integer_with_format("int64"), - // Unsigned integers: use OpenAPI format registry - "u8" => integer_with_format("uint8"), - "u16" => integer_with_format("uint16"), - "u32" => integer_with_format("uint32"), - "u64" => integer_with_format("uint64"), - // i128, isize, StatusCode: no standard format in the registry - "i128" | "isize" | "StatusCode" => SchemaRef::Inline(Box::new(Schema::integer())), - // u128, usize: unsigned with no standard format — use minimum: 0 - "u128" | "usize" => SchemaRef::Inline(Box::new(Schema { - minimum: Some(0.0), - ..Schema::integer() - })), - "f32" => number_with_format("float"), - "f64" => number_with_format("double"), - "Decimal" => number_with_format("decimal"), - "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), - "char" => string_with_format("char"), - "Uuid" => string_with_format("uuid"), - "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), - // Date-time types from chrono and time crates - "DateTime" - | "NaiveDateTime" - | "DateTimeWithTimeZone" - | "DateTimeUtc" - | "DateTimeLocal" - | "OffsetDateTime" - | "PrimitiveDateTime" => string_with_format("date-time"), - "NaiveDate" | "Date" => string_with_format("date"), - "NaiveTime" | "Time" => string_with_format("time"), - // Duration types - "Duration" => string_with_format("duration"), - // File upload types (vespera::multipart / tempfile) - // FieldData → string with binary format - "FieldData" | "NamedTempFile" => string_with_format("binary"), - // Standard library types that should not be referenced - // Note: HashMap and BTreeMap are handled above in generic types - "Vec" | "HashSet" | "BTreeSet" | "Option" | "Result" | "Json" | "Path" - | "Query" | "Header" => { - // These are not schema types, return object schema - SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) - } - _ => { - // Check if this is a known schema (struct with Schema derive) - // Use just the type name (handles both crate::TestStruct and TestStruct) - let type_name = ident_str.clone(); - - // For paths like `module::Schema`, try to find the schema name - // by checking if there's a schema named `ModuleSchema` or `ModuleNameSchema` - let resolved_name = if type_name == "Schema" && path.segments.len() > 1 { - // Get the parent module name (e.g., "user" from "crate::models::user::Schema") - let parent_segment = &path.segments[path.segments.len() - 2]; - let parent_name = parent_segment.ident.to_string(); - - // Try PascalCase version: "user" -> "UserSchema" - // Rust identifiers are guaranteed non-empty - let pascal_name = format!("{}Schema", capitalize_first(&parent_name)); - - if known_schemas.contains(&pascal_name) { - pascal_name - } else { - // Try lowercase version: "userSchema" - let lower_name = format!("{parent_name}Schema"); - if known_schemas.contains(&lower_name) { - lower_name - } else { - type_name - } - } - } else { - type_name - }; - - if known_schemas.contains(&resolved_name) { - if let Some(def) = struct_definitions.get(&resolved_name) - && let Ok(parsed_struct) = syn::parse_str::(def) - && let Some((schema_name, nullable)) = - extract_schema_ref_override(&parsed_struct.attrs) - { - return SchemaRef::Inline(Box::new(Schema { - ref_path: Some(format!("#/components/schemas/{schema_name}")), - schema_type: None, - nullable: nullable.then_some(true), - ..Schema::new(SchemaType::Object) - })); - } - - // Check if this is a generic type with type parameters - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - // This is a concrete generic type like GenericStruct - // Inline the schema by substituting generic parameters with concrete types - if let Some(base_def) = struct_definitions.get(&resolved_name) - && let Ok(mut parsed) = syn::parse_str::(base_def) - { - // Extract generic parameter names from the struct definition - let generic_params: Vec = parsed - .generics - .params - .iter() - .filter_map(|param| { - if let syn::GenericParam::Type(type_param) = param { - Some(type_param.ident.to_string()) - } else { - None - } - }) - .collect(); - - // Extract concrete type arguments - let concrete_types: Vec<&Type> = args - .args - .iter() - .filter_map(|arg| { - if let syn::GenericArgument::Type(ty) = arg { - Some(ty) - } else { - None - } - }) - .collect(); - - // Substitute generic parameters with concrete types in all fields - if generic_params.len() == concrete_types.len() { - if let syn::Fields::Named(fields_named) = &mut parsed.fields { - for field in &mut fields_named.named { - field.ty = substitute_type( - &field.ty, - &generic_params, - &concrete_types, - ); - } - } - - // Remove generics from the struct (it's now concrete) - parsed.generics.params.clear(); - parsed.generics.where_clause = None; - - // Parse the substituted struct to schema (inline) - let schema = parse_struct_to_schema( - &parsed, - known_schemas, - struct_definitions, - ); - return SchemaRef::Inline(Box::new(schema)); - } - } - } - // Non-generic type or generic without parameters - use reference - SchemaRef::Ref(Reference::schema(&resolved_name)) - } else { - // For unknown custom types, return object schema instead of reference - // This prevents creating invalid references to non-existent schemas - SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) - } - } - } - } - Type::Reference(type_ref) => { - // Handle &T, &mut T, etc. — goes through depth guard via public entry point - parse_type_to_schema_ref(&type_ref.elem, known_schemas, struct_definitions) - } - // () unit type → null (e.g. Json<()> serializes to JSON null) - Type::Tuple(tuple) if tuple.elems.is_empty() => { - SchemaRef::Inline(Box::new(Schema::new(SchemaType::Null))) - } - _ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), - } -} - #[cfg(test)] mod tests { + use std::collections::{HashMap, HashSet}; + use rstest::rstest; + use syn::Type; + use vespera_core::schema::AdditionalProperties; + use vespera_core::schema::SchemaRef; use vespera_core::schema::SchemaType; + use super::conversion::{MAX_SCHEMA_RECURSION_DEPTH, SCHEMA_RECURSION_DEPTH}; use super::*; + fn empty_known() -> HashSet { + HashSet::new() + } + + fn empty_struct_definitions() -> HashMap { + HashMap::new() + } + #[rstest] #[case("HashMap", Some(SchemaType::Object), true)] #[case("Option", Some(SchemaType::String), false)] // nullable check @@ -428,7 +36,7 @@ mod tests { #[case] expect_additional_props: bool, ) { let ty: syn::Type = syn::parse_str(ty_src).unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, expected_type); if expect_additional_props { @@ -448,7 +56,7 @@ mod tests { known.insert("User".to_string()); let ty: syn::Type = syn::parse_str("Option").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &empty_struct_definitions()); match schema_ref { SchemaRef::Inline(schema) => { @@ -473,12 +81,12 @@ mod tests { segments: syn::punctuated::Punctuated::new(), }, }); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); assert!(matches!(schema_ref, SchemaRef::Inline(_))); // Reference type delegates to inner let ty: Type = syn::parse_str("&i32").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::Integer)); } else { @@ -492,11 +100,11 @@ mod tests { known_schemas.insert("Known".to_string()); let ty: Type = syn::parse_str("Known").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &empty_struct_definitions()); assert!(matches!(schema_ref, SchemaRef::Ref(_))); let ty: Type = syn::parse_str("UnknownType").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &empty_struct_definitions()); assert!(matches!(schema_ref, SchemaRef::Inline(_))); } @@ -556,7 +164,7 @@ mod tests { known_schemas.insert("Value".to_string()); let ty: Type = syn::parse_str(ty_src).unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &empty_struct_definitions()); match expected_ref { Some(expected) => { let SchemaRef::Inline(schema) = schema_ref else { @@ -566,7 +174,10 @@ mod tests { .additional_properties .as_ref() .expect("additional_properties missing"); - assert_eq!(additional.get("$ref").unwrap(), expected); + let AdditionalProperties::Schema(SchemaRef::Ref(reference)) = additional else { + panic!("expected a schema-ref additionalProperties for {ty_src}"); + }; + assert_eq!(reference.ref_path, expected); } None => match schema_ref { SchemaRef::Inline(schema) => { @@ -587,7 +198,7 @@ mod tests { #[test] fn test_parse_type_to_schema_ref_vec_without_args() { let ty: Type = syn::parse_str("Vec").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); // Vec without angle brackets should return object schema assert!(matches!(schema_ref, SchemaRef::Inline(_))); } @@ -597,7 +208,7 @@ mod tests { fn test_parse_type_to_schema_ref_unknown_custom_type() { // MyUnknownType is not in known_schemas, should return inline object schema let ty: Type = syn::parse_str("MyUnknownType").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::Object)); } else { @@ -610,7 +221,7 @@ mod tests { fn test_parse_type_to_schema_ref_qualified_unknown_type() { // crate::models::UnknownStruct is not in known_schemas let ty: Type = syn::parse_str("crate::models::UnknownStruct").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::Object)); } else { @@ -622,7 +233,7 @@ mod tests { #[test] fn test_parse_type_to_schema_ref_btreemap() { let ty: Type = syn::parse_str("BTreeMap").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::Object)); assert!(schema.additional_properties.is_some()); @@ -635,7 +246,7 @@ mod tests { #[test] fn test_parse_type_to_schema_ref_box_type() { let ty: Type = syn::parse_str("Box").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); // Box should be transparent - returns T's schema match schema_ref { SchemaRef::Inline(schema) => { @@ -650,7 +261,7 @@ mod tests { let mut known = HashSet::new(); known.insert("User".to_string()); let ty: Type = syn::parse_str("Box").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &empty_struct_definitions()); // Box should return User's schema ref match schema_ref { SchemaRef::Ref(reference) => { @@ -665,7 +276,7 @@ mod tests { fn test_parse_type_to_schema_ref_has_one_entity() { // HasOne should produce nullable ref to UserSchema let ty: Type = syn::parse_str("HasOne").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); match schema_ref { SchemaRef::Inline(schema) => { // Should have ref_path to UserSchema and be nullable @@ -683,7 +294,7 @@ mod tests { fn test_parse_type_to_schema_ref_has_one_fallback() { // HasOne should fallback to generic object (no Entity) let ty: Type = syn::parse_str("HasOne").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); match schema_ref { SchemaRef::Inline(schema) => { // Fallback: generic object @@ -698,7 +309,7 @@ mod tests { fn test_parse_type_to_schema_ref_has_one_non_entity_path() { // HasOne - path doesn't end with Entity let ty: Type = syn::parse_str("HasOne").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); match schema_ref { SchemaRef::Inline(schema) => { // Fallback: generic object since not "Entity" @@ -713,13 +324,13 @@ mod tests { fn test_parse_type_to_schema_ref_has_many_entity() { // HasMany should produce array of refs let ty: Type = syn::parse_str("HasMany").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); match schema_ref { SchemaRef::Inline(schema) => { // Should be array type assert_eq!(schema.schema_type, Some(SchemaType::Array)); // Items should be ref to CommentSchema - if let Some(SchemaRef::Ref(items_ref)) = schema.items.as_deref() { + if let Some(SchemaRef::Ref(items_ref)) = schema.items.as_ref() { assert_eq!(items_ref.ref_path, "#/components/schemas/Comment"); } else { panic!("Expected items to be a $ref"); @@ -733,12 +344,12 @@ mod tests { fn test_parse_type_to_schema_ref_has_many_fallback() { // HasMany should fallback to array of objects let ty: Type = syn::parse_str("HasMany").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); match schema_ref { SchemaRef::Inline(schema) => { assert_eq!(schema.schema_type, Some(SchemaType::Array)); // Items should be inline object - if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { + if let Some(SchemaRef::Inline(items)) = schema.items.as_ref() { assert_eq!(items.schema_type, Some(SchemaType::Object)); } else { panic!("Expected inline items for HasMany fallback"); @@ -755,7 +366,7 @@ mod tests { let mut known = HashSet::new(); known.insert("UserSchema".to_string()); let ty: Type = syn::parse_str("crate::models::user::Schema").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &empty_struct_definitions()); match schema_ref { SchemaRef::Ref(reference) => { assert_eq!(reference.ref_path, "#/components/schemas/UserSchema"); @@ -770,7 +381,7 @@ mod tests { let mut known = HashSet::new(); known.insert("userSchema".to_string()); let ty: Type = syn::parse_str("crate::models::user::Schema").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &empty_struct_definitions()); match schema_ref { SchemaRef::Ref(reference) => { assert_eq!(reference.ref_path, "#/components/schemas/userSchema"); @@ -783,7 +394,7 @@ mod tests { fn test_parse_type_to_schema_ref_module_schema_path_fallback() { // crate::models::user::Schema with no known schemas should use Schema as-is let ty: Type = syn::parse_str("crate::models::user::Schema").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); // Falls through to unknown type handling match schema_ref { SchemaRef::Inline(schema) => { @@ -804,7 +415,7 @@ mod tests { let mut known = HashSet::new(); known.insert("Schema".to_string()); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &empty_struct_definitions()); match schema_ref { SchemaRef::Ref(reference) => { assert_eq!(reference.ref_path, "#/components/schemas/Schema"); @@ -827,7 +438,7 @@ mod tests { #[case] expected_format: &str, ) { let ty: Type = syn::parse_str(ty_name).unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!( @@ -856,7 +467,7 @@ mod tests { #[case] expected_format: &str, ) { let ty: Type = syn::parse_str(ty_name).unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!( @@ -878,7 +489,7 @@ mod tests { #[test] fn test_parse_type_to_schema_ref_duration() { let ty: Type = syn::parse_str("Duration").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::String)); @@ -900,7 +511,8 @@ mod tests { for (ty_str, expected_format) in qualified_types { let ty: Type = syn::parse_str(ty_str).unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = + parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!( @@ -929,7 +541,7 @@ mod tests { #[case] expected_format: &str, ) { let ty: Type = syn::parse_str(ty_str).unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::String)); @@ -944,11 +556,11 @@ mod tests { #[test] fn test_parse_type_to_schema_ref_vec_date_time_types() { let ty: Type = syn::parse_str("Vec").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::Array)); - if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { + if let Some(SchemaRef::Inline(items)) = schema.items.as_ref() { assert_eq!(items.schema_type, Some(SchemaType::String)); assert_eq!(items.format, Some("date-time".to_string())); } else { @@ -963,7 +575,7 @@ mod tests { #[test] fn test_parse_type_to_schema_ref_box_date_time_types() { let ty: Type = syn::parse_str("Box").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::String)); @@ -1092,7 +704,7 @@ mod tests { #[test] fn test_parse_type_field_data_binary_format() { let ty: Type = syn::parse_str("FieldData").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::String)); assert_eq!(schema.format, Some("binary".to_string())); @@ -1104,7 +716,7 @@ mod tests { #[test] fn test_parse_type_named_temp_file_binary_format() { let ty: Type = syn::parse_str("NamedTempFile").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::String)); assert_eq!(schema.format, Some("binary".to_string())); @@ -1118,7 +730,7 @@ mod tests { #[test] fn test_parse_type_status_code_integer() { let ty: Type = syn::parse_str("StatusCode").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::Integer)); } else { @@ -1130,7 +742,7 @@ mod tests { fn test_parse_type_qualified_status_code_integer() { // axum::http::StatusCode should also map to integer (last segment matching) let ty: Type = syn::parse_str("axum::http::StatusCode").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::Integer)); } else { @@ -1149,7 +761,7 @@ mod tests { #[case("Header")] fn test_parse_type_non_generic_wrappers_return_object(#[case] ty_src: &str) { let ty: Type = syn::parse_str(ty_src).unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); if let SchemaRef::Inline(schema) = schema_ref { assert_eq!( schema.schema_type, @@ -1169,8 +781,11 @@ mod tests { let previous = depth.get(); depth.set(MAX_SCHEMA_RECURSION_DEPTH); let ty: Type = syn::parse_str("String").unwrap(); - let schema_ref = - parse_type_to_schema_ref_with_schemas(&ty, &HashSet::new(), &HashMap::new()); + let schema_ref = parse_type_to_schema_ref_with_schemas( + &ty, + &empty_known(), + &empty_struct_definitions(), + ); // Should return object fallback, NOT string if let SchemaRef::Inline(schema) = &schema_ref { assert_eq!(schema.schema_type, Some(SchemaType::Object)); @@ -1188,318 +803,10 @@ mod tests { assert_eq!(depth.get(), 0, "Depth should start at 0"); }); let ty: Type = syn::parse_str("Vec>").unwrap(); - let _ = parse_type_to_schema_ref_with_schemas(&ty, &HashSet::new(), &HashMap::new()); + let _ = + parse_type_to_schema_ref_with_schemas(&ty, &empty_known(), &empty_struct_definitions()); SCHEMA_RECURSION_DEPTH.with(|depth| { assert_eq!(depth.get(), 0, "Depth should reset to 0 after call"); }); } - - // ========== Coverage: generic known schema edge cases ========== - - #[test] - fn test_generic_known_schema_no_struct_definition() { - // Known schema with angle brackets but NO struct_definitions entry → falls through to Ref - let mut known = HashSet::new(); - known.insert("Wrapper".to_string()); - // Do NOT insert into struct_definitions - let ty: Type = syn::parse_str("Wrapper").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); - // Should fall through to non-generic ref path - assert!( - matches!(schema_ref, SchemaRef::Ref(_)), - "Should be a $ref when no struct definition found" - ); - } - - #[test] - fn test_generic_known_schema_param_count_mismatch() { - // Struct has 1 generic param but 2 concrete types provided → falls through to Ref - let mut known = HashSet::new(); - known.insert("Single".to_string()); - let mut defs = HashMap::new(); - defs.insert( - "Single".to_string(), - "struct Single { value: T }".to_string(), - ); - - let ty: Type = syn::parse_str("Single").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); - assert!( - matches!(schema_ref, SchemaRef::Ref(_)), - "Mismatched param count should fall through to $ref" - ); - } - - #[test] - fn test_generic_known_schema_invalid_definition() { - // struct_definitions has invalid Rust code → parse fails → falls through to Ref - let mut known = HashSet::new(); - known.insert("Bad".to_string()); - let mut defs = HashMap::new(); - defs.insert("Bad".to_string(), "not valid rust code!!!".to_string()); - - let ty: Type = syn::parse_str("Bad").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); - assert!( - matches!(schema_ref, SchemaRef::Ref(_)), - "Invalid definition should fall through to $ref" - ); - } - - #[test] - fn test_generic_known_schema_tuple_struct() { - // Tuple struct fields are NOT Named → skips field substitution but still inlines - let mut known = HashSet::new(); - known.insert("Pair".to_string()); - let mut defs = HashMap::new(); - defs.insert("Pair".to_string(), "struct Pair(T, T);".to_string()); - - let ty: Type = syn::parse_str("Pair").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); - // Tuple struct still gets inlined (generics cleared, parse_struct_to_schema called) - // but field types are NOT substituted (no Named fields to iterate) - assert!( - matches!(schema_ref, SchemaRef::Inline(_)), - "Tuple struct should still inline" - ); - } - - #[test] - fn test_generic_known_schema_no_generic_params_in_def() { - // Struct definition has no generics but concrete type has angle brackets → mismatch - let mut known = HashSet::new(); - known.insert("Plain".to_string()); - let mut defs = HashMap::new(); - defs.insert("Plain".to_string(), "struct Plain { x: i32 }".to_string()); - - let ty: Type = syn::parse_str("Plain").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); - // 0 generic params != 1 concrete type → falls through to Ref - assert!(matches!(schema_ref, SchemaRef::Ref(_))); - } - - // ========== Coverage: nested generic types ========== - - #[test] - fn test_nested_vec_vec_string() { - let ty: Type = syn::parse_str("Vec>").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - if let Some(SchemaRef::Inline(inner)) = schema.items.as_deref() { - assert_eq!(inner.schema_type, Some(SchemaType::Array)); - if let Some(SchemaRef::Inline(innermost)) = inner.items.as_deref() { - assert_eq!(innermost.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected innermost inline schema"); - } - } else { - panic!("Expected inner inline schema"); - } - } else { - panic!("Expected inline schema for nested Vec"); - } - } - - #[test] - fn test_option_vec_i32() { - let ty: Type = syn::parse_str("Option>").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert_eq!(schema.nullable, Some(true)); - if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { - assert_eq!(items.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline items"); - } - } else { - panic!("Expected inline schema for Option>"); - } - } - - #[test] - fn test_box_box_i32() { - // Box> → transparent twice → integer - let ty: Type = syn::parse_str("Box>").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline integer schema for Box>"); - } - } - - // ========== Coverage: HashMap/BTreeMap with known ref value ========== - - #[test] - fn test_hashmap_with_known_ref_value() { - let mut known = HashSet::new(); - known.insert("User".to_string()); - let ty: Type = syn::parse_str("HashMap").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Object)); - let additional = schema.additional_properties.as_ref().unwrap(); - assert_eq!(additional.get("$ref").unwrap(), "#/components/schemas/User"); - } else { - panic!("Expected inline schema for HashMap"); - } - } - - #[test] - fn test_btreemap_with_inline_value() { - let ty: Type = syn::parse_str("BTreeMap>").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Object)); - let additional = schema.additional_properties.as_ref().unwrap(); - // Value should be an array schema serialized - assert_eq!(additional.get("type").unwrap(), "array"); - } else { - panic!("Expected inline schema for BTreeMap with Vec value"); - } - } - - // ========== Coverage: HashMap/BTreeMap with insufficient args ========== - - #[test] - fn test_hashmap_single_arg_falls_through() { - // HashMap — only 1 type arg, need 2 → falls through to unknown type - let ty: Type = syn::parse_str("HashMap").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Object)); - // Should NOT have additional_properties since it fell through - assert!(schema.additional_properties.is_none()); - } else { - panic!("Expected inline schema"); - } - } - - // ========== Coverage: &mut T reference ========== - - #[test] - fn test_mutable_reference_delegates_to_inner() { - let ty: Type = syn::parse_str("&mut String").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline string schema for &mut String"); - } - } - - // ========== Coverage: HashSet/BTreeSet → uniqueItems ========== - - #[test] - fn test_hashset_string_produces_unique_items_array() { - let ty: Type = syn::parse_str("HashSet").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert_eq!(schema.unique_items, Some(true)); - if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { - assert_eq!(items.schema_type, Some(SchemaType::String)); - } else { - panic!("Expected inline string items for HashSet"); - } - } else { - panic!("Expected inline schema for HashSet"); - } - } - - #[test] - fn test_btreeset_i32_produces_unique_items_array() { - let ty: Type = syn::parse_str("BTreeSet").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert_eq!(schema.unique_items, Some(true)); - if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { - assert_eq!(items.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline integer items for BTreeSet"); - } - } else { - panic!("Expected inline schema for BTreeSet"); - } - } - - #[test] - fn test_option_hashset_is_nullable_unique_array() { - let ty: Type = syn::parse_str("Option>").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert_eq!(schema.unique_items, Some(true)); - assert_eq!(schema.nullable, Some(true)); - if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { - assert_eq!(items.schema_type, Some(SchemaType::Integer)); - } else { - panic!("Expected inline integer items for Option>"); - } - } else { - panic!("Expected inline schema for Option>"); - } - } - - #[test] - fn test_vec_does_not_have_unique_items() { - let ty: Type = syn::parse_str("Vec").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - if let SchemaRef::Inline(schema) = &schema_ref { - assert_eq!(schema.schema_type, Some(SchemaType::Array)); - assert!(schema.unique_items.is_none()); - } else { - panic!("Expected inline schema for Vec"); - } - } - - #[test] - fn test_bare_hashset_without_generics() { - // HashSet without angle brackets → falls through to bare-name match - let ty: Type = syn::parse_str("HashSet").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - assert!(matches!(schema_ref, SchemaRef::Inline(_))); - } - - #[test] - fn test_bare_btreeset_without_generics() { - let ty: Type = syn::parse_str("BTreeSet").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); - assert!(matches!(schema_ref, SchemaRef::Inline(_))); - } - - #[test] - fn test_known_schema_ref_override_returns_inline_ref_schema() { - let mut known = HashSet::new(); - known.insert("UserSchema".to_string()); - - let mut defs = HashMap::new(); - defs.insert( - "UserSchema".to_string(), - r#" - #[schema(ref = "ExternalUser", nullable)] - struct UserSchema { - id: i32, - } - "# - .to_string(), - ); - - let ty: Type = syn::parse_str("UserSchema").unwrap(); - let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); - - match schema_ref { - SchemaRef::Inline(schema) => { - assert_eq!( - schema.ref_path.as_deref(), - Some("#/components/schemas/ExternalUser") - ); - assert_eq!(schema.nullable, Some(true)); - } - SchemaRef::Ref(_) => panic!("expected inline schema ref override"), - } - } } diff --git a/crates/vespera_macro/src/parser/schema/type_schema/conversion.rs b/crates/vespera_macro/src/parser/schema/type_schema/conversion.rs new file mode 100644 index 00000000..7da685a0 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/type_schema/conversion.rs @@ -0,0 +1,769 @@ +//! Type to JSON Schema conversion for `OpenAPI` generation. +//! +//! This module handles the conversion of Rust types (as parsed by syn) +//! into OpenAPI-compatible JSON Schema references and inline schemas. + +use std::{ + borrow::Borrow, + cell::Cell, + collections::{HashMap, HashSet}, + hash::Hash, + rc::Rc, +}; + +use syn::Type; +use vespera_core::schema::{AdditionalProperties, Reference, Schema, SchemaRef, SchemaType}; + +/// Maximum recursion depth for type-to-schema conversion. +/// Prevents stack overflow from deeply nested or circular type references. +pub(super) const MAX_SCHEMA_RECURSION_DEPTH: usize = 32; + +thread_local! { + pub(super) static SCHEMA_RECURSION_DEPTH: Cell = const { Cell::new(0) }; +} + +use super::super::{ + generics::substitute_type, + serde_attrs::{capitalize_first, extract_schema_name_from_entity, extract_schema_ref_override}, + struct_schema::parse_struct_to_schema, +}; + +/// Parse a known schema's definition into a shared `syn::ItemStruct`. +/// +/// MUST NOT memoise the parsed AST in a `thread_local` (or any storage that +/// outlives the macro invocation). A `syn::ItemStruct` parsed during real +/// proc-macro expansion holds bridge-backed `proc_macro2` tokens whose `Drop` +/// calls into the proc-macro bridge; if such a value survived in TLS until the +/// proc-macro server thread exits, that drop would run with NO active expansion +/// and abort the compile ("procedural macro API is used outside of a procedural +/// macro" -> STATUS_STACK_BUFFER_OVERRUN). Every AST returned here is therefore +/// dropped within the same invocation that parsed it. +fn parse_struct_def(def: &str) -> Option> { + syn::parse_str::(def).ok().map(Rc::new) +} + +/// Check if a type is a primitive Rust type that maps directly to a JSON Schema type. +/// Inline integer schema with an OpenAPI format string. +fn integer_with_format(format: &str) -> SchemaRef { + SchemaRef::Inline(Box::new(Schema { + format: Some(format.to_string()), + ..Schema::integer() + })) +} + +/// Inline number schema with an OpenAPI format string. +fn number_with_format(format: &str) -> SchemaRef { + SchemaRef::Inline(Box::new(Schema { + format: Some(format.to_string()), + ..Schema::number() + })) +} + +/// Inline string schema with an OpenAPI format string. +fn string_with_format(format: &str) -> SchemaRef { + SchemaRef::Inline(Box::new(Schema { + format: Some(format.to_string()), + ..Schema::string() + })) +} + +pub fn is_primitive_type(ty: &Type) -> bool { + match ty { + Type::Path(type_path) => { + let path = &type_path.path; + if path.segments.len() == 1 { + let ident = path.segments[0].ident.to_string(); + ident == "str" + || crate::schema_macro::type_utils::PRIMITIVE_TYPE_NAMES + .contains(&ident.as_str()) + } else { + false + } + } + _ => false, + } +} + +/// Converts a Rust type to an `OpenAPI` `SchemaRef`. +/// +/// This is the main entry point for type-to-schema conversion. +pub fn parse_type_to_schema_ref( + ty: &Type, + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, +) -> SchemaRef { + parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions) +} + +/// Type-to-schema conversion with depth-guarded recursion. +/// +/// Handles: +/// - Primitive types (i32, String, bool, etc.) +/// - Generic wrappers (Vec, Option, Box) +/// - `SeaORM` relations (`HasOne`, `HasMany`) +/// - Map types (`HashMap`, `BTreeMap`) +/// - Date/time types (`DateTime`, `NaiveDate`, etc.) +/// - Known schema references +/// - Generic type instantiation +pub fn parse_type_to_schema_ref_with_schemas( + ty: &Type, + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, +) -> SchemaRef { + SCHEMA_RECURSION_DEPTH.with(|depth| { + let current = depth.get(); + if current >= MAX_SCHEMA_RECURSION_DEPTH { + return SchemaRef::Inline(Box::new(Schema { + description: Some(format!( + "Schema generation stopped after reaching recursion depth limit ({MAX_SCHEMA_RECURSION_DEPTH}) for `{}`", + quote::quote!(#ty) + )), + ..Schema::new(SchemaType::Object) + })); + } + depth.set(current + 1); + let result = parse_type_impl(ty, known_schemas, struct_definitions); + depth.set(current); + result + }) +} + +/// Core type-to-schema logic (called within depth guard). +#[allow(clippy::too_many_lines)] +fn parse_type_impl( + ty: &Type, + known_schemas: &HashSet + Eq + Hash>, + struct_definitions: &HashMap + Eq + Hash, impl AsRef>, +) -> SchemaRef { + match ty { + Type::Path(type_path) => { + let path = &type_path.path; + if path.segments.is_empty() { + return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); + } + + // Get the last segment as the type name (handles paths like crate::TestStruct) + let segment = path.segments.last().unwrap(); + let ident_str = segment.ident.to_string(); + + // Handle generic types + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + match ident_str.as_str() { + // Box -> T's schema (Box is just heap allocation, transparent for schema) + "Box" => { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + return parse_type_to_schema_ref( + inner_ty, + known_schemas, + struct_definitions, + ); + } + } + "Vec" | "HashSet" | "BTreeSet" | "Option" => { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + let inner_schema = parse_type_to_schema_ref( + inner_ty, + known_schemas, + struct_definitions, + ); + if ident_str == "Vec" { + return SchemaRef::Inline(Box::new(Schema::array(inner_schema))); + } + if ident_str == "HashSet" || ident_str == "BTreeSet" { + let mut schema = Schema::array(inner_schema); + schema.unique_items = Some(true); + return SchemaRef::Inline(Box::new(schema)); + } + // Option -> nullable schema + match inner_schema { + SchemaRef::Inline(mut schema) => { + schema.nullable = Some(true); + return SchemaRef::Inline(schema); + } + SchemaRef::Ref(reference) => { + // Wrap reference in an inline schema to attach nullable flag + return SchemaRef::Inline(Box::new( + Schema::nullable_reference(reference.ref_path), + )); + } + } + } + } + // SeaORM relation types: convert Entity to Schema reference + "HasOne" => { + // HasOne -> nullable reference to corresponding Schema + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Some(schema_name) = extract_schema_name_from_entity(inner_ty) + { + return SchemaRef::Inline(Box::new(Schema::nullable_reference( + format!("#/components/schemas/{schema_name}"), + ))); + } + // Fallback: generic object + return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); + } + "HasMany" => { + // HasMany -> array of references to corresponding Schema + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Some(schema_name) = extract_schema_name_from_entity(inner_ty) + { + let inner_ref = SchemaRef::Ref(Reference::new(format!( + "#/components/schemas/{schema_name}" + ))); + return SchemaRef::Inline(Box::new(Schema::array(inner_ref))); + } + // Fallback: array of generic objects + return SchemaRef::Inline(Box::new(Schema::array(SchemaRef::Inline( + Box::new(Schema::new(SchemaType::Object)), + )))); + } + "HashMap" | "BTreeMap" => { + // HashMap or BTreeMap -> object with additionalProperties + // K is typically String, we use V as the value type + if args.args.len() >= 2 + && let ( + Some(syn::GenericArgument::Type(_key_ty)), + Some(syn::GenericArgument::Type(value_ty)), + ) = (args.args.get(0), args.args.get(1)) + { + let value_schema = parse_type_to_schema_ref( + value_ty, + known_schemas, + struct_definitions, + ); + // Carry the value schema directly as a typed + // `AdditionalProperties::Schema` — no + // `SchemaRef -> serde_json::Value` round-trip + // (CORE-04). Untagged serialization is + // byte-identical to the prior JSON form. + return SchemaRef::Inline(Box::new(Schema { + schema_type: Some(SchemaType::Object), + additional_properties: Some(AdditionalProperties::Schema( + value_schema, + )), + ..Schema::object() + })); + } + } + _ => {} + } + } + + // Handle primitive types + // For standard OpenAPI format types (i32, i64, f32, f64), use `format` + // per the OAS 3.1 Data Type Format spec. For non-standard types, fall + // back to `minimum`/`maximum` constraints. + match ident_str.as_str() { + // Signed integers: use OpenAPI format registry + // https://spec.openapis.org/registry/format/index.html + "i8" => integer_with_format("int8"), + "i16" => integer_with_format("int16"), + "i32" => integer_with_format("int32"), + "i64" => integer_with_format("int64"), + // Unsigned integers: use OpenAPI format registry + "u8" => integer_with_format("uint8"), + "u16" => integer_with_format("uint16"), + "u32" => integer_with_format("uint32"), + "u64" => integer_with_format("uint64"), + // i128, isize, StatusCode: no standard format in the registry + "i128" | "isize" | "StatusCode" => SchemaRef::Inline(Box::new(Schema::integer())), + // u128, usize: unsigned with no standard format — use minimum: 0 + "u128" | "usize" => SchemaRef::Inline(Box::new(Schema { + minimum: Some(0.0), + ..Schema::integer() + })), + "f32" => number_with_format("float"), + "f64" => number_with_format("double"), + // `rust_decimal` serializes `Decimal` as a JSON *string* (to + // preserve precision), so the wire type is string, not number. + "Decimal" => string_with_format("decimal"), + "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), + "char" => string_with_format("char"), + "Uuid" => string_with_format("uuid"), + "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), + // Date-time types from chrono and time crates + "DateTime" + | "NaiveDateTime" + | "DateTimeWithTimeZone" + | "DateTimeUtc" + | "DateTimeLocal" + | "OffsetDateTime" + | "PrimitiveDateTime" => string_with_format("date-time"), + "NaiveDate" | "Date" => string_with_format("date"), + "NaiveTime" | "Time" => string_with_format("time"), + // Duration types + "Duration" => string_with_format("duration"), + // File upload types (vespera::multipart / tempfile) + // FieldData → string with binary format + "FieldData" | "NamedTempFile" => string_with_format("binary"), + // Standard library types that should not be referenced + // Note: HashMap and BTreeMap are handled above in generic types + "Vec" | "HashSet" | "BTreeSet" | "Option" | "Result" | "Json" | "Path" + | "Query" | "Header" => { + // These are not schema types, return object schema + SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) + } + _ => { + // Check if this is a known schema (struct with Schema derive) + // Use just the type name (handles both crate::TestStruct and TestStruct) + let type_name = ident_str.clone(); + + // For paths like `module::Schema`, try to find the schema name + // by checking if there's a schema named `ModuleSchema` or `ModuleNameSchema` + let resolved_name = if type_name == "Schema" && path.segments.len() > 1 { + // Get the parent module name (e.g., "user" from "crate::models::user::Schema") + let parent_segment = &path.segments[path.segments.len() - 2]; + let parent_name = parent_segment.ident.to_string(); + + // Try PascalCase version: "user" -> "UserSchema" + // Rust identifiers are guaranteed non-empty + let pascal_name = format!("{}Schema", capitalize_first(&parent_name)); + + if known_schemas.contains(pascal_name.as_str()) { + pascal_name + } else { + // Try lowercase version: "userSchema" + let lower_name = format!("{parent_name}Schema"); + if known_schemas.contains(lower_name.as_str()) { + lower_name + } else { + type_name + } + } + } else { + type_name + }; + + if known_schemas.contains(resolved_name.as_str()) { + // Parse the struct definition ONCE (when present) and reuse it for + // BOTH the `#[schema(ref=...)]` override check and the + // generic-substitution path below. `syn::parse_str::` + // tokenises + parses the whole definition string, so this single + // parse replaces the two that the override branch and the generic + // branch each used to run for a generic schema type. + let parsed_def = struct_definitions + .get(resolved_name.as_str()) + .and_then(|def| parse_struct_def(def.as_ref())); + + if let Some(parsed_struct) = &parsed_def + && let Some((schema_name, nullable)) = + extract_schema_ref_override(&parsed_struct.attrs) + { + return SchemaRef::Inline(Box::new(Schema { + ref_path: Some(format!("#/components/schemas/{schema_name}")), + schema_type: None, + nullable: nullable.then_some(true), + ..Schema::new(SchemaType::Object) + })); + } + + // Check if this is a generic type with type parameters + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + // This is a concrete generic type like GenericStruct + // Inline the schema by substituting generic parameters with concrete types + if let Some(parsed_rc) = parsed_def { + // Clone the memoised AST before mutating it for generic + // substitution (cheaper than the prior re-parse). + let mut parsed = (*parsed_rc).clone(); + // Extract generic parameter names from the struct definition + let generic_params: Vec = parsed + .generics + .params + .iter() + .filter_map(|param| { + if let syn::GenericParam::Type(type_param) = param { + Some(type_param.ident.to_string()) + } else { + None + } + }) + .collect(); + + // Extract concrete type arguments + let concrete_types: Vec<&Type> = args + .args + .iter() + .filter_map(|arg| { + if let syn::GenericArgument::Type(ty) = arg { + Some(ty) + } else { + None + } + }) + .collect(); + + // Substitute generic parameters with concrete types in all fields + if generic_params.len() == concrete_types.len() { + if let syn::Fields::Named(fields_named) = &mut parsed.fields { + for field in &mut fields_named.named { + field.ty = substitute_type( + &field.ty, + &generic_params, + &concrete_types, + ); + } + } + + // Remove generics from the struct (it's now concrete) + parsed.generics.params.clear(); + parsed.generics.where_clause = None; + + // Parse the substituted struct to schema (inline) + let schema = parse_struct_to_schema( + &parsed, + known_schemas, + struct_definitions, + ); + return SchemaRef::Inline(Box::new(schema)); + } + } + } + // Non-generic type or generic without parameters - use reference + SchemaRef::Ref(Reference::schema(&resolved_name)) + } else { + // For unknown custom types, return object schema instead of reference + // This prevents creating invalid references to non-existent schemas + SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) + } + } + } + } + Type::Reference(type_ref) => { + // Handle &T, &mut T, etc. — goes through depth guard via public entry point + parse_type_to_schema_ref(&type_ref.elem, known_schemas, struct_definitions) + } + // () unit type → null (e.g. Json<()> serializes to JSON null) + Type::Tuple(tuple) if tuple.elems.is_empty() => { + SchemaRef::Inline(Box::new(Schema::new(SchemaType::Null))) + } + _ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_known() -> HashSet { + HashSet::new() + } + + fn empty_struct_definitions() -> HashMap { + HashMap::new() + } + + // ========== Coverage: generic known schema edge cases ========== + + #[test] + fn test_generic_known_schema_no_struct_definition() { + // Known schema with angle brackets but NO struct_definitions entry → falls through to Ref + let mut known = HashSet::new(); + known.insert("Wrapper".to_string()); + // Do NOT insert into struct_definitions + let ty: Type = syn::parse_str("Wrapper").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &empty_struct_definitions()); + // Should fall through to non-generic ref path + assert!( + matches!(schema_ref, SchemaRef::Ref(_)), + "Should be a $ref when no struct definition found" + ); + } + + #[test] + fn test_generic_known_schema_param_count_mismatch() { + // Struct has 1 generic param but 2 concrete types provided → falls through to Ref + let mut known = HashSet::new(); + known.insert("Single".to_string()); + let mut defs = HashMap::new(); + defs.insert( + "Single".to_string(), + "struct Single { value: T }".to_string(), + ); + + let ty: Type = syn::parse_str("Single").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + assert!( + matches!(schema_ref, SchemaRef::Ref(_)), + "Mismatched param count should fall through to $ref" + ); + } + + #[test] + fn test_generic_known_schema_invalid_definition() { + // struct_definitions has invalid Rust code → parse fails → falls through to Ref + let mut known = HashSet::new(); + known.insert("Bad".to_string()); + let mut defs = HashMap::new(); + defs.insert("Bad".to_string(), "not valid rust code!!!".to_string()); + + let ty: Type = syn::parse_str("Bad").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + assert!( + matches!(schema_ref, SchemaRef::Ref(_)), + "Invalid definition should fall through to $ref" + ); + } + + #[test] + fn test_generic_known_schema_tuple_struct() { + // Tuple struct fields are NOT Named → skips field substitution but still inlines + let mut known = HashSet::new(); + known.insert("Pair".to_string()); + let mut defs = HashMap::new(); + defs.insert("Pair".to_string(), "struct Pair(T, T);".to_string()); + + let ty: Type = syn::parse_str("Pair").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + // Tuple struct still gets inlined (generics cleared, parse_struct_to_schema called) + // but field types are NOT substituted (no Named fields to iterate) + assert!( + matches!(schema_ref, SchemaRef::Inline(_)), + "Tuple struct should still inline" + ); + } + + #[test] + fn test_generic_known_schema_no_generic_params_in_def() { + // Struct definition has no generics but concrete type has angle brackets → mismatch + let mut known = HashSet::new(); + known.insert("Plain".to_string()); + let mut defs = HashMap::new(); + defs.insert("Plain".to_string(), "struct Plain { x: i32 }".to_string()); + + let ty: Type = syn::parse_str("Plain").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + // 0 generic params != 1 concrete type → falls through to Ref + assert!(matches!(schema_ref, SchemaRef::Ref(_))); + } + + // ========== Coverage: nested generic types ========== + + #[test] + fn test_nested_vec_vec_string() { + let ty: Type = syn::parse_str("Vec>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + if let Some(SchemaRef::Inline(inner)) = schema.items.as_ref() { + assert_eq!(inner.schema_type, Some(SchemaType::Array)); + if let Some(SchemaRef::Inline(innermost)) = inner.items.as_ref() { + assert_eq!(innermost.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected innermost inline schema"); + } + } else { + panic!("Expected inner inline schema"); + } + } else { + panic!("Expected inline schema for nested Vec"); + } + } + + #[test] + fn test_option_vec_i32() { + let ty: Type = syn::parse_str("Option>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.nullable, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_ref() { + assert_eq!(items.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline items"); + } + } else { + panic!("Expected inline schema for Option>"); + } + } + + #[test] + fn test_box_box_i32() { + // Box> → transparent twice → integer + let ty: Type = syn::parse_str("Box>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline integer schema for Box>"); + } + } + + // ========== Coverage: HashMap/BTreeMap with known ref value ========== + + #[test] + fn test_hashmap_with_known_ref_value() { + let mut known = HashSet::new(); + known.insert("User".to_string()); + let ty: Type = syn::parse_str("HashMap").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + let additional = schema.additional_properties.as_ref().unwrap(); + let AdditionalProperties::Schema(SchemaRef::Ref(reference)) = additional else { + panic!("expected a schema-ref additionalProperties, got {additional:?}"); + }; + assert_eq!(reference.ref_path, "#/components/schemas/User"); + } else { + panic!("Expected inline schema for HashMap"); + } + } + + #[test] + fn test_btreemap_with_inline_value() { + let ty: Type = syn::parse_str("BTreeMap>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + let additional = schema.additional_properties.as_ref().unwrap(); + // Value should be an inline array schema. + let AdditionalProperties::Schema(SchemaRef::Inline(value_schema)) = additional else { + panic!("expected an inline-schema additionalProperties, got {additional:?}"); + }; + assert_eq!(value_schema.schema_type, Some(SchemaType::Array)); + } else { + panic!("Expected inline schema for BTreeMap with Vec value"); + } + } + + // ========== Coverage: HashMap/BTreeMap with insufficient args ========== + + #[test] + fn test_hashmap_single_arg_falls_through() { + // HashMap — only 1 type arg, need 2 → falls through to unknown type + let ty: Type = syn::parse_str("HashMap").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + // Should NOT have additional_properties since it fell through + assert!(schema.additional_properties.is_none()); + } else { + panic!("Expected inline schema"); + } + } + + // ========== Coverage: &mut T reference ========== + + #[test] + fn test_mutable_reference_delegates_to_inner() { + let ty: Type = syn::parse_str("&mut String").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline string schema for &mut String"); + } + } + + // ========== Coverage: HashSet/BTreeSet → uniqueItems ========== + + #[test] + fn test_hashset_string_produces_unique_items_array() { + let ty: Type = syn::parse_str("HashSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.unique_items, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_ref() { + assert_eq!(items.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline string items for HashSet"); + } + } else { + panic!("Expected inline schema for HashSet"); + } + } + + #[test] + fn test_btreeset_i32_produces_unique_items_array() { + let ty: Type = syn::parse_str("BTreeSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.unique_items, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_ref() { + assert_eq!(items.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline integer items for BTreeSet"); + } + } else { + panic!("Expected inline schema for BTreeSet"); + } + } + + #[test] + fn test_option_hashset_is_nullable_unique_array() { + let ty: Type = syn::parse_str("Option>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.unique_items, Some(true)); + assert_eq!(schema.nullable, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_ref() { + assert_eq!(items.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline integer items for Option>"); + } + } else { + panic!("Expected inline schema for Option>"); + } + } + + #[test] + fn test_vec_does_not_have_unique_items() { + let ty: Type = syn::parse_str("Vec").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert!(schema.unique_items.is_none()); + } else { + panic!("Expected inline schema for Vec"); + } + } + + #[test] + fn test_bare_hashset_without_generics() { + // HashSet without angle brackets → falls through to bare-name match + let ty: Type = syn::parse_str("HashSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + assert!(matches!(schema_ref, SchemaRef::Inline(_))); + } + + #[test] + fn test_bare_btreeset_without_generics() { + let ty: Type = syn::parse_str("BTreeSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &empty_known(), &empty_struct_definitions()); + assert!(matches!(schema_ref, SchemaRef::Inline(_))); + } + + #[test] + fn test_known_schema_ref_override_returns_inline_ref_schema() { + let mut known = HashSet::new(); + known.insert("UserSchema".to_string()); + + let mut defs = HashMap::new(); + defs.insert( + "UserSchema".to_string(), + r#" + #[schema(ref = "ExternalUser", nullable)] + struct UserSchema { + id: i32, + } + "# + .to_string(), + ); + + let ty: Type = syn::parse_str("UserSchema").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &known, &defs); + + match schema_ref { + SchemaRef::Inline(schema) => { + assert_eq!( + schema.ref_path.as_deref(), + Some("#/components/schemas/ExternalUser") + ); + assert_eq!(schema.nullable, Some(true)); + } + SchemaRef::Ref(_) => panic!("expected inline schema ref override"), + } + } +} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_validated_path_single.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_validated_path_single.snap new file mode 100644 index 00000000..11a8ab1c --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_validated_path_single.snap @@ -0,0 +1,64 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[ + Parameter { + name: "item_id", + in: Path, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: Some( + "int32", + ), + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + discriminator: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_validated_query_struct.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_validated_query_struct.snap new file mode 100644 index 00000000..91bd9ddb --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__parameters__tests__parse_function_parameter_cases@params_validated_query_struct.snap @@ -0,0 +1,124 @@ +--- +source: crates/vespera_macro/src/parser/parameters.rs +expression: parameters +--- +[ + Parameter { + name: "page", + in: Query, + description: None, + required: Some( + true, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: Some( + "int32", + ), + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + discriminator: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, + Parameter { + name: "limit", + in: Query, + description: None, + required: Some( + false, + ), + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Integer, + ), + format: Some( + "int32", + ), + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + discriminator: None, + nullable: Some( + true, + ), + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + }, +] diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_validated_form.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_validated_form.snap new file mode 100644 index 00000000..e494af0d --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_validated_form.snap @@ -0,0 +1,65 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +Some( + RequestBody { + description: None, + required: Some( + true, + ), + content: { + "application/x-www-form-urlencoded": MediaType { + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + discriminator: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + examples: None, + }, + }, + }, +) diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_validated_json.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_validated_json.snap new file mode 100644 index 00000000..7662291c --- /dev/null +++ b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__request_body__tests__parse_request_body_cases@req_body_validated_json.snap @@ -0,0 +1,65 @@ +--- +source: crates/vespera_macro/src/parser/request_body.rs +expression: body +--- +Some( + RequestBody { + description: None, + required: Some( + true, + ), + content: { + "application/json": MediaType { + schema: Some( + Inline( + Schema { + ref_path: None, + schema_type: Some( + Object, + ), + format: None, + title: None, + description: None, + default: None, + example: None, + examples: None, + minimum: None, + maximum: None, + exclusive_minimum: None, + exclusive_maximum: None, + multiple_of: None, + min_length: None, + max_length: None, + pattern: None, + items: None, + prefix_items: None, + min_items: None, + max_items: None, + unique_items: None, + properties: None, + required: None, + additional_properties: None, + min_properties: None, + max_properties: None, + enum: None, + all_of: None, + any_of: None, + one_of: None, + not: None, + discriminator: None, + nullable: None, + read_only: None, + write_only: None, + external_docs: None, + defs: None, + dynamic_anchor: None, + dynamic_ref: None, + }, + ), + ), + example: None, + examples: None, + }, + }, + }, +) diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap deleted file mode 100644 index 77851e77..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_named_object.snap +++ /dev/null @@ -1,239 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: None, - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: Some( - [ - Inline( - Schema { - ref_path: None, - schema_type: Some( - Object, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: Some( - { - "Detail": Inline( - Schema { - ref_path: None, - schema_type: Some( - Object, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: Some( - { - "id": Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - "note": Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: Some( - true, - ), - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - }, - ), - required: Some( - [ - "id", - ], - ), - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - }, - ), - required: Some( - [ - "Detail", - ], - ), - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ], - ), - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap deleted file mode 100644 index 7c87eba8..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_multi.snap +++ /dev/null @@ -1,237 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: None, - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: Some( - [ - Inline( - Schema { - ref_path: None, - schema_type: Some( - Object, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: Some( - { - "Values": Inline( - Schema { - ref_path: None, - schema_type: Some( - Array, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: Some( - [ - Inline( - Schema { - ref_path: None, - schema_type: Some( - Integer, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ], - ), - min_items: Some( - 2, - ), - max_items: Some( - 2, - ), - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - }, - ), - required: Some( - [ - "Values", - ], - ), - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ], - ), - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_single.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_single.snap deleted file mode 100644 index 16038cc3..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_tuple_and_named_variants@tuple_named_tuple_single.snap +++ /dev/null @@ -1,142 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: None, - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: Some( - [ - Inline( - Schema { - ref_path: None, - schema_type: Some( - Object, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: Some( - { - "Data": Inline( - Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - }, - ), - required: Some( - [ - "Data", - ], - ), - additional_properties: None, - min_properties: None, - max_properties: None, - enum: None, - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, - }, - ), - ], - ), - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple.snap deleted file mode 100644 index 933db19a..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: Some( - [ - String("First"), - String("Second"), - ], - ), - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple_snake.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple_snake.snap deleted file mode 100644 index 07da71c1..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_simple_snake.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: Some( - [ - String("first_item"), - String("second_item"), - ], - ), - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap b/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap deleted file mode 100644 index e42df388..00000000 --- a/crates/vespera_macro/src/parser/snapshots/vespera_macro__parser__schema__tests__parse_enum_to_schema_unit_variants@unit_status.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: crates/vespera_macro/src/parser/schema.rs -expression: schema ---- -Schema { - ref_path: None, - schema_type: Some( - String, - ), - format: None, - title: None, - description: None, - default: None, - example: None, - examples: None, - minimum: None, - maximum: None, - exclusive_minimum: None, - exclusive_maximum: None, - multiple_of: None, - min_length: None, - max_length: None, - pattern: None, - items: None, - prefix_items: None, - min_items: None, - max_items: None, - unique_items: None, - properties: None, - required: None, - additional_properties: None, - min_properties: None, - max_properties: None, - enum: Some( - [ - String("ok-status"), - String("error-code"), - ], - ), - all_of: None, - any_of: None, - one_of: None, - not: None, - nullable: None, - read_only: None, - write_only: None, - external_docs: None, - defs: None, - dynamic_anchor: None, - dynamic_ref: None, -} diff --git a/crates/vespera_macro/src/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index 3e99096f..ea8eb6f2 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -1,4 +1,4 @@ -use crate::{args::RouteArgs, http::is_http_method}; +use crate::{args::RouteArgs, http::is_http_method, metadata::HeaderParam}; /// Extract doc comments from attributes /// Returns concatenated doc comment string or None if no doc comments @@ -37,8 +37,17 @@ pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { pub struct RouteInfo { pub method: String, pub path: Option, + pub success_status: Option, pub error_status: Option>, + pub typed_responses: Option>, pub tags: Option>, + pub security: Option>, + pub headers: Vec, + pub operation_id: Option, + pub summary: Option, + pub request_example: Option, + pub response_example: Option, + pub deprecated: bool, pub description: Option, } @@ -62,56 +71,142 @@ fn build_route_info_from_args(route_args: &RouteArgs) -> RouteInfo { None }; - let error_status = route_args.error_status.as_ref().and_then(|array| { - let mut status_codes = Vec::new(); - for elem in &array.elems { + let error_status = route_args + .error_status + .as_ref() + .and_then(extract_status_codes); + let tags = route_args.tags.as_ref().and_then(extract_non_empty_strings); + let typed_responses = route_args + .responses + .as_ref() + .and_then(extract_typed_responses); + let security = route_args.security.as_ref().map(extract_strings); + let headers = route_args.headers.clone().unwrap_or_default(); + + let description = if let Some(lit) = route_args.description.as_ref() { + Some(lit.value()) + } else { + None + }; + + let operation_id = if let Some(lit) = route_args.operation_id.as_ref() { + Some(lit.value()) + } else { + None + }; + + let summary = if let Some(lit) = route_args.summary.as_ref() { + Some(lit.value()) + } else { + None + }; + + let request_example = route_args + .request_example + .as_ref() + .map(parse_example_string); + let response_example = route_args + .response_example + .as_ref() + .map(parse_example_string); + + RouteInfo { + method, + path, + success_status: route_args.success_status, + error_status, + typed_responses, + tags, + security, + headers, + operation_id, + summary, + request_example, + response_example, + deprecated: route_args.deprecated, + description, + } +} + +fn parse_example_string(lit: &syn::LitStr) -> serde_json::Value { + let value = lit.value(); + serde_json::from_str(&value).unwrap_or(serde_json::Value::String(value)) +} + +fn extract_status_codes(array: &syn::ExprArray) -> Option> { + let status_codes: Vec = array + .elems + .iter() + .filter_map(|elem| { if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Int(lit_int), .. }) = elem - && let Ok(code) = lit_int.base10_parse::() { - status_codes.push(code); + lit_int.base10_parse::().ok() + } else { + None } - } - if status_codes.is_empty() { - None - } else { - Some(status_codes) - } - }); + }) + .collect(); + (!status_codes.is_empty()).then_some(status_codes) +} - let tags = route_args.tags.as_ref().and_then(|array| { - let mut tag_list = Vec::new(); - for elem in &array.elems { +fn extract_strings(array: &syn::ExprArray) -> Vec { + array + .elems + .iter() + .filter_map(|elem| { if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(lit_str), .. }) = elem { - tag_list.push(lit_str.value()); + Some(lit_str.value()) + } else { + None } - } - if tag_list.is_empty() { + }) + .collect() +} + +fn extract_non_empty_strings(array: &syn::ExprArray) -> Option> { + let values = extract_strings(array); + (!values.is_empty()).then_some(values) +} + +fn extract_typed_responses(array: &syn::ExprArray) -> Option> { + let responses: Vec<(u16, String)> = array + .elems + .iter() + .filter_map(extract_typed_response) + .collect(); + (!responses.is_empty()).then_some(responses) +} + +fn extract_typed_response(elem: &syn::Expr) -> Option<(u16, String)> { + let syn::Expr::Tuple(tuple) = elem else { + return None; + }; + let status = tuple.elems.first().and_then(|status| { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(lit_int), + .. + }) = status + { + lit_int.base10_parse::().ok() + } else { None + } + })?; + let schema_name = tuple.elems.get(1).and_then(|schema| { + if let syn::Expr::Path(path) = schema { + path.path.segments.last().map(|seg| seg.ident.to_string()) } else { - Some(tag_list) + None } - }); - - let description = if let Some(lit) = route_args.description.as_ref() { - Some(lit.value()) - } else { - None - }; - - RouteInfo { - method, - path, - error_status, - tags, - description, - } + })?; + Some((status, schema_name)) } pub fn check_route_by_meta(meta: &syn::Meta) -> bool { @@ -175,8 +270,17 @@ fn try_extract_from_meta(meta: &syn::Meta) -> Option { Some(RouteInfo { method: method_str, path: None, + success_status: None, error_status: None, + typed_responses: None, tags: None, + security: None, + headers: Vec::new(), + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, description: None, }) } @@ -184,8 +288,17 @@ fn try_extract_from_meta(meta: &syn::Meta) -> Option { syn::Meta::Path(_) => Some(RouteInfo { method: "get".to_string(), path: None, + success_status: None, error_status: None, + typed_responses: None, tags: None, + security: None, + headers: Vec::new(), + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, description: None, }), } diff --git a/crates/vespera_macro/src/route_impl.rs b/crates/vespera_macro/src/route_impl.rs index 7b334ea5..c3f5d84f 100644 --- a/crates/vespera_macro/src/route_impl.rs +++ b/crates/vespera_macro/src/route_impl.rs @@ -32,9 +32,10 @@ //! } //! ``` +use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; -use crate::args; +use crate::{args, metadata::HeaderParam}; /// Metadata stored by `#[route]` for later consumption by `vespera!()`. /// /// Each invocation of `#[route]` pushes one entry into [`ROUTE_STORAGE`]. @@ -53,26 +54,102 @@ pub struct StoredRouteInfo { /// Custom path from `path = "/{id}"`. Used by the collector to /// derive the full route URL when present. pub custom_path: Option, + /// Declared non-200 success status from `status = ` (validated 2xx). + pub success_status: Option, /// Additional error status codes from `error_status = [400, 404]`. pub error_status: Option>, + /// Typed error responses from `responses = [(404, NotFoundError)]`. + pub typed_responses: Option>, /// Tags for `OpenAPI` grouping from `tags = ["users"]`. pub tags: Option>, + /// Per-route security requirements from `security = ["bearerAuth"]`. + pub security: Option>, + /// Header parameters from `headers = [{ name = "Authorization" }]`. + pub headers: Vec, + /// Explicit OpenAPI operationId from `operation_id = "getUser"`. + pub operation_id: Option, + /// OpenAPI operation summary from `summary = "Get user"`. + pub summary: Option, + /// Operation-level request example. + pub request_example: Option, + /// Operation-level response example. + pub response_example: Option, + /// Whether the operation is deprecated via bare `deprecated`. + pub deprecated: bool, /// Description from `description = "Get user by ID"`. pub description: Option, /// Source file path from `Span::call_site().local_file()` (requires Rust 1.88+). /// `None` on older Rust — collector falls back to full file parsing. pub file_path: Option, - /// Full function item as a string. Re-parsed via `syn::parse_str()` - /// by both [`crate::collector`] and [`crate::openapi_generator`] so - /// the source file does not need to be opened from disk for routes - /// already known via `ROUTE_STORAGE`. - pub fn_item_str: String, + /// Function signature as a string. Re-parsed via `syn::parse_str()` by + /// [`crate::openapi_generator`] when the source file AST is unavailable. + /// Stores only `syn::Signature` tokens, not the handler body. + pub fn_sig_str: String, } -/// Global storage for route metadata collected by `#[route]` attribute macros. -/// Read by `vespera!()` to supplement file-based route discovery. -pub static ROUTE_STORAGE: LazyLock>> = - LazyLock::new(|| Mutex::new(Vec::new())); +/// Per-crate storage for route metadata collected by `#[route]` attribute +/// macros, read by `vespera!()` / `export_app!()` to supplement file-based +/// route discovery. +/// +/// Keyed by [`crate::schema_impl::current_crate_key`] so a long-lived +/// rust-analyzer proc-macro server (one process, many crates) never feeds +/// crate A's routes into crate B's generated router/spec. See +/// [`SCHEMA_STORAGE`](crate::schema_impl::SCHEMA_STORAGE) for the rationale. +pub static ROUTE_STORAGE: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn same_route_source(left: &StoredRouteInfo, right: &StoredRouteInfo) -> bool { + left.fn_name == right.fn_name + && paths_equal_normalized(left.file_path.as_deref(), right.file_path.as_deref()) +} + +/// Compare two optional source paths treating `\` and `/` as equivalent, +/// WITHOUT allocating a normalized copy of either side. +/// +/// `register_route` calls this once per already-registered route on every +/// `#[route]` expansion, i.e. O(routes²) comparisons over a full build. The +/// previous `.replace('\\', "/")` on BOTH sides allocated two fresh `String`s +/// per comparison — a quadratic compile-time allocation source. Folding `\` +/// to `/` byte-by-byte (the remap is length-preserving, so a length mismatch +/// short-circuits) removes every one of those allocations. +fn paths_equal_normalized(left: Option<&str>, right: Option<&str>) -> bool { + let (left, right) = (left.unwrap_or_default(), right.unwrap_or_default()); + let norm = |b: u8| if b == b'\\' { b'/' } else { b }; + left.len() == right.len() + && std::iter::zip(left.bytes(), right.bytes()).all(|(l, r)| norm(l) == norm(r)) +} + +/// Replace-insert a `#[route]` metadata entry in the current crate's bucket. +pub fn register_route(info: StoredRouteInfo) { + let mut guard = ROUTE_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let bucket = guard + .entry(crate::schema_impl::current_crate_key()) + .or_default(); + if let Some(existing) = bucket + .iter_mut() + .find(|existing| same_route_source(existing, &info)) + { + *existing = info; + } else { + bucket.push(info); + } +} + +/// Snapshot (clone) of the current crate's registered routes, so consumers +/// keep operating on a `Vec` exactly as before per-crate +/// scoping — never seeing another crate's routes in a shared proc-macro +/// server. +#[must_use] +pub fn current_crate_routes() -> Vec { + ROUTE_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&crate::schema_impl::current_crate_key()) + .cloned() + .unwrap_or_default() +} /// Extract `u16` error status codes from a `syn::ExprArray`. fn extract_error_status_codes(arr: &syn::ExprArray) -> Option> { @@ -94,10 +171,35 @@ fn extract_error_status_codes(arr: &syn::ExprArray) -> Option> { if codes.is_empty() { None } else { Some(codes) } } -/// Extract `String` tags from a `syn::ExprArray`. -fn extract_tag_strings(arr: &syn::ExprArray) -> Option> { - let tags: Vec = arr - .elems +/// Reject any non-string-literal element of a `[...]` attribute array with a +/// spanned compile error instead of silently dropping it. A typo such as +/// `security = [bearerAuth]` (missing quotes) or `tags = [users]` would +/// otherwise vanish — and for `security` that silently documents a PROTECTED +/// route as unauthenticated, so this guard is security-relevant. +fn require_string_literal_elems(arr: &syn::ExprArray, attr_name: &str) -> syn::Result<()> { + for elem in &arr.elems { + if !matches!( + elem, + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(_), + .. + }) + ) { + return Err(syn::Error::new_spanned( + elem, + format!( + "#[route] `{attr_name}` entries must be string literals, e.g. `{attr_name} = [\"name\"]`" + ), + )); + } + } + Ok(()) +} + +/// Collect every `syn::Lit::Str` value from a validated array. Call only after +/// [`require_string_literal_elems`] so non-string elements cannot slip through. +fn collect_string_literal_values(arr: &syn::ExprArray) -> Vec { + arr.elems .iter() .filter_map(|elem| { if let syn::Expr::Lit(syn::ExprLit { @@ -110,8 +212,68 @@ fn extract_tag_strings(arr: &syn::ExprArray) -> Option> { None } }) + .collect() +} + +/// Extract `String` tags from a `syn::ExprArray`, erroring on any non-string +/// entry. An all-empty / no-string array yields `None`. +fn extract_tag_strings(arr: &syn::ExprArray) -> syn::Result>> { + require_string_literal_elems(arr, "tags")?; + let tags = collect_string_literal_values(arr); + Ok(if tags.is_empty() { None } else { Some(tags) }) +} + +/// Extract security scheme names from a `syn::ExprArray`, erroring on any +/// non-string entry. +/// +/// Unlike tags, an empty array is meaningful: `security = []` disables +/// inherited/global security for that operation in OpenAPI. +fn extract_security_strings(arr: &syn::ExprArray) -> syn::Result> { + require_string_literal_elems(arr, "security")?; + Ok(collect_string_literal_values(arr)) +} + +fn parse_example_string(lit: &syn::LitStr) -> serde_json::Value { + let value = lit.value(); + serde_json::from_str(&value).unwrap_or(serde_json::Value::String(value)) +} + +/// Extract typed response status/schema pairs from `responses = [(404, NotFoundError)]`. +fn extract_typed_responses(arr: &syn::ExprArray) -> Option> { + let responses: Vec<(u16, String)> = arr + .elems + .iter() + .filter_map(|elem| { + let syn::Expr::Tuple(tuple) = elem else { + return None; + }; + let status = tuple.elems.first().and_then(|status| { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(lit_int), + .. + }) = status + { + lit_int.base10_parse::().ok() + } else { + None + } + })?; + let schema_name = tuple.elems.get(1).and_then(|schema| { + if let syn::Expr::Path(path) = schema { + path.path.segments.last().map(|seg| seg.ident.to_string()) + } else { + None + } + })?; + Some((status, schema_name)) + }) .collect(); - if tags.is_empty() { None } else { Some(tags) } + + if responses.is_empty() { + None + } else { + Some(responses) + } } /// Validate route function - must be pub and async @@ -139,31 +301,53 @@ pub fn process_route_attribute( let route_args = syn::parse2::(attr)?; let item_fn: syn::ItemFn = syn::parse2(item.clone()).map_err(|e| syn::Error::new(e.span(), "#[route] attribute: can only be applied to functions, not other items. Move or remove the attribute."))?; validate_route_fn(&item_fn)?; + let fn_sig = &item_fn.sig; // Store route metadata for later consumption by vespera!() macro let stored = StoredRouteInfo { fn_name: item_fn.sig.ident.to_string(), method: route_args.method.as_ref().map(syn::Ident::to_string), custom_path: route_args.path.as_ref().map(syn::LitStr::value), + success_status: route_args.success_status, error_status: route_args .error_status .as_ref() .and_then(extract_error_status_codes), - tags: route_args.tags.as_ref().and_then(extract_tag_strings), + typed_responses: route_args + .responses + .as_ref() + .and_then(extract_typed_responses), + tags: match route_args.tags.as_ref() { + Some(arr) => extract_tag_strings(arr)?, + None => None, + }, + security: match route_args.security.as_ref() { + Some(arr) => Some(extract_security_strings(arr)?), + None => None, + }, + headers: route_args.headers.unwrap_or_default(), + operation_id: route_args.operation_id.as_ref().map(syn::LitStr::value), + summary: route_args.summary.as_ref().map(syn::LitStr::value), + request_example: route_args + .request_example + .as_ref() + .map(parse_example_string), + response_example: route_args + .response_example + .as_ref() + .map(parse_example_string), + deprecated: route_args.deprecated, description: route_args .description .as_ref() .map(syn::LitStr::value) .or_else(|| crate::route::extract_doc_comment(&item_fn.attrs)), - fn_item_str: item.to_string(), + fn_sig_str: quote::quote!(#fn_sig).to_string(), file_path: proc_macro2::Span::call_site() .local_file() .map(|p| p.display().to_string()), }; - ROUTE_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(stored); + register_route(stored); Ok(item) } @@ -347,10 +531,9 @@ mod tests { let result = process_route_attribute(attr, item); assert!(result.is_ok()); - // Find our entry by unique fn_name (ROUTE_STORAGE is global, shared across parallel tests) - let storage = ROUTE_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + // Find our entry by unique fn_name (the current crate's routes are + // shared across parallel tests in this crate). + let storage = current_crate_routes(); // Find our entry and verify fields let stored = storage @@ -366,7 +549,8 @@ mod tests { assert_eq!(stored.tags, Some(vec!["users".to_string()])); assert_eq!(stored.description, Some("Get user by ID".to_string())); assert_eq!(stored.error_status, Some(vec![404])); - assert!(stored.fn_item_str.contains("get_user_test_storage")); + assert!(stored.headers.is_empty()); + assert!(stored.fn_sig_str.contains("get_user_test_storage")); } #[test] @@ -380,9 +564,7 @@ mod tests { let result = process_route_attribute(attr, item); assert!(result.is_ok()); - let storage = ROUTE_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let storage = current_crate_routes(); let stored = storage.iter().find(|s| s.fn_name == "minimal_handler_test"); assert!(stored.is_some()); @@ -392,6 +574,51 @@ mod tests { assert_eq!(stored.tags, None); assert_eq!(stored.description, None); assert_eq!(stored.error_status, None); + assert!(stored.headers.is_empty()); + } + + #[test] + fn test_register_route_replaces_same_file_and_function() { + let file_path = Some("/tmp/vespera/routes/replaced.rs".to_string()); + let fn_name = "__test_replace_route".to_string(); + let base = StoredRouteInfo { + fn_name: fn_name.clone(), + method: Some("get".to_string()), + custom_path: Some("/before".to_string()), + success_status: None, + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + file_path: file_path.clone(), + fn_sig_str: "pub async fn __test_replace_route ()".to_string(), + }; + register_route(base); + register_route(StoredRouteInfo { + method: Some("post".to_string()), + custom_path: Some("/after".to_string()), + file_path, + fn_sig_str: "pub async fn __test_replace_route ()".to_string(), + ..current_crate_routes() + .into_iter() + .find(|entry| entry.fn_name == fn_name) + .expect("first route registration should exist") + }); + + let matches: Vec<_> = current_crate_routes() + .into_iter() + .filter(|entry| entry.fn_name == fn_name) + .collect(); + assert_eq!(matches.len(), 1, "same source route should replace"); + assert_eq!(matches[0].method, Some("post".to_string())); + assert_eq!(matches[0].custom_path, Some("/after".to_string())); } #[test] @@ -409,14 +636,14 @@ mod tests { #[test] fn test_extract_tag_strings_empty() { let arr: syn::ExprArray = syn::parse_quote!([]); - assert_eq!(extract_tag_strings(&arr), None); + assert_eq!(extract_tag_strings(&arr).unwrap(), None); } #[test] fn test_extract_tag_strings_values() { let arr: syn::ExprArray = syn::parse_quote!(["users", "admin", "api"]); assert_eq!( - extract_tag_strings(&arr), + extract_tag_strings(&arr).unwrap(), Some(vec![ "users".to_string(), "admin".to_string(), @@ -424,4 +651,41 @@ mod tests { ]) ); } + + #[test] + fn test_extract_tag_strings_rejects_non_string() { + // `tags = [users]` (bare ident, missing quotes) must be a compile + // error, not silently dropped. + let arr: syn::ExprArray = syn::parse_quote!([users]); + let err = extract_tag_strings(&arr).expect_err("non-string tag must error"); + assert!(err.to_string().contains("string literals"), "{err}"); + } + + #[test] + fn test_extract_security_strings_values() { + let arr: syn::ExprArray = syn::parse_quote!(["bearerAuth", "apiKey"]); + assert_eq!( + extract_security_strings(&arr).unwrap(), + vec!["bearerAuth".to_string(), "apiKey".to_string()] + ); + } + + #[test] + fn test_extract_security_strings_empty_is_ok() { + // `security = []` is meaningful (explicit no-auth) and must NOT error. + let arr: syn::ExprArray = syn::parse_quote!([]); + assert_eq!( + extract_security_strings(&arr).unwrap(), + Vec::::new() + ); + } + + #[test] + fn test_extract_security_strings_rejects_non_string() { + // `security = [bearerAuth]` (missing quotes) must error rather than + // silently documenting a protected route as unauthenticated. + let arr: syn::ExprArray = syn::parse_quote!([bearerAuth]); + let err = extract_security_strings(&arr).expect_err("non-string security must error"); + assert!(err.to_string().contains("string literals"), "{err}"); + } } diff --git a/crates/vespera_macro/src/router_codegen.rs b/crates/vespera_macro/src/router_codegen.rs index 75f60129..b1629a16 100644 --- a/crates/vespera_macro/src/router_codegen.rs +++ b/crates/vespera_macro/src/router_codegen.rs @@ -1,1970 +1,13 @@ //! Router code generation and macro input parsing. //! -//! This module contains the core logic for: -//! - Parsing `vespera!` and `export_app!` macro inputs -//! - Processing input into validated configuration -//! - Generating Axum router code from collected metadata -//! -//! # Overview -//! -//! The vespera macros accept configuration parameters (directory, `OpenAPI` files, etc.) -//! which are parsed and processed into a normalized form. This module then generates -//! the `TokenStream` that creates the Axum router with all discovered routes. -//! -//! # Key Components -//! -//! - [`AutoRouterInput`] - Parsed `vespera!()` macro arguments -//! - [`ExportAppInput`] - Parsed `export_app!()` macro arguments -//! - [`process_vespera_input`] - Validate and process vespera! arguments -//! - [`generate_router_code`] - Generate the router `TokenStream` -//! -//! # Macro Parameters -//! -//! **vespera!()** accepts: -//! - `dir` - Route discovery folder (default: "routes") -//! - `openapi` - Output file path(s) for `OpenAPI` spec -//! - `title` - API title (`OpenAPI` info.title) -//! - `version` - API version (`OpenAPI` info.version) -//! - `docs_url` - Swagger UI endpoint -//! - `redoc_url` - `ReDoc` endpoint -//! - `servers` - Array of server configurations -//! - `merge` - Child vespera apps to merge -//! -//! **`export_app`!()** accepts: -//! - `dir` - Route discovery folder (default: "routes") - -use proc_macro2::Span; -use quote::quote; -use syn::{ - LitStr, bracketed, - parse::{Parse, ParseStream}, - punctuated::Punctuated, -}; -use vespera_core::{openapi::Server, route::HttpMethod}; - -use crate::{ - metadata::{CollectedMetadata, CronMetadata}, - method::http_method_to_token_stream, -}; - -/// Server configuration for `OpenAPI` -#[derive(Clone)] -pub struct ServerConfig { - pub url: String, - pub description: Option, -} - -/// Input for the `vespera!` macro -pub struct AutoRouterInput { - pub dir: Option, - pub openapi: Option>, - pub title: Option, - pub version: Option, - pub docs_url: Option, - pub redoc_url: Option, - pub servers: Option>, - /// Apps to merge (e.g., [`third::ThirdApp`, `another::AnotherApp`]) - pub merge: Option>, -} - -impl Parse for AutoRouterInput { - #[allow(clippy::too_many_lines)] - fn parse(input: ParseStream) -> syn::Result { - let mut dir = None; - let mut openapi = None; - let mut title = None; - let mut version = None; - let mut docs_url = None; - let mut redoc_url = None; - let mut servers = None; - let mut merge = None; - - while !input.is_empty() { - let lookahead = input.lookahead1(); - - if lookahead.peek(syn::Ident) { - let ident: syn::Ident = input.parse()?; - let ident_str = ident.to_string(); - - match ident_str.as_str() { - "dir" => { - input.parse::()?; - dir = Some(input.parse()?); - } - "openapi" => { - openapi = Some(parse_openapi_values(input)?); - } - "docs_url" => { - input.parse::()?; - docs_url = Some(input.parse()?); - } - "redoc_url" => { - input.parse::()?; - redoc_url = Some(input.parse()?); - } - "title" => { - input.parse::()?; - title = Some(input.parse()?); - } - "version" => { - input.parse::()?; - version = Some(input.parse()?); - } - "servers" => { - servers = Some(parse_servers_values(input)?); - } - "merge" => { - merge = Some(parse_merge_values(input)?); - } - _ => { - return Err(syn::Error::new( - ident.span(), - format!( - "unknown field: `{ident_str}`. Expected `dir`, `openapi`, `title`, `version`, `docs_url`, `redoc_url`, `servers`, or `merge`" - ), - )); - } - } - } else if lookahead.peek(syn::LitStr) { - // If just a string, treat it as dir (for backward compatibility) - dir = Some(input.parse()?); - } else { - return Err(lookahead.error()); - } - - if input.peek(syn::Token![,]) { - input.parse::()?; - } else { - break; - } - } - - Ok(Self { - dir: dir.or_else(|| { - std::env::var("VESPERA_DIR") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - openapi: openapi.or_else(|| { - std::env::var("VESPERA_OPENAPI") - .map(|f| vec![LitStr::new(&f, Span::call_site())]) - .ok() - }), - title: title.or_else(|| { - std::env::var("VESPERA_TITLE") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - version: version - .or_else(|| { - std::env::var("VESPERA_VERSION") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }) - .or_else(|| { - std::env::var("CARGO_PKG_VERSION") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - docs_url: docs_url.or_else(|| { - std::env::var("VESPERA_DOCS_URL") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - redoc_url: redoc_url.or_else(|| { - std::env::var("VESPERA_REDOC_URL") - .map(|f| LitStr::new(&f, Span::call_site())) - .ok() - }), - servers: servers.or_else(|| { - std::env::var("VESPERA_SERVER_URL") - .ok() - .filter(|url| url.starts_with("http://") || url.starts_with("https://")) - .map(|url| { - vec![ServerConfig { - url, - description: std::env::var("VESPERA_SERVER_DESCRIPTION").ok(), - }] - }) - }), - merge, - }) - } -} - -/// Parse merge values: merge = [`path::to::App`, `another::App`] -fn parse_merge_values(input: ParseStream) -> syn::Result> { - input.parse::()?; - - let content; - let _ = bracketed!(content in input); - let paths: Punctuated = - content.parse_terminated(syn::Path::parse, syn::Token![,])?; - Ok(paths.into_iter().collect()) -} - -fn parse_openapi_values(input: ParseStream) -> syn::Result> { - input.parse::()?; - - if input.peek(syn::token::Bracket) { - let content; - let _ = bracketed!(content in input); - let entries: Punctuated = - content.parse_terminated(syn::parse::ParseBuffer::parse::, syn::Token![,])?; - Ok(entries.into_iter().collect()) - } else { - let single: LitStr = input.parse()?; - Ok(vec![single]) - } -} - -/// Validate that a URL starts with http:// or https:// -fn validate_server_url(url: &LitStr) -> syn::Result { - let url_value = url.value(); - if !url_value.starts_with("http://") && !url_value.starts_with("https://") { - return Err(syn::Error::new( - url.span(), - format!( - "invalid server URL: `{url_value}`. URL must start with `http://` or `https://`" - ), - )); - } - Ok(url_value) -} - -/// Parse server values in various formats: -/// - `servers = "url"` - single URL -/// - `servers = ["url1", "url2"]` - multiple URLs (strings only) -/// - `servers = [("url", "description")]` - tuple format with descriptions -/// - `servers = [{url = "...", description = "..."}]` - struct-like format -/// - `servers = {url = "...", description = "..."}` - single server struct-like format -fn parse_servers_values(input: ParseStream) -> syn::Result> { - use syn::token::{Brace, Paren}; - - input.parse::()?; - - if input.peek(syn::token::Bracket) { - // Array format: [...] - let content; - let _ = bracketed!(content in input); - - let mut servers = Vec::new(); - - while !content.is_empty() { - if content.peek(Paren) { - // Parse tuple: ("url", "description") - let tuple_content; - syn::parenthesized!(tuple_content in content); - let url: LitStr = tuple_content.parse()?; - let url_value = validate_server_url(&url)?; - let description = if tuple_content.peek(syn::Token![,]) { - tuple_content.parse::()?; - Some(tuple_content.parse::()?.value()) - } else { - None - }; - servers.push(ServerConfig { - url: url_value, - description, - }); - } else if content.peek(Brace) { - // Parse struct-like: {url = "...", description = "..."} - let server = parse_server_struct(&content)?; - servers.push(server); - } else { - // Parse simple string: "url" - let url: LitStr = content.parse()?; - let url_value = validate_server_url(&url)?; - servers.push(ServerConfig { - url: url_value, - description: None, - }); - } - - if content.peek(syn::Token![,]) { - content.parse::()?; - } else { - break; - } - } - - Ok(servers) - } else if input.peek(syn::token::Brace) { - // Single struct-like format: servers = {url = "...", description = "..."} - let server = parse_server_struct(input)?; - Ok(vec![server]) - } else { - // Single string: servers = "url" - let single: LitStr = input.parse()?; - let url_value = validate_server_url(&single)?; - Ok(vec![ServerConfig { - url: url_value, - description: None, - }]) - } -} - -/// Parse a single server in struct-like format: {url = "...", description = "..."} -fn parse_server_struct(input: ParseStream) -> syn::Result { - let content; - syn::braced!(content in input); - - let mut url: Option = None; - let mut description: Option = None; - - while !content.is_empty() { - let ident: syn::Ident = content.parse()?; - let ident_str = ident.to_string(); - - match ident_str.as_str() { - "url" => { - content.parse::()?; - let url_lit: LitStr = content.parse()?; - url = Some(validate_server_url(&url_lit)?); - } - "description" => { - content.parse::()?; - description = Some(content.parse::()?.value()); - } - _ => { - return Err(syn::Error::new( - ident.span(), - format!("unknown field: `{ident_str}`. Expected `url` or `description`"), - )); - } - } - - if content.peek(syn::Token![,]) { - content.parse::()?; - } else { - break; - } - } - - let url = url.ok_or_else(|| syn::Error::new(proc_macro2::Span::call_site(), "vespera! macro: server configuration missing required `url` field. Use format: `servers = { url = \"http://localhost:3000\" }` or `servers = { url = \"...\", description = \"...\" }`."))?; - - Ok(ServerConfig { url, description }) -} - -/// Processed vespera input with extracted values -pub struct ProcessedVesperaInput { - pub folder_name: String, - pub openapi_file_names: Vec, - pub title: Option, - pub version: Option, - pub docs_url: Option, - pub redoc_url: Option, - pub servers: Option>, - /// Apps to merge (`syn::Path` for code generation) - pub merge: Vec, -} - -/// Process `AutoRouterInput` into extracted values -pub fn process_vespera_input(input: AutoRouterInput) -> ProcessedVesperaInput { - ProcessedVesperaInput { - folder_name: input - .dir - .map_or_else(|| "routes".to_string(), |f| f.value()), - openapi_file_names: input - .openapi - .unwrap_or_default() - .into_iter() - .map(|f| f.value()) - .collect(), - title: input.title.map(|t| t.value()), - version: input.version.map(|v| v.value()), - docs_url: input.docs_url.map(|u| u.value()), - redoc_url: input.redoc_url.map(|u| u.value()), - servers: input.servers.map(|svrs| { - svrs.into_iter() - .map(|s| Server { - url: s.url, - description: s.description, - variables: None, - }) - .collect() - }), - merge: input.merge.unwrap_or_default(), - } -} - -/// Input for `export_app`! macro -pub struct ExportAppInput { - /// App name (struct name to generate) - pub name: syn::Ident, - /// Route directory - pub dir: Option, -} - -impl Parse for ExportAppInput { - fn parse(input: ParseStream) -> syn::Result { - let name: syn::Ident = input.parse()?; - - let mut dir = None; - - // Parse optional comma and arguments - while input.peek(syn::Token![,]) { - input.parse::()?; - - if input.is_empty() { - break; - } - - let ident: syn::Ident = input.parse()?; - let ident_str = ident.to_string(); - - match ident_str.as_str() { - "dir" => { - input.parse::()?; - dir = Some(input.parse()?); - } - _ => { - return Err(syn::Error::new( - ident.span(), - format!("unknown field: `{ident_str}`. Expected `dir`"), - )); - } - } - } - - Ok(Self { name, dir }) - } -} - -/// Swagger UI HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. -const SWAGGER_UI_HTML: &str = r##"Swagger UI
"##; - -/// ReDoc HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. -const REDOC_HTML: &str = r#"ReDoc
"#; - -/// Generate a documentation route handler (Swagger UI or ReDoc). -/// -/// When `has_merge` is true, the handler merges specs from child apps at runtime. -/// When false, it serves the spec directly from the compile-time constant. -fn generate_docs_route_tokens( - url: &str, - html_template: &str, - merge_spec_code: &[proc_macro2::TokenStream], - has_merge: bool, -) -> proc_macro2::TokenStream { - let method_path = http_method_to_token_stream(HttpMethod::Get); - - if has_merge { - quote!( - .route(#url, #method_path(|| async { - static MERGED_SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); - let spec = MERGED_SPEC.get_or_init(|| { - let mut merged: vespera::OpenApi = vespera::serde_json::from_str(__VESPERA_SPEC).unwrap(); - #(#merge_spec_code)* - vespera::serde_json::to_string(&merged).unwrap() - }); - static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); - let html = HTML.get_or_init(|| { - format!(#html_template, spec) - }); - vespera::axum::response::Html(html.as_str()) - })) - ) - } else { - quote!( - .route(#url, #method_path(|| async { - static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); - let html = HTML.get_or_init(|| { - format!(#html_template, __VESPERA_SPEC) - }); - vespera::axum::response::Html(html.as_str()) - })) - ) - } -} -/// Generate cron scheduler spawn code from collected cron metadata. -fn generate_cron_scheduler_code(cron_jobs: &[CronMetadata]) -> proc_macro2::TokenStream { - if cron_jobs.is_empty() { - return quote!(); - } - - let job_additions: Vec = cron_jobs - .iter() - .map(|cron| { - let expression = &cron.expression; - let module_path = &cron.module_path; - let function_name = &cron.function_name; - - // Build the full path: crate::module::function - let mut p: syn::punctuated::Punctuated = - syn::punctuated::Punctuated::new(); - p.push(syn::PathSegment { - ident: syn::Ident::new("crate", Span::call_site()), - arguments: syn::PathArguments::None, - }); - p.extend(module_path.split("::").filter_map(|s| { - if s.is_empty() { - None - } else { - Some(syn::PathSegment { - ident: syn::Ident::new(s, Span::call_site()), - arguments: syn::PathArguments::None, - }) - } - })); - let func_ident = syn::Ident::new(function_name, Span::call_site()); - - let err_create = format!("vespera: failed to create cron job '{function_name}'"); - let err_add = format!("vespera: failed to add cron job '{function_name}'"); - - quote! { - __vespera_cron_scheduler.add( - vespera::tokio_cron_scheduler::Job::new_async(#expression, |_uuid, _l| { - Box::pin(async move { - #p::#func_ident().await; - }) - }).expect(#err_create) - ).await.expect(#err_add); - } - }) - .collect(); - - quote! { - vespera::tokio::spawn(async move { - let mut __vespera_cron_scheduler = vespera::tokio_cron_scheduler::JobScheduler::new().await - .expect("vespera: failed to create cron scheduler"); - #(#job_additions)* - __vespera_cron_scheduler.start().await - .expect("vespera: failed to start cron scheduler"); - // Keep scheduler alive forever - ::std::future::pending::<()>().await; - }); - } -} - -/// Generate Axum router code from collected metadata -#[allow(clippy::too_many_lines)] -pub fn generate_router_code( - metadata: &CollectedMetadata, - docs_url: Option<&str>, - redoc_url: Option<&str>, - spec_tokens: Option, - merge_apps: &[syn::Path], - cron_jobs: &[CronMetadata], -) -> proc_macro2::TokenStream { - let mut router_nests = Vec::new(); - - for route in &metadata.routes { - let Ok(http_method) = HttpMethod::try_from(route.method.as_str()) else { - eprintln!( - "vespera: skipping route '{}' — unknown HTTP method '{}'", - route.path, route.method - ); - continue; - }; - let method_path = http_method_to_token_stream(http_method); - let path = &route.path; - let module_path = &route.module_path; - let function_name = &route.function_name; - - let mut p: syn::punctuated::Punctuated = - syn::punctuated::Punctuated::new(); - p.push(syn::PathSegment { - ident: syn::Ident::new("crate", Span::call_site()), - arguments: syn::PathArguments::None, - }); - p.extend(module_path.split("::").filter_map(|s| { - if s.is_empty() { - None - } else { - Some(syn::PathSegment { - ident: syn::Ident::new(s, Span::call_site()), - arguments: syn::PathArguments::None, - }) - } - })); - let func_name = syn::Ident::new(function_name, Span::call_site()); - router_nests.push(quote!( - .route(#path, #method_path(#p::#func_name)) - )); - } - - // Check if we need to merge specs at runtime - let has_merge = !merge_apps.is_empty(); - - // Generate merge code once, reuse in both docs_url and redoc_url routes - let merge_spec_code: Vec<_> = merge_apps - .iter() - .map(|app_path| { - quote! { - if let Ok(other) = vespera::serde_json::from_str::(#app_path::OPENAPI_SPEC) { - merged.merge(other); - } - } - }) - .collect(); - - if let Some(docs_url) = docs_url { - router_nests.push(generate_docs_route_tokens( - docs_url, - SWAGGER_UI_HTML, - &merge_spec_code, - has_merge, - )); - } - - if let Some(redoc_url) = redoc_url { - router_nests.push(generate_docs_route_tokens( - redoc_url, - REDOC_HTML, - &merge_spec_code, - has_merge, - )); - } - - let needs_spec_const = spec_tokens.is_some() && (docs_url.is_some() || redoc_url.is_some()); - let cron_code = generate_cron_scheduler_code(cron_jobs); - - if needs_spec_const { - let spec_expr = spec_tokens.unwrap(); - if merge_apps.is_empty() { - quote! { - { - const __VESPERA_SPEC: &str = #spec_expr; - #cron_code - vespera::axum::Router::new() - #( #router_nests )* - } - } - } else { - quote! { - { - const __VESPERA_SPEC: &str = #spec_expr; - #cron_code - vespera::VesperaRouter::new( - vespera::axum::Router::new() - #( #router_nests )*, - vec![#( #merge_apps::router ),*] - ) - } - } - } - } else if merge_apps.is_empty() { - if cron_jobs.is_empty() { - quote! { - vespera::axum::Router::new() - #( #router_nests )* - } - } else { - quote! { - { - #cron_code - vespera::axum::Router::new() - #( #router_nests )* - } - } - } - } else { - // When merging apps, return VesperaRouter which defers the merge - // until with_state() is called. This is necessary because Axum requires - // merged routers to have the same state type. - if cron_jobs.is_empty() { - quote! { - vespera::VesperaRouter::new( - vespera::axum::Router::new() - #( #router_nests )*, - vec![#( #merge_apps::router ),*] - ) - } - } else { - quote! { - { - #cron_code - vespera::VesperaRouter::new( - vespera::axum::Router::new() - #( #router_nests )*, - vec![#( #merge_apps::router ),*] - ) - } - } - } - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use rstest::rstest; - use tempfile::TempDir; - - use super::*; - use crate::collector::collect_metadata; - - fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { - let file_path = dir.path().join(filename); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).expect("Failed to create parent directory"); - } - fs::write(&file_path, content).expect("Failed to write temp file"); - file_path - } - - #[test] - fn test_generate_router_code_empty() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Should generate empty router - // quote! generates "vespera :: axum :: Router :: new ()" format - assert!( - code.contains("Router") && code.contains("new"), - "Code should contain Router::new(), got: {code}" - ); - assert!( - !code.contains("route"), - "Code should not contain route, got: {code}" - ); - - drop(temp_dir); - } - - #[rstest] - #[case::single_get_route( - "routes", - vec![( - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users", - "routes::users::get_users", - )] - #[case::single_post_route( - "routes", - vec![( - "create_user.rs", - r#" -#[route(post)] -pub fn create_user() -> String { - "created".to_string() -} -"#, - )], - "post", - "/create-user", - "routes::create_user::create_user", - )] - #[case::single_put_route( - "routes", - vec![( - "update_user.rs", - r#" -#[route(put)] -pub fn update_user() -> String { - "updated".to_string() -} -"#, - )], - "put", - "/update-user", - "routes::update_user::update_user", - )] - #[case::single_delete_route( - "routes", - vec![( - "delete_user.rs", - r#" -#[route(delete)] -pub fn delete_user() -> String { - "deleted".to_string() -} -"#, - )], - "delete", - "/delete-user", - "routes::delete_user::delete_user", - )] - #[case::single_patch_route( - "routes", - vec![( - "patch_user.rs", - r#" -#[route(patch)] -pub fn patch_user() -> String { - "patched".to_string() -} -"#, - )], - "patch", - "/patch-user", - "routes::patch_user::patch_user", - )] - #[case::route_with_custom_path( - "routes", - vec![( - "users.rs", - r#" -#[route(get, path = "/api/users")] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users/api/users", - "routes::users::get_users", - )] - #[case::nested_module( - "routes", - vec![( - "api/users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/api/users", - "routes::api::users::get_users", - )] - #[case::deeply_nested_module( - "routes", - vec![( - "api/v1/users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/api/v1/users", - "routes::api::v1::users::get_users", - )] - fn test_generate_router_code_single_route( - #[case] folder_name: &str, - #[case] files: Vec<(&str, &str)>, - #[case] expected_method: &str, - #[case] expected_path: &str, - #[case] expected_function_path: &str, - ) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - for (filename, content) in files { - create_temp_file(&temp_dir, filename, content); - } - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!( - code.contains("Router") && code.contains("new"), - "Code should contain Router::new(), got: {code}" - ); - - // Check route method - assert!( - code.contains(expected_method), - "Code should contain method: {expected_method}, got: {code}" - ); - - // Check route path - assert!( - code.contains(expected_path), - "Code should contain path: {expected_path}, got: {code}" - ); - - // Check function path (quote! adds spaces, so we check for parts) - let function_parts: Vec<&str> = expected_function_path.split("::").collect(); - for part in &function_parts { - if !part.is_empty() { - assert!( - code.contains(part), - "Code should contain function part: {part}, got: {code}" - ); - } - } - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_multiple_routes() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create multiple route files - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - create_temp_file( - &temp_dir, - "create_user.rs", - r#" -#[route(post)] -pub fn create_user() -> String { - "created".to_string() -} -"#, - ); - - create_temp_file( - &temp_dir, - "update_user.rs", - r#" -#[route(put)] -pub fn update_user() -> String { - "updated".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check all routes are present - assert!(code.contains("get_users")); - assert!(code.contains("create_user")); - assert!(code.contains("update_user")); - - // Check methods - assert!(code.contains("get")); - assert!(code.contains("post")); - assert!(code.contains("put")); - - // Count route calls (quote! generates ". route (" with spaces) - // Count occurrences of ". route (" pattern - let route_count = code.matches(". route (").count(); - assert_eq!( - route_count, 3, - "Should have 3 route calls, got: {route_count}, code: {code}" - ); - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_same_path_different_methods() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create routes with same path but different methods - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} - -#[route(post)] -pub fn create_users() -> String { - "created".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check both routes are present - assert!(code.contains("get_users")); - assert!(code.contains("create_users")); - - // Check methods - assert!(code.contains("get")); - assert!(code.contains("post")); - - // Should have 2 routes (quote! generates ". route (" with spaces) - let route_count = code.matches(". route (").count(); - assert_eq!( - route_count, 2, - "Should have 2 routes, got: {route_count}, code: {code}" - ); - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_with_mod_rs() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create mod.rs file - create_temp_file( - &temp_dir, - "mod.rs", - r#" -#[route(get)] -pub fn index() -> String { - "index".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check route is present - assert!(code.contains("index")); - - // Path should be / (mod.rs maps to root, segments is empty) - // quote! generates "\"/\"" - assert!(code.contains("\"/\"")); - - drop(temp_dir); - } - - #[test] - fn test_generate_router_code_empty_folder_name() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = ""; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name, &[]) - .unwrap() - .0, - None, - None, - None, - &[], - &[], - ); - let code = result.to_string(); - - // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") - assert!(code.contains("Router") && code.contains("new")); - - // Check route is present - assert!(code.contains("get_users")); - - // Module path should not have double colons - assert!(!code.contains("::users::users")); - - drop(temp_dir); - } - - // ========== Tests for parsing functions ========== - - #[test] - fn test_parse_openapi_values_single() { - // Test that single string openapi value parses correctly via AutoRouterInput - let tokens = quote::quote!(openapi = "openapi.json"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let openapi = input.openapi.unwrap(); - assert_eq!(openapi.len(), 1); - assert_eq!(openapi[0].value(), "openapi.json"); - } - - #[test] - fn test_parse_openapi_values_array() { - // Test that array openapi value parses correctly via AutoRouterInput - let tokens = quote::quote!(openapi = ["openapi.json", "api.json"]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let openapi = input.openapi.unwrap(); - assert_eq!(openapi.len(), 2); - assert_eq!(openapi[0].value(), "openapi.json"); - assert_eq!(openapi[1].value(), "api.json"); - } - - #[test] - fn test_validate_server_url_valid_http() { - let lit = LitStr::new("http://localhost:3000", Span::call_site()); - let result = validate_server_url(&lit); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "http://localhost:3000"); - } - - #[test] - fn test_validate_server_url_valid_https() { - let lit = LitStr::new("https://api.example.com", Span::call_site()); - let result = validate_server_url(&lit); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "https://api.example.com"); - } - - #[test] - fn test_validate_server_url_invalid() { - let lit = LitStr::new("ftp://example.com", Span::call_site()); - let result = validate_server_url(&lit); - assert!(result.is_err()); - } - - #[test] - fn test_validate_server_url_no_scheme() { - let lit = LitStr::new("example.com", Span::call_site()); - let result = validate_server_url(&lit); - assert!(result.is_err()); - } - - #[test] - fn test_auto_router_input_parse_dir_only() { - let tokens = quote::quote!(dir = "api"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.dir.unwrap().value(), "api"); - assert!(input.openapi.is_none()); - } - - #[test] - fn test_auto_router_input_parse_string_as_dir() { - let tokens = quote::quote!("routes"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.dir.unwrap().value(), "routes"); - } - - #[test] - fn test_auto_router_input_parse_openapi_single() { - let tokens = quote::quote!(openapi = "openapi.json"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let openapi = input.openapi.unwrap(); - assert_eq!(openapi.len(), 1); - assert_eq!(openapi[0].value(), "openapi.json"); - } - - #[test] - fn test_auto_router_input_parse_openapi_array() { - let tokens = quote::quote!(openapi = ["a.json", "b.json"]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let openapi = input.openapi.unwrap(); - assert_eq!(openapi.len(), 2); - } - - #[test] - fn test_auto_router_input_parse_title_version() { - let tokens = quote::quote!(title = "My API", version = "2.0.0"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.title.unwrap().value(), "My API"); - assert_eq!(input.version.unwrap().value(), "2.0.0"); - } - - #[test] - fn test_auto_router_input_parse_docs_redoc() { - let tokens = quote::quote!(docs_url = "/docs", redoc_url = "/redoc"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.docs_url.unwrap().value(), "/docs"); - assert_eq!(input.redoc_url.unwrap().value(), "/redoc"); - } - - #[test] - fn test_auto_router_input_parse_servers_single() { - let tokens = quote::quote!(servers = "http://localhost:3000"); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "http://localhost:3000"); - assert!(servers[0].description.is_none()); - } - - #[test] - fn test_auto_router_input_parse_servers_array_strings() { - let tokens = quote::quote!(servers = ["http://localhost:3000", "https://api.example.com"]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 2); - } - - #[test] - fn test_auto_router_input_parse_servers_tuple() { - let tokens = quote::quote!(servers = [("http://localhost:3000", "Development")]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "http://localhost:3000"); - assert_eq!(servers[0].description, Some("Development".to_string())); - } - - #[test] - fn test_auto_router_input_parse_servers_struct() { - let tokens = - quote::quote!(servers = [{ url = "http://localhost:3000", description = "Dev" }]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "http://localhost:3000"); - assert_eq!(servers[0].description, Some("Dev".to_string())); - } - - #[test] - fn test_auto_router_input_parse_servers_single_struct() { - let tokens = quote::quote!(servers = { url = "https://api.example.com" }); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "https://api.example.com"); - } - - #[test] - fn test_auto_router_input_parse_unknown_field() { - let tokens = quote::quote!(unknown_field = "value"); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - } - - #[test] - fn test_auto_router_input_parse_all_fields() { - let tokens = quote::quote!( - dir = "api", - openapi = "openapi.json", - title = "Test API", - version = "1.0.0", - docs_url = "/docs", - redoc_url = "/redoc", - servers = "http://localhost:3000" - ); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - assert!(input.dir.is_some()); - assert!(input.openapi.is_some()); - assert!(input.title.is_some()); - assert!(input.version.is_some()); - assert!(input.docs_url.is_some()); - assert!(input.redoc_url.is_some()); - assert!(input.servers.is_some()); - } - - #[test] - fn test_generate_router_code_with_docs() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = generate_router_code( - &metadata, - Some("/docs"), - None, - Some(quote::quote!(#spec)), - &[], - &[], - ); - let code = result.to_string(); - - assert!(code.contains("/docs")); - assert!(code.contains("swagger-ui")); - assert!(code.contains("__VESPERA_SPEC")); - assert!(code.contains("OnceLock")); - } - - #[test] - fn test_generate_router_code_with_redoc() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = generate_router_code( - &metadata, - None, - Some("/redoc"), - Some(quote::quote!(#spec)), - &[], - &[], - ); - let code = result.to_string(); - - assert!(code.contains("/redoc")); - assert!(code.contains("redoc")); - assert!(code.contains("__VESPERA_SPEC")); - assert!(code.contains("OnceLock")); - } - - #[test] - fn test_generate_router_code_with_both_docs() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = generate_router_code( - &metadata, - Some("/docs"), - Some("/redoc"), - Some(quote::quote!(#spec)), - &[], - &[], - ); - let code = result.to_string(); - - assert!(code.contains("/docs")); - assert!(code.contains("/redoc")); - assert!(code.contains("__VESPERA_SPEC")); - } - - #[test] - fn test_swagger_html_template_renders_valid_quotes() { - assert!( - !SWAGGER_UI_HTML.contains(r#"\""#), - "Swagger template should not contain literal backslash-quotes: {SWAGGER_UI_HTML}" - ); - assert!( - SWAGGER_UI_HTML.contains(r#"href="https://unpkg.com/swagger-ui-dist/swagger-ui.css""#) - ); - assert!( - SWAGGER_UI_HTML - .contains(r#"src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js""#) - ); - assert!(SWAGGER_UI_HTML.contains(r##"dom_id: "#swagger-ui""##)); - } - - #[test] - fn test_redoc_html_template_renders_valid_quotes() { - assert!( - !REDOC_HTML.contains(r#"\""#), - "ReDoc template should not contain literal backslash-quotes: {REDOC_HTML}" - ); - assert!( - REDOC_HTML.contains(r#"href="https://unpkg.com/redoc/bundles/redoc.standalone.css""#) - ); - assert!( - REDOC_HTML.contains(r#"src="https://unpkg.com/redoc/bundles/redoc.standalone.js""#) - ); - assert!(REDOC_HTML.contains(r#"document.getElementById("redoc-container")"#)); - } - - #[test] - fn test_parse_server_struct_url_only() { - // Test server struct parsing via AutoRouterInput - let tokens = quote::quote!(servers = { url = "http://localhost:3000" }); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "http://localhost:3000"); - assert!(servers[0].description.is_none()); - } - - #[test] - fn test_parse_server_struct_with_description() { - let tokens = - quote::quote!(servers = { url = "http://localhost:3000", description = "Local" }); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers[0].description, Some("Local".to_string())); - } - - #[test] - fn test_parse_server_struct_unknown_field() { - let tokens = quote::quote!(servers = { url = "http://localhost:3000", unknown = "test" }); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - } - - #[test] - fn test_parse_server_struct_missing_url() { - let tokens = quote::quote!(servers = { description = "test" }); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - } - - #[test] - fn test_parse_servers_tuple_url_only() { - let tokens = quote::quote!(servers = [("http://localhost:3000")]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let servers = input.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert!(servers[0].description.is_none()); - } - - #[test] - fn test_parse_servers_invalid_url() { - let tokens = quote::quote!(servers = "invalid-url"); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - } - - #[test] - fn test_generate_router_code_unknown_http_method() { - // Test lines 337-340: route with unknown HTTP method is skipped in router codegen - let mut metadata = CollectedMetadata { - routes: Vec::new(), - structs: Vec::new(), - crons: Vec::new(), - }; - metadata.routes.push(crate::metadata::RouteMetadata { - method: "INVALID".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes::users".to_string(), - file_path: "dummy.rs".to_string(), - signature: "fn get_users() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let result = generate_router_code(&metadata, None, None, None, &[], &[]); - let code = result.to_string(); - - // Router should be generated but without any route calls - assert!( - code.contains("Router") && code.contains("new"), - "Code should contain Router::new(), got: {code}" - ); - assert!( - !code.contains(". route ("), - "Route with unknown HTTP method should be skipped, got: {code}" - ); - } - - #[test] - fn test_generate_router_code_unknown_method_skipped_valid_kept() { - // Test that unknown methods are skipped while valid routes are still generated - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let (mut metadata, _file_asts) = - collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); - // Inject an additional route with invalid method - metadata.routes.push(crate::metadata::RouteMetadata { - method: "CONNECT".to_string(), - path: "/invalid".to_string(), - function_name: "connect_handler".to_string(), - module_path: "routes::invalid".to_string(), - file_path: "dummy.rs".to_string(), - signature: "fn connect_handler() -> String".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let result = generate_router_code(&metadata, None, None, None, &[], &[]); - let code = result.to_string(); - - // Valid route should be present - assert!( - code.contains("get_users"), - "Valid route should be present, got: {code}" - ); - // Invalid route should be skipped - assert!( - !code.contains("connect_handler"), - "Invalid method route should be skipped, got: {code}" - ); - - drop(temp_dir); - } - - #[test] - fn test_auto_router_input_parse_invalid_token() { - // Test line 149: neither ident nor string literal triggers lookahead error - let tokens = quote::quote!(123); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - } - - #[test] - fn test_auto_router_input_empty() { - // Test empty input - should use defaults/env vars - let tokens = quote::quote!(); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_ok()); - } - - #[test] - fn test_auto_router_input_multiple_commas() { - // Test input with trailing comma - let tokens = quote::quote!(dir = "api",); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_ok()); - } - - #[test] - fn test_auto_router_input_no_comma() { - // Test input without comma between fields (should stop at second field) - let tokens = quote::quote!(dir = "api" title = "Test"); - let result: syn::Result = syn::parse2(tokens); - // This should fail or only parse first field - assert!(result.is_err()); - } - - // ========== Tests for process_vespera_input ========== - - #[test] - fn test_process_vespera_input_defaults() { - let tokens = quote::quote!(); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let processed = process_vespera_input(input); - assert_eq!(processed.folder_name, "routes"); - assert!(processed.openapi_file_names.is_empty()); - assert!(processed.title.is_none()); - assert!(processed.docs_url.is_none()); - } - - #[test] - fn test_process_vespera_input_all_fields() { - let tokens = quote::quote!( - dir = "api", - openapi = ["openapi.json", "api.json"], - title = "My API", - version = "1.0.0", - docs_url = "/docs", - redoc_url = "/redoc", - servers = "http://localhost:3000" - ); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let processed = process_vespera_input(input); - assert_eq!(processed.folder_name, "api"); - assert_eq!( - processed.openapi_file_names, - vec!["openapi.json", "api.json"] - ); - assert_eq!(processed.title, Some("My API".to_string())); - assert_eq!(processed.version, Some("1.0.0".to_string())); - assert_eq!(processed.docs_url, Some("/docs".to_string())); - assert_eq!(processed.redoc_url, Some("/redoc".to_string())); - let servers = processed.servers.unwrap(); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].url, "http://localhost:3000"); - } - - #[test] - fn test_process_vespera_input_servers_with_description() { - let tokens = quote::quote!( - servers = [{ url = "https://api.example.com", description = "Production" }] - ); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let processed = process_vespera_input(input); - let servers = processed.servers.unwrap(); - assert_eq!(servers[0].url, "https://api.example.com"); - assert_eq!(servers[0].description, Some("Production".to_string())); - } - - // ========== Tests for parse_merge_values ========== - - #[test] - fn test_parse_merge_values_single() { - let tokens = quote::quote!(merge = [some::path::App]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let merge = input.merge.unwrap(); - assert_eq!(merge.len(), 1); - // Check the path segments - let path = &merge[0]; - let segments: Vec<_> = path.segments.iter().map(|s| s.ident.to_string()).collect(); - assert_eq!(segments, vec!["some", "path", "App"]); - } - - #[test] - fn test_parse_merge_values_multiple() { - let tokens = quote::quote!(merge = [first::App, second::Other]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let merge = input.merge.unwrap(); - assert_eq!(merge.len(), 2); - } - - #[test] - fn test_parse_merge_values_empty() { - let tokens = quote::quote!(merge = []); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let merge = input.merge.unwrap(); - assert!(merge.is_empty()); - } - - #[test] - fn test_parse_merge_values_with_trailing_comma() { - let tokens = quote::quote!(merge = [app::MyApp,]); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - let merge = input.merge.unwrap(); - assert_eq!(merge.len(), 1); - } - - // ========== Tests for generate_router_code with merge ========== - - #[test] - fn test_generate_router_code_with_merge_apps() { - let metadata = CollectedMetadata::new(); - let merge_apps: Vec = vec![syn::parse_quote!(third::ThirdApp)]; - - let result = generate_router_code(&metadata, None, None, None, &merge_apps, &[]); - let code = result.to_string(); - - // Should use VesperaRouter instead of plain Router - assert!( - code.contains("VesperaRouter"), - "Should use VesperaRouter for merge, got: {code}" - ); - assert!( - code.contains("third :: ThirdApp") || code.contains("third::ThirdApp"), - "Should reference merged app, got: {code}" - ); - } - - #[test] - fn test_generate_router_code_with_docs_and_merge() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - let merge_apps: Vec = vec![syn::parse_quote!(app::MyApp)]; - - let result = generate_router_code( - &metadata, - Some("/docs"), - None, - Some(quote::quote!(#spec)), - &merge_apps, - &[], - ); - let code = result.to_string(); - - // Should have merge code for docs - assert!( - code.contains("OnceLock"), - "Should use OnceLock for merged docs, got: {code}" - ); - assert!( - code.contains("MERGED_SPEC"), - "Should have MERGED_SPEC, got: {code}" - ); - // quote! generates "merged . merge" with spaces - assert!( - code.contains("merged . merge") || code.contains("merged.merge"), - "Should call merge on spec, got: {code}" - ); - } - - #[test] - fn test_generate_router_code_with_redoc_and_merge() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - let merge_apps: Vec = vec![syn::parse_quote!(other::OtherApp)]; - - let result = generate_router_code( - &metadata, - None, - Some("/redoc"), - Some(quote::quote!(#spec)), - &merge_apps, - &[], - ); - let code = result.to_string(); - - // Should have merge code for redoc - assert!( - code.contains("OnceLock"), - "Should use OnceLock for merged redoc" - ); - assert!(code.contains("redoc"), "Should contain redoc"); - } - - #[test] - fn test_generate_router_code_with_both_docs_and_merge() { - let metadata = CollectedMetadata::new(); - let spec = r#"{"openapi":"3.1.0"}"#; - let merge_apps: Vec = vec![syn::parse_quote!(merged::App)]; - - let result = generate_router_code( - &metadata, - Some("/docs"), - Some("/redoc"), - Some(quote::quote!(#spec)), - &merge_apps, - &[], - ); - let code = result.to_string(); - - // Both docs should have merge code - // Count MERGED_SPEC occurrences - should appear in docs and redoc handlers - let merged_spec_count = code.matches("MERGED_SPEC").count(); - assert!( - merged_spec_count >= 2, - "Should have at least 2 MERGED_SPEC for docs and redoc, got: {merged_spec_count}" - ); - // __VESPERA_SPEC should appear exactly once (the const declaration) - let vespera_spec_count = code.matches("__VESPERA_SPEC").count(); - assert!( - vespera_spec_count >= 1, - "Should have __VESPERA_SPEC const, got: {vespera_spec_count}" - ); - // Both docs_url and redoc_url should be present - assert!( - code.contains("/docs") && code.contains("/redoc"), - "Should contain both /docs and /redoc" - ); - } - - #[test] - fn test_generate_router_code_with_multiple_merge_apps() { - let metadata = CollectedMetadata::new(); - let merge_apps: Vec = vec![ - syn::parse_quote!(first::App), - syn::parse_quote!(second::App), - ]; - - let result = generate_router_code(&metadata, None, None, None, &merge_apps, &[]); - let code = result.to_string(); - - // Should reference both apps - assert!( - code.contains("first") && code.contains("second"), - "Should reference both merge apps, got: {code}" - ); - } - - // ========== Tests for generate_router_code with cron jobs ========== - - #[test] - fn test_generate_router_code_with_merge_and_cron() { - let metadata = CollectedMetadata::new(); - let merge_apps: Vec = vec![syn::parse_quote!(third::ThirdApp)]; - let cron_jobs = vec![CronMetadata { - expression: "0 */5 * * * *".to_string(), - function_name: "cleanup".to_string(), - module_path: "tasks".to_string(), - file_path: "src/tasks.rs".to_string(), - }]; - - let result = generate_router_code(&metadata, None, None, None, &merge_apps, &cron_jobs); - let code = result.to_string(); - - assert!( - code.contains("VesperaRouter"), - "Should use VesperaRouter for merge, got: {code}" - ); - assert!( - code.contains("JobScheduler"), - "Should contain cron scheduler code, got: {code}" - ); - assert!( - code.contains("cleanup"), - "Should reference cron function, got: {code}" - ); - } - - #[test] - fn test_generate_router_code_with_cron_no_merge() { - let metadata = CollectedMetadata::new(); - let cron_jobs = vec![CronMetadata { - expression: "1/10 * * * * *".to_string(), - function_name: "heartbeat".to_string(), - module_path: "cron::health".to_string(), - file_path: "src/cron/health.rs".to_string(), - }]; - - let result = generate_router_code(&metadata, None, None, None, &[], &cron_jobs); - let code = result.to_string(); - - assert!( - !code.contains("VesperaRouter"), - "Should NOT use VesperaRouter without merge, got: {code}" - ); - assert!( - code.contains("JobScheduler"), - "Should contain cron scheduler code, got: {code}" - ); - assert!( - code.contains("heartbeat"), - "Should reference cron function, got: {code}" - ); - } - - // ========== Tests for ExportAppInput parsing ========== - - #[test] - fn test_export_app_input_name_only() { - let tokens = quote::quote!(MyApp); - let input: ExportAppInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.name.to_string(), "MyApp"); - assert!(input.dir.is_none()); - } - - #[test] - fn test_export_app_input_with_dir() { - let tokens = quote::quote!(MyApp, dir = "api"); - let input: ExportAppInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.name.to_string(), "MyApp"); - assert_eq!(input.dir.unwrap().value(), "api"); - } - - #[test] - fn test_export_app_input_with_trailing_comma() { - let tokens = quote::quote!(MyApp,); - let input: ExportAppInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.name.to_string(), "MyApp"); - assert!(input.dir.is_none()); - } - - #[test] - fn test_export_app_input_unknown_field() { - let tokens = quote::quote!(MyApp, unknown = "value"); - let result: syn::Result = syn::parse2(tokens); - assert!(result.is_err()); - let err = result.err().unwrap(); - assert!(err.to_compile_error().to_string().contains("unknown field")); - } - - #[test] - fn test_export_app_input_multiple_commas() { - let tokens = quote::quote!(MyApp, dir = "api",); - let input: ExportAppInput = syn::parse2(tokens).unwrap(); - assert_eq!(input.name.to_string(), "MyApp"); - assert_eq!(input.dir.unwrap().value(), "api"); - } - - // ========== Tests for env var fallbacks (lines 181-183) ========== - // Note: These tests use env vars which are global state. - // The tests are designed to be resilient to parallel test execution. - - #[test] - fn test_auto_router_input_server_env_var_fallback() { - // Test lines 181-183: VESPERA_SERVER_URL env var fallback - // This test verifies the code path but may be affected by parallel tests - // Using a unique test URL to reduce collision chances - let test_url = "https://vespera-test-unique-12345.example.com"; - let test_desc = "Vespera Test Server 12345"; - - // Save current state - let old_server_url = std::env::var("VESPERA_SERVER_URL").ok(); - let old_server_desc = std::env::var("VESPERA_SERVER_DESCRIPTION").ok(); - - // SAFETY: Single-threaded test context - unsafe { - std::env::set_var("VESPERA_SERVER_URL", test_url); - std::env::set_var("VESPERA_SERVER_DESCRIPTION", test_desc); - } - - // Parse empty input - should pick up env vars - let tokens = quote::quote!(); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); - - // Restore env vars immediately after parsing - unsafe { - if let Some(url) = old_server_url { - std::env::set_var("VESPERA_SERVER_URL", url); - } else { - std::env::remove_var("VESPERA_SERVER_URL"); - } - if let Some(desc) = old_server_desc { - std::env::set_var("VESPERA_SERVER_DESCRIPTION", desc); - } else { - std::env::remove_var("VESPERA_SERVER_DESCRIPTION"); - } - } - - // Check if servers was set - may not be if another test interfered - if let Some(servers) = input.servers { - // If we got servers, verify they match our test values - if servers.len() == 1 && servers[0].url == test_url { - assert_eq!(servers[0].description, Some(test_desc.to_string())); - } - // Otherwise another test's values were picked up, which is fine - } - // If servers is None, another test may have cleared the env var - acceptable - } - - #[test] - fn test_auto_router_input_server_env_var_invalid_url_filtered() { - // Test that invalid URLs (not http/https) are filtered out by the .filter() call - // This exercises the filter branch, not lines 181-183 directly - let old_server_url = std::env::var("VESPERA_SERVER_URL").ok(); - - // SAFETY: Single-threaded test context - unsafe { - std::env::set_var("VESPERA_SERVER_URL", "ftp://invalid-url-test.com"); - } - - let tokens = quote::quote!(); - let input: AutoRouterInput = syn::parse2(tokens).unwrap(); +//! Public API is re-exported from child modules to preserve +//! `crate::router_codegen::...` call paths. - // Restore env var - unsafe { - if let Some(url) = old_server_url { - std::env::set_var("VESPERA_SERVER_URL", url); - } else { - std::env::remove_var("VESPERA_SERVER_URL"); - } - } +mod docs; +mod export; +mod generator; +mod input; - // If servers is Some, it means another test set a valid URL - acceptable - // If servers is None, our invalid URL was correctly filtered - if let Some(servers) = &input.servers { - // Another test set a valid URL, check it's not our invalid one - assert!( - servers.is_empty() || servers[0].url != "ftp://invalid-url-test.com", - "Invalid ftp:// URL should have been filtered" - ); - } - } -} +pub use export::ExportAppInput; +pub use generator::generate_router_code; +pub use input::{AutoRouterInput, ProcessedVesperaInput, process_vespera_input}; diff --git a/crates/vespera_macro/src/router_codegen/docs.rs b/crates/vespera_macro/src/router_codegen/docs.rs new file mode 100644 index 00000000..bcab3862 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/docs.rs @@ -0,0 +1,68 @@ +use quote::quote; +use vespera_core::route::HttpMethod; + +use crate::method::http_method_to_token_stream; + +/// Swagger UI HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. +pub(super) const SWAGGER_UI_HTML: &str = r##"Swagger UI
"##; + +/// ReDoc HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. +pub(super) const REDOC_HTML: &str = r#"ReDoc
"#; + +/// Generate a documentation route handler (Swagger UI or ReDoc). +/// +/// When `has_merge` is true, the handler merges specs from child apps at runtime. +/// When false, it serves the spec directly from the compile-time constant. +pub(super) fn generate_docs_route_tokens( + url: &str, + html_template: &str, + spec_expr: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let method_path = http_method_to_token_stream(HttpMethod::Get); + + quote!( + .route(#url, #method_path(|| async { + static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); + let html = HTML.get_or_init(|| { + format!(#html_template, #spec_expr) + }); + vespera::axum::response::Html(html.as_str()) + })) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_swagger_html_template_renders_valid_quotes() { + assert!( + !SWAGGER_UI_HTML.contains(r#"\""#), + "Swagger template should not contain literal backslash-quotes: {SWAGGER_UI_HTML}" + ); + assert!( + SWAGGER_UI_HTML.contains(r#"href="https://unpkg.com/swagger-ui-dist/swagger-ui.css""#) + ); + assert!( + SWAGGER_UI_HTML + .contains(r#"src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js""#) + ); + assert!(SWAGGER_UI_HTML.contains(r##"dom_id: "#swagger-ui""##)); + } + + #[test] + fn test_redoc_html_template_renders_valid_quotes() { + assert!( + !REDOC_HTML.contains(r#"\""#), + "ReDoc template should not contain literal backslash-quotes: {REDOC_HTML}" + ); + assert!( + REDOC_HTML.contains(r#"href="https://unpkg.com/redoc/bundles/redoc.standalone.css""#) + ); + assert!( + REDOC_HTML.contains(r#"src="https://unpkg.com/redoc/bundles/redoc.standalone.js""#) + ); + assert!(REDOC_HTML.contains(r#"document.getElementById("redoc-container")"#)); + } +} diff --git a/crates/vespera_macro/src/router_codegen/export.rs b/crates/vespera_macro/src/router_codegen/export.rs new file mode 100644 index 00000000..e3aaf763 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/export.rs @@ -0,0 +1,119 @@ +use syn::{ + LitStr, + parse::{Parse, ParseStream}, +}; + +/// Input for `export_app`! macro +pub struct ExportAppInput { + /// App name (struct name to generate) + pub name: syn::Ident, + /// Route directory + pub dir: Option, +} + +impl Parse for ExportAppInput { + fn parse(input: ParseStream) -> syn::Result { + let name: syn::Ident = input.parse()?; + + let mut dir = None; + + // Parse optional comma and arguments + while input.peek(syn::Token![,]) { + input.parse::()?; + + if input.is_empty() { + break; + } + + let ident: syn::Ident = input.parse()?; + let ident_str = ident.to_string(); + + match ident_str.as_str() { + "dir" => { + // Reject a repeated `dir` with a spanned error instead of + // silently letting the later value overwrite the earlier + // one — matches the `vespera!` arg parser's duplicate guard. + if dir.is_some() { + return Err(syn::Error::new( + ident.span(), + "duplicate field `dir` in export_app! macro", + )); + } + input.parse::()?; + dir = Some(input.parse()?); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!("unknown field: `{ident_str}`. Expected `dir`"), + )); + } + } + } + + Ok(Self { name, dir }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_export_app_input_name_only() { + let tokens = quote::quote!(MyApp); + let input: ExportAppInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.name.to_string(), "MyApp"); + assert!(input.dir.is_none()); + } + + #[test] + fn test_export_app_input_with_dir() { + let tokens = quote::quote!(MyApp, dir = "api"); + let input: ExportAppInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.name.to_string(), "MyApp"); + assert_eq!(input.dir.unwrap().value(), "api"); + } + + #[test] + fn test_export_app_input_with_trailing_comma() { + let tokens = quote::quote!(MyApp,); + let input: ExportAppInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.name.to_string(), "MyApp"); + assert!(input.dir.is_none()); + } + + #[test] + fn test_export_app_input_unknown_field() { + let tokens = quote::quote!(MyApp, unknown = "value"); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + let err = result.err().unwrap(); + assert!(err.to_compile_error().to_string().contains("unknown field")); + } + + #[test] + fn test_export_app_input_multiple_commas() { + let tokens = quote::quote!(MyApp, dir = "api",); + let input: ExportAppInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.name.to_string(), "MyApp"); + assert_eq!(input.dir.unwrap().value(), "api"); + } + + #[test] + fn test_export_app_input_duplicate_dir() { + // A repeated `dir` must be a spanned compile error, not a silent + // last-wins overwrite. + let tokens = quote::quote!(MyApp, dir = "api", dir = "other"); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err(), "duplicate `dir` must be rejected"); + assert!( + result + .err() + .unwrap() + .to_compile_error() + .to_string() + .contains("duplicate field `dir`") + ); + } +} diff --git a/crates/vespera_macro/src/router_codegen/generator.rs b/crates/vespera_macro/src/router_codegen/generator.rs new file mode 100644 index 00000000..201cd70d --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/generator.rs @@ -0,0 +1,267 @@ +use proc_macro2::Span; +use quote::quote; +use vespera_core::route::HttpMethod; + +use crate::{ + metadata::{CollectedMetadata, CronMetadata}, + method::http_method_to_token_stream, +}; + +use super::docs::{REDOC_HTML, SWAGGER_UI_HTML, generate_docs_route_tokens}; + +/// Generate cron scheduler spawn code from collected cron metadata. +fn generate_cron_scheduler_code(cron_jobs: &[CronMetadata]) -> proc_macro2::TokenStream { + if cron_jobs.is_empty() { + return quote!(); + } + + let job_additions: Vec = cron_jobs + .iter() + .map(|cron| { + let expression = &cron.expression; + let module_path = &cron.module_path; + let function_name = &cron.function_name; + + // Build the full path: crate::module::function + let mut p: syn::punctuated::Punctuated = + syn::punctuated::Punctuated::new(); + p.push(syn::PathSegment { + ident: syn::Ident::new("crate", Span::call_site()), + arguments: syn::PathArguments::None, + }); + p.extend(module_path.split("::").filter_map(|s| { + if s.is_empty() { + None + } else { + Some(syn::PathSegment { + ident: syn::Ident::new(s, Span::call_site()), + arguments: syn::PathArguments::None, + }) + } + })); + let func_ident = syn::Ident::new(function_name, Span::call_site()); + + let err_create = format!("vespera: failed to create cron job '{function_name}'"); + let err_add = format!("vespera: failed to add cron job '{function_name}'"); + + quote! { + // A cron expression is compile-time validated (vespera_macro/cron + // feature), so `new_async` failing here is a runtime library/env + // condition, not user error — log it and skip THIS job rather than + // `.expect()`-panicking the whole scheduler task (which tokio would + // swallow as a silent `JoinError`, hiding the failure entirely). + match vespera::tokio_cron_scheduler::Job::new_async(#expression, |_uuid, _l| { + Box::pin(async move { + #p::#func_ident().await; + }) + }) { + Ok(__vespera_job) => { + if let Err(__vespera_err) = + __vespera_cron_scheduler.add(__vespera_job).await + { + eprintln!("{}: {__vespera_err}", #err_add); + } + } + Err(__vespera_err) => eprintln!("{}: {__vespera_err}", #err_create), + } + } + }) + .collect(); + + quote! { + vespera::tokio::spawn(async move { + // Scheduler setup runs in a detached `tokio::spawn` task: a panic here + // would be swallowed as a silent `JoinError`, so log + bail instead of + // `.expect()` so a scheduler-init failure is observable, not invisible. + let mut __vespera_cron_scheduler = + match vespera::tokio_cron_scheduler::JobScheduler::new().await { + Ok(__vespera_sched) => __vespera_sched, + Err(__vespera_err) => { + eprintln!("vespera: failed to create cron scheduler: {__vespera_err}"); + return; + } + }; + #(#job_additions)* + if let Err(__vespera_err) = __vespera_cron_scheduler.start().await { + eprintln!("vespera: failed to start cron scheduler: {__vespera_err}"); + return; + } + // Keep the scheduler alive for the process lifetime. + ::std::future::pending::<()>().await; + }); + } +} + +/// Generate Axum router code from collected metadata +#[allow(clippy::too_many_lines)] +pub fn generate_router_code( + metadata: &CollectedMetadata, + docs_url: Option<&str>, + redoc_url: Option<&str>, + spec_tokens: Option, + merge_apps: &[syn::Path], + cron_jobs: &[CronMetadata], +) -> proc_macro2::TokenStream { + let mut router_nests = Vec::new(); + + for route in &metadata.routes { + let Ok(http_method) = HttpMethod::try_from(route.method.as_str()) else { + let message = format!( + "vespera: route '{}' has unsupported HTTP method '{}'. Supported methods are GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS.", + route.path, route.method + ); + router_nests.push(syn::Error::new(Span::call_site(), message).to_compile_error()); + continue; + }; + let method_path = http_method_to_token_stream(http_method); + let path = &route.path; + let module_path = &route.module_path; + let function_name = &route.function_name; + + let mut p: syn::punctuated::Punctuated = + syn::punctuated::Punctuated::new(); + p.push(syn::PathSegment { + ident: syn::Ident::new("crate", Span::call_site()), + arguments: syn::PathArguments::None, + }); + p.extend(module_path.split("::").filter_map(|s| { + if s.is_empty() { + None + } else { + Some(syn::PathSegment { + ident: syn::Ident::new(s, Span::call_site()), + arguments: syn::PathArguments::None, + }) + } + })); + let func_name = syn::Ident::new(function_name, Span::call_site()); + router_nests.push(quote!( + .route(#path, #method_path(#p::#func_name)) + )); + } + + // Check if we need to merge specs at runtime + let has_merge = !merge_apps.is_empty(); + + // Generate merge code once, reuse in both docs_url and redoc_url routes + let merge_spec_code: Vec<_> = merge_apps + .iter() + .map(|app_path| { + quote! { + if let Ok(other) = vespera::serde_json::from_str::(#app_path::OPENAPI_SPEC) { + merged.merge(other); + } + } + }) + .collect(); + + let docs_spec_expr = if has_merge { + quote! { __vespera_merged_spec() } + } else { + quote! { __VESPERA_SPEC } + }; + + if let Some(docs_url) = docs_url { + router_nests.push(generate_docs_route_tokens( + docs_url, + SWAGGER_UI_HTML, + &docs_spec_expr, + )); + } + + if let Some(redoc_url) = redoc_url { + router_nests.push(generate_docs_route_tokens( + redoc_url, + REDOC_HTML, + &docs_spec_expr, + )); + } + + let needs_spec_const = spec_tokens.is_some() && (docs_url.is_some() || redoc_url.is_some()); + let cron_code = generate_cron_scheduler_code(cron_jobs); + + if needs_spec_const { + let spec_expr = spec_tokens.unwrap(); + if merge_apps.is_empty() { + quote! { + { + const __VESPERA_SPEC: &str = #spec_expr; + #cron_code + vespera::axum::Router::new() + #( #router_nests )* + } + } + } else { + quote! { + { + const __VESPERA_SPEC: &str = #spec_expr; + fn __vespera_merged_spec() -> &'static str { + static MERGED_SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); + MERGED_SPEC.get_or_init(|| { + // The base spec is Vespera-generated and expected to parse; on the + // unreachable drift where parse or re-serialization fails, fall back + // to serving the un-merged base spec instead of panicking inside this + // request handler — the docs page still renders. + let Ok(mut merged) = + vespera::serde_json::from_str::(__VESPERA_SPEC) + else { + return __VESPERA_SPEC.to_string(); + }; + #(#merge_spec_code)* + vespera::serde_json::to_string(&merged) + .unwrap_or_else(|_| __VESPERA_SPEC.to_string()) + }) + } + #cron_code + vespera::VesperaRouter::new( + vespera::axum::Router::new() + #( #router_nests )*, + vec![#( #merge_apps::router ),*] + ) + } + } + } + } else if merge_apps.is_empty() { + if cron_jobs.is_empty() { + quote! { + vespera::axum::Router::new() + #( #router_nests )* + } + } else { + quote! { + { + #cron_code + vespera::axum::Router::new() + #( #router_nests )* + } + } + } + } else { + // When merging apps, return VesperaRouter which defers the merge + // until with_state() is called. This is necessary because Axum requires + // merged routers to have the same state type. + if cron_jobs.is_empty() { + quote! { + vespera::VesperaRouter::new( + vespera::axum::Router::new() + #( #router_nests )*, + vec![#( #merge_apps::router ),*] + ) + } + } else { + quote! { + { + #cron_code + vespera::VesperaRouter::new( + vespera::axum::Router::new() + #( #router_nests )*, + vec![#( #merge_apps::router ),*] + ) + } + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/vespera_macro/src/router_codegen/generator/tests.rs b/crates/vespera_macro/src/router_codegen/generator/tests.rs new file mode 100644 index 00000000..bb12609c --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/generator/tests.rs @@ -0,0 +1,796 @@ +use std::fs; + +use rstest::rstest; +use tempfile::TempDir; + +use super::*; +use crate::collector::collect_metadata; + +fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = dir.path().join(filename); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path +} + +#[test] +fn test_generate_router_code_empty() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Should generate empty router + // quote! generates "vespera :: axum :: Router :: new ()" format + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {code}" + ); + assert!( + !code.contains("route"), + "Code should not contain route, got: {code}" + ); + + drop(temp_dir); +} + +#[rstest] +#[case::single_get_route( + "routes", + vec![( + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + )], + "get", + "/users", + "routes::users::get_users", +)] +#[case::single_post_route( + "routes", + vec![( + "create_user.rs", + r#" +#[route(post)] +pub fn create_user() -> String { +"created".to_string() +} +"#, + )], + "post", + "/create-user", + "routes::create_user::create_user", +)] +#[case::single_put_route( + "routes", + vec![( + "update_user.rs", + r#" +#[route(put)] +pub fn update_user() -> String { +"updated".to_string() +} +"#, + )], + "put", + "/update-user", + "routes::update_user::update_user", +)] +#[case::single_delete_route( + "routes", + vec![( + "delete_user.rs", + r#" +#[route(delete)] +pub fn delete_user() -> String { +"deleted".to_string() +} +"#, + )], + "delete", + "/delete-user", + "routes::delete_user::delete_user", +)] +#[case::single_patch_route( + "routes", + vec![( + "patch_user.rs", + r#" +#[route(patch)] +pub fn patch_user() -> String { +"patched".to_string() +} +"#, + )], + "patch", + "/patch-user", + "routes::patch_user::patch_user", +)] +#[case::route_with_custom_path( + "routes", + vec![( + "users.rs", + r#" +#[route(get, path = "/api/users")] +pub fn get_users() -> String { +"users".to_string() +} +"#, + )], + "get", + "/users/api/users", + "routes::users::get_users", +)] +#[case::nested_module( + "routes", + vec![( + "api/users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + )], + "get", + "/api/users", + "routes::api::users::get_users", +)] +#[case::deeply_nested_module( + "routes", + vec![( + "api/v1/users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + )], + "get", + "/api/v1/users", + "routes::api::v1::users::get_users", +)] +fn test_generate_router_code_single_route( + #[case] folder_name: &str, + #[case] files: Vec<(&str, &str)>, + #[case] expected_method: &str, + #[case] expected_path: &str, + #[case] expected_function_path: &str, +) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + for (filename, content) in files { + create_temp_file(&temp_dir, filename, content); + } + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {code}" + ); + + // Check route method + assert!( + code.contains(expected_method), + "Code should contain method: {expected_method}, got: {code}" + ); + + // Check route path + assert!( + code.contains(expected_path), + "Code should contain path: {expected_path}, got: {code}" + ); + + // Check function path (quote! adds spaces, so we check for parts) + let function_parts: Vec<&str> = expected_function_path.split("::").collect(); + for part in &function_parts { + if !part.is_empty() { + assert!( + code.contains(part), + "Code should contain function part: {part}, got: {code}" + ); + } + } + + drop(temp_dir); +} + +#[test] +fn test_generate_router_code_multiple_routes() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create multiple route files + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + ); + + create_temp_file( + &temp_dir, + "create_user.rs", + r#" +#[route(post)] +pub fn create_user() -> String { +"created".to_string() +} +"#, + ); + + create_temp_file( + &temp_dir, + "update_user.rs", + r#" +#[route(put)] +pub fn update_user() -> String { +"updated".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check all routes are present + assert!(code.contains("get_users")); + assert!(code.contains("create_user")); + assert!(code.contains("update_user")); + + // Check methods + assert!(code.contains("get")); + assert!(code.contains("post")); + assert!(code.contains("put")); + + // Count route calls (quote! generates ". route (" with spaces) + // Count occurrences of ". route (" pattern + let route_count = code.matches(". route (").count(); + assert_eq!( + route_count, 3, + "Should have 3 route calls, got: {route_count}, code: {code}" + ); + + drop(temp_dir); +} + +#[test] +fn test_generate_router_code_same_path_different_methods() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create routes with same path but different methods + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} + +#[route(post)] +pub fn create_users() -> String { +"created".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check both routes are present + assert!(code.contains("get_users")); + assert!(code.contains("create_users")); + + // Check methods + assert!(code.contains("get")); + assert!(code.contains("post")); + + // Should have 2 routes (quote! generates ". route (" with spaces) + let route_count = code.matches(". route (").count(); + assert_eq!( + route_count, 2, + "Should have 2 routes, got: {route_count}, code: {code}" + ); + + drop(temp_dir); +} + +#[test] +fn test_generate_router_code_with_mod_rs() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create mod.rs file + create_temp_file( + &temp_dir, + "mod.rs", + r#" +#[route(get)] +pub fn index() -> String { +"index".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check route is present + assert!(code.contains("index")); + + // Path should be / (mod.rs maps to root, segments is empty) + // quote! generates "\"/\"" + assert!(code.contains("\"/\"")); + + drop(temp_dir); +} + +#[test] +fn test_generate_router_code_empty_folder_name() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = ""; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + ); + + let result = generate_router_code( + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, + None, + None, + None, + &[], + &[], + ); + let code = result.to_string(); + + // Check router initialization (quote! generates "vespera :: axum :: Router :: new ()") + assert!(code.contains("Router") && code.contains("new")); + + // Check route is present + assert!(code.contains("get_users")); + + // Module path should not have double colons + assert!(!code.contains("::users::users")); + + drop(temp_dir); +} + +#[test] +fn test_generate_router_code_with_docs() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = generate_router_code( + &metadata, + Some("/docs"), + None, + Some(quote::quote!(#spec)), + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("/docs")); + assert!(code.contains("swagger-ui")); + assert!(code.contains("__VESPERA_SPEC")); + assert!(code.contains("OnceLock")); +} + +#[test] +fn test_generate_router_code_with_redoc() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = generate_router_code( + &metadata, + None, + Some("/redoc"), + Some(quote::quote!(#spec)), + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("/redoc")); + assert!(code.contains("redoc")); + assert!(code.contains("__VESPERA_SPEC")); + assert!(code.contains("OnceLock")); +} + +#[test] +fn test_generate_router_code_with_both_docs() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = generate_router_code( + &metadata, + Some("/docs"), + Some("/redoc"), + Some(quote::quote!(#spec)), + &[], + &[], + ); + let code = result.to_string(); + + assert!(code.contains("/docs")); + assert!(code.contains("/redoc")); + assert!(code.contains("__VESPERA_SPEC")); +} + +#[test] +fn test_generate_router_code_unknown_http_method() { + // Unknown methods surface as compile_error! instead of stderr-only skips. + let mut metadata = CollectedMetadata { + routes: Vec::new(), + structs: Vec::new(), + crons: Vec::new(), + }; + metadata.routes.push(crate::metadata::RouteMetadata { + method: "INVALID".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes::users".to_string(), + file_path: "dummy.rs".to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + }); + + let result = generate_router_code(&metadata, None, None, None, &[], &[]); + let code = result.to_string(); + + assert!( + code.contains("compile_error"), + "Invalid method should produce compile_error!, got: {code}" + ); + assert!( + code.contains("unsupported HTTP method"), + "Diagnostic should mention invalid method, got: {code}" + ); + + // Router should still be generated but without any invalid route calls. + assert!( + code.contains("Router") && code.contains("new"), + "Code should contain Router::new(), got: {code}" + ); + assert!( + !code.contains(". route ("), + "Route with unknown HTTP method should be skipped, got: {code}" + ); +} + +#[test] +fn test_generate_router_code_unknown_method_skipped_valid_kept() { + // Test that unknown methods produce compile_error while valid routes are still generated. + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { +"users".to_string() +} +"#, + ); + + let (mut metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); + // Inject an additional route with invalid method + metadata.routes.push(crate::metadata::RouteMetadata { + method: "CONNECT".to_string(), + path: "/invalid".to_string(), + function_name: "connect_handler".to_string(), + module_path: "routes::invalid".to_string(), + file_path: "dummy.rs".to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + }); + + let result = generate_router_code(&metadata, None, None, None, &[], &[]); + let code = result.to_string(); + + // Valid route should be present + assert!( + code.contains("get_users"), + "Valid route should be present, got: {code}" + ); + assert!( + code.contains("compile_error"), + "Invalid method should produce compile_error!, got: {code}" + ); + // Invalid route should not be emitted as an axum route. + assert!( + !code.contains("connect_handler"), + "Invalid method route should be skipped, got: {code}" + ); + + drop(temp_dir); +} + +#[test] +fn test_generate_router_code_with_merge_apps() { + let metadata = CollectedMetadata::new(); + let merge_apps: Vec = vec![syn::parse_quote!(third::ThirdApp)]; + + let result = generate_router_code(&metadata, None, None, None, &merge_apps, &[]); + let code = result.to_string(); + + // Should use VesperaRouter instead of plain Router + assert!( + code.contains("VesperaRouter"), + "Should use VesperaRouter for merge, got: {code}" + ); + assert!( + code.contains("third :: ThirdApp") || code.contains("third::ThirdApp"), + "Should reference merged app, got: {code}" + ); +} + +#[test] +fn test_generate_router_code_with_docs_and_merge() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + let merge_apps: Vec = vec![syn::parse_quote!(app::MyApp)]; + + let result = generate_router_code( + &metadata, + Some("/docs"), + None, + Some(quote::quote!(#spec)), + &merge_apps, + &[], + ); + let code = result.to_string(); + + // Should have merge code for docs + assert!( + code.contains("OnceLock"), + "Should use OnceLock for merged docs, got: {code}" + ); + assert!( + code.contains("MERGED_SPEC"), + "Should have MERGED_SPEC, got: {code}" + ); + // quote! generates "merged . merge" with spaces + assert!( + code.contains("merged . merge") || code.contains("merged.merge"), + "Should call merge on spec, got: {code}" + ); +} + +#[test] +fn test_generate_router_code_with_redoc_and_merge() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + let merge_apps: Vec = vec![syn::parse_quote!(other::OtherApp)]; + + let result = generate_router_code( + &metadata, + None, + Some("/redoc"), + Some(quote::quote!(#spec)), + &merge_apps, + &[], + ); + let code = result.to_string(); + + // Should have merge code for redoc + assert!( + code.contains("OnceLock"), + "Should use OnceLock for merged redoc" + ); + assert!(code.contains("redoc"), "Should contain redoc"); +} + +#[test] +fn test_generate_router_code_with_both_docs_and_merge() { + let metadata = CollectedMetadata::new(); + let spec = r#"{"openapi":"3.1.0"}"#; + let merge_apps: Vec = vec![syn::parse_quote!(merged::App)]; + + let result = generate_router_code( + &metadata, + Some("/docs"), + Some("/redoc"), + Some(quote::quote!(#spec)), + &merge_apps, + &[], + ); + let code = result.to_string(); + + // Both docs should have merge code + // Count MERGED_SPEC occurrences - should appear in docs and redoc handlers + let merged_spec_count = code.matches("MERGED_SPEC").count(); + assert!( + merged_spec_count >= 2, + "Should have at least 2 MERGED_SPEC for docs and redoc, got: {merged_spec_count}" + ); + // __VESPERA_SPEC should appear exactly once (the const declaration) + let vespera_spec_count = code.matches("__VESPERA_SPEC").count(); + assert!( + vespera_spec_count >= 1, + "Should have __VESPERA_SPEC const, got: {vespera_spec_count}" + ); + // Both docs_url and redoc_url should be present + assert!( + code.contains("/docs") && code.contains("/redoc"), + "Should contain both /docs and /redoc" + ); +} + +#[test] +fn test_generate_router_code_with_multiple_merge_apps() { + let metadata = CollectedMetadata::new(); + let merge_apps: Vec = vec![ + syn::parse_quote!(first::App), + syn::parse_quote!(second::App), + ]; + + let result = generate_router_code(&metadata, None, None, None, &merge_apps, &[]); + let code = result.to_string(); + + // Should reference both apps + assert!( + code.contains("first") && code.contains("second"), + "Should reference both merge apps, got: {code}" + ); +} + +// ========== Tests for generate_router_code with cron jobs ========== + +#[test] +fn test_generate_router_code_with_merge_and_cron() { + let metadata = CollectedMetadata::new(); + let merge_apps: Vec = vec![syn::parse_quote!(third::ThirdApp)]; + let cron_jobs = vec![CronMetadata { + expression: "0 */5 * * * *".to_string(), + function_name: "cleanup".to_string(), + module_path: "tasks".to_string(), + file_path: "src/tasks.rs".to_string(), + }]; + + let result = generate_router_code(&metadata, None, None, None, &merge_apps, &cron_jobs); + let code = result.to_string(); + + assert!( + code.contains("VesperaRouter"), + "Should use VesperaRouter for merge, got: {code}" + ); + assert!( + code.contains("JobScheduler"), + "Should contain cron scheduler code, got: {code}" + ); + assert!( + code.contains("cleanup"), + "Should reference cron function, got: {code}" + ); +} + +#[test] +fn test_generate_router_code_with_cron_no_merge() { + let metadata = CollectedMetadata::new(); + let cron_jobs = vec![CronMetadata { + expression: "1/10 * * * * *".to_string(), + function_name: "heartbeat".to_string(), + module_path: "cron::health".to_string(), + file_path: "src/cron/health.rs".to_string(), + }]; + + let result = generate_router_code(&metadata, None, None, None, &[], &cron_jobs); + let code = result.to_string(); + + assert!( + !code.contains("VesperaRouter"), + "Should NOT use VesperaRouter without merge, got: {code}" + ); + assert!( + code.contains("JobScheduler"), + "Should contain cron scheduler code, got: {code}" + ); + assert!( + code.contains("heartbeat"), + "Should reference cron function, got: {code}" + ); +} diff --git a/crates/vespera_macro/src/router_codegen/input.rs b/crates/vespera_macro/src/router_codegen/input.rs new file mode 100644 index 00000000..c1427908 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/input.rs @@ -0,0 +1,742 @@ +use proc_macro2::Span; +use std::collections::{BTreeMap, HashMap, HashSet}; +use syn::{ + LitStr, bracketed, + parse::{Parse, ParseStream}, + punctuated::Punctuated, +}; +use vespera_core::openapi::Server; +use vespera_core::schema::{SecurityScheme, SecuritySchemeType}; + +/// Server configuration for `OpenAPI` +#[derive(Clone)] +pub struct ServerConfig { + pub url: String, + pub description: Option, +} + +/// Security scheme configuration for `OpenAPI` components. +#[derive(Clone)] +pub struct SecuritySchemeConfig { + pub name: String, + pub scheme: SecurityScheme, +} + +/// Top-level OpenAPI tag configuration from `vespera!(tags = [...])`. +#[derive(Clone)] +pub struct TagConfig { + pub name: String, + pub description: Option, +} + +/// Input for the `vespera!` macro +pub struct AutoRouterInput { + pub dir: Option, + pub openapi: Option>, + pub title: Option, + pub version: Option, + pub docs_url: Option, + pub redoc_url: Option, + pub servers: Option>, + pub security_schemes: Option>, + pub security: Option>, + pub tags: Option>, + /// Apps to merge (e.g., [`third::ThirdApp`, `another::AnotherApp`]) + pub merge: Option>, +} + +impl Parse for AutoRouterInput { + #[allow(clippy::too_many_lines)] + fn parse(input: ParseStream) -> syn::Result { + let mut dir = None; + let mut openapi = None; + let mut title = None; + let mut version = None; + let mut docs_url = None; + let mut redoc_url = None; + let mut servers = None; + let mut security_schemes = None; + let mut security = None; + let mut tags = None; + let mut merge = None; + // Reject a repeated named argument (e.g. `title = ..., title = ...`) + // with a spanned error instead of silently letting the later value + // overwrite the earlier one — a typo would otherwise build a spec that + // does not match the source. + let mut seen_fields = HashSet::::new(); + + while !input.is_empty() { + let lookahead = input.lookahead1(); + + if lookahead.peek(syn::Ident) { + let ident: syn::Ident = input.parse()?; + let ident_str = ident.to_string(); + if !seen_fields.insert(ident_str.clone()) { + return Err(syn::Error::new( + ident.span(), + format!("duplicate field `{ident_str}` in vespera! macro"), + )); + } + + match ident_str.as_str() { + "dir" => { + input.parse::()?; + dir = Some(input.parse()?); + } + "openapi" => { + openapi = Some(parse_openapi_values(input)?); + } + "docs_url" => { + input.parse::()?; + docs_url = Some(input.parse()?); + } + "redoc_url" => { + input.parse::()?; + redoc_url = Some(input.parse()?); + } + "title" => { + input.parse::()?; + title = Some(input.parse()?); + } + "version" => { + input.parse::()?; + version = Some(input.parse()?); + } + "servers" => { + servers = Some(parse_servers_values(input)?); + } + "security_schemes" => { + security_schemes = Some(parse_security_scheme_values(input)?); + } + "security" => { + security = Some(parse_security_values(input)?); + } + "tags" => { + tags = Some(parse_tag_values(input)?); + } + "merge" => { + merge = Some(parse_merge_values(input)?); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!( + "unknown field: `{ident_str}`. Expected `dir`, `openapi`, `title`, `version`, `docs_url`, `redoc_url`, `servers`, `security_schemes`, `security`, `tags`, or `merge`" + ), + )); + } + } + } else if lookahead.peek(syn::LitStr) { + // If just a string, treat it as dir (for backward compatibility) + dir = Some(input.parse()?); + } else { + return Err(lookahead.error()); + } + + if input.peek(syn::Token![,]) { + input.parse::()?; + } else { + break; + } + } + + Ok(Self { + dir: dir.or_else(|| { + std::env::var("VESPERA_DIR") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + openapi: openapi.or_else(|| { + std::env::var("VESPERA_OPENAPI") + .map(|f| vec![LitStr::new(&f, Span::call_site())]) + .ok() + }), + title: title.or_else(|| { + std::env::var("VESPERA_TITLE") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + version: version + .or_else(|| { + std::env::var("VESPERA_VERSION") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }) + .or_else(|| { + std::env::var("CARGO_PKG_VERSION") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + docs_url: docs_url.or_else(|| { + std::env::var("VESPERA_DOCS_URL") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + redoc_url: redoc_url.or_else(|| { + std::env::var("VESPERA_REDOC_URL") + .map(|f| LitStr::new(&f, Span::call_site())) + .ok() + }), + servers: servers.or_else(|| { + std::env::var("VESPERA_SERVER_URL") + .ok() + .filter(|url| url.starts_with("http://") || url.starts_with("https://")) + .map(|url| { + vec![ServerConfig { + url, + description: std::env::var("VESPERA_SERVER_DESCRIPTION").ok(), + }] + }) + }), + security_schemes, + security, + tags, + merge, + }) + } +} + +fn parse_tag_values(input: ParseStream) -> syn::Result> { + input.parse::()?; + + let content; + let _ = bracketed!(content in input); + let mut tags = Vec::new(); + + while !content.is_empty() { + tags.push(parse_tag_struct(&content)?); + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + Ok(tags) +} + +fn parse_tag_struct(input: ParseStream) -> syn::Result { + let content; + syn::braced!(content in input); + + let mut name: Option = None; + let mut description: Option = None; + // Reject a repeated tag field (e.g. `name = ..., name = ...`) with a + // spanned error instead of silently letting the later value overwrite the + // earlier one — matches the top-level `vespera!` arg parser and + // `parse_security_scheme_struct`. + let mut seen_fields = HashSet::::new(); + + while !content.is_empty() { + let ident: syn::Ident = content.parse()?; + let ident_str = ident.to_string(); + if !seen_fields.insert(ident_str.clone()) { + return Err(syn::Error::new( + ident.span(), + format!("duplicate tag field: `{ident_str}`"), + )); + } + content.parse::()?; + let value: LitStr = content.parse()?; + + match ident_str.as_str() { + "name" => name = Some(value.value()), + "description" => description = Some(value.value()), + _ => { + return Err(syn::Error::new( + ident.span(), + format!("unknown tag field: `{ident_str}`. Expected `name` or `description`"), + )); + } + } + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + let name = name.ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + "vespera! macro: tag configuration missing required `name` field.", + ) + })?; + + Ok(TagConfig { name, description }) +} + +fn parse_security_values(input: ParseStream) -> syn::Result> { + input.parse::()?; + + let content; + let _ = bracketed!(content in input); + let entries: Punctuated = + content.parse_terminated(syn::parse::ParseBuffer::parse::, syn::Token![,])?; + Ok(entries.into_iter().map(|entry| entry.value()).collect()) +} + +fn security_requirements(schemes: Vec) -> Vec>> { + schemes + .into_iter() + .map(|scheme| BTreeMap::from([(scheme, Vec::new())])) + .collect() +} + +fn parse_security_scheme_values(input: ParseStream) -> syn::Result> { + input.parse::()?; + + let content; + let _ = bracketed!(content in input); + let mut schemes = Vec::new(); + + while !content.is_empty() { + schemes.push(parse_security_scheme_struct(&content)?); + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + Ok(schemes) +} + +fn parse_security_scheme_struct(input: ParseStream) -> syn::Result { + let content; + syn::braced!(content in input); + + let mut name: Option = None; + let mut scheme_type: Option = None; + let mut description: Option = None; + let mut header_name: Option = None; + let mut location: Option = None; + let mut scheme: Option = None; + let mut bearer_format: Option = None; + let mut open_id_connect_url: Option = None; + let mut seen_fields = HashSet::::new(); + + while !content.is_empty() { + let (field_name, span) = parse_security_field_name(&content)?; + if !seen_fields.insert(field_name.clone()) { + return Err(syn::Error::new( + span, + format!("duplicate security scheme field: `{field_name}`"), + )); + } + content.parse::()?; + let value: LitStr = content.parse()?; + + match field_name.as_str() { + "name" => name = Some(value.value()), + "type" => scheme_type = Some(parse_security_scheme_type(&value)?), + "description" => description = Some(value.value()), + "header_name" => header_name = Some(value.value()), + "in" => location = Some(value.value()), + "scheme" => scheme = Some(value.value()), + "bearer_format" => bearer_format = Some(value.value()), + "open_id_connect_url" => open_id_connect_url = Some(value.value()), + _ => { + return Err(syn::Error::new( + span, + format!( + "unknown security scheme field: `{field_name}`. Expected `name`, `type`, `description`, `header_name`, `in`, `scheme`, `bearer_format`, or `open_id_connect_url`" + ), + )); + } + } + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + let name = name.ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + "vespera! macro: security scheme missing required `name` field.", + ) + })?; + let scheme_type = scheme_type.ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + "vespera! macro: security scheme missing required `type` field.", + ) + })?; + // Type-specific OpenAPI validity: reject an under-specified scheme at + // compile time instead of silently emitting a spec that violates the + // OpenAPI Security Scheme Object requirements. + validate_security_scheme_fields( + &name, + scheme_type, + location.as_deref(), + header_name.as_deref(), + scheme.as_deref(), + open_id_connect_url.as_deref(), + )?; + + Ok(SecuritySchemeConfig { + name, + scheme: SecurityScheme { + r#type: scheme_type, + description, + name: header_name, + r#in: location, + scheme, + bearer_format, + flows: None, + open_id_connect_url, + }, + }) +} + +/// Validate that a security scheme carries the fields OpenAPI requires for +/// its `type`, so `vespera!` never emits a structurally-invalid +/// `components.securitySchemes` entry. +/// +/// - `apiKey` → `header_name` (the api-key `name`) + `in` ∈ {query, header, cookie} +/// - `http` → `scheme` +/// - `openIdConnect` → `open_id_connect_url` +/// - `oauth2` → requires `flows`, which the DSL does not yet parse → rejected +/// with an explicit message (better than emitting an `oauth2` scheme with no +/// flows, which is invalid) +/// - `mutualTLS` → no extra required fields +fn validate_security_scheme_fields( + name: &str, + scheme_type: SecuritySchemeType, + location: Option<&str>, + header_name: Option<&str>, + scheme: Option<&str>, + open_id_connect_url: Option<&str>, +) -> syn::Result<()> { + let span = proc_macro2::Span::call_site(); + let missing = |field: &str, hint: &str| { + syn::Error::new( + span, + format!( + "vespera! macro: security scheme `{name}` of type `{}` is missing required field `{field}` ({hint})", + scheme_type_label(scheme_type) + ), + ) + }; + match scheme_type { + SecuritySchemeType::ApiKey => { + if header_name.is_none() { + return Err(missing("header_name", "the api-key parameter name")); + } + match location { + None => return Err(missing("in", "one of \"query\", \"header\", or \"cookie\"")), + Some(loc) if !matches!(loc, "query" | "header" | "cookie") => { + return Err(syn::Error::new( + span, + format!( + "vespera! macro: security scheme `{name}` has invalid `in` value `{loc}`; expected \"query\", \"header\", or \"cookie\"" + ), + )); + } + Some(_) => {} + } + } + SecuritySchemeType::Http => { + if scheme.is_none() { + return Err(missing("scheme", "e.g. \"bearer\" or \"basic\"")); + } + } + SecuritySchemeType::OpenIdConnect => { + if open_id_connect_url.is_none() { + return Err(missing( + "open_id_connect_url", + "the OpenID Connect discovery URL", + )); + } + } + SecuritySchemeType::OAuth2 => { + return Err(syn::Error::new( + span, + format!( + "vespera! macro: security scheme `{name}` of type `oauth2` requires `flows`, which the vespera! security_schemes DSL does not yet support" + ), + )); + } + SecuritySchemeType::MutualTls => {} + } + Ok(()) +} + +/// OpenAPI wire label for a [`SecuritySchemeType`], for diagnostics. +fn scheme_type_label(scheme_type: SecuritySchemeType) -> &'static str { + match scheme_type { + SecuritySchemeType::ApiKey => "apiKey", + SecuritySchemeType::Http => "http", + SecuritySchemeType::MutualTls => "mutualTLS", + SecuritySchemeType::OAuth2 => "oauth2", + SecuritySchemeType::OpenIdConnect => "openIdConnect", + } +} + +fn parse_security_field_name(input: ParseStream) -> syn::Result<(String, proc_macro2::Span)> { + if input.peek(syn::Token![type]) { + let token: syn::Token![type] = input.parse()?; + Ok(("type".to_string(), token.span)) + } else if input.peek(syn::Token![in]) { + let token: syn::Token![in] = input.parse()?; + Ok(("in".to_string(), token.span)) + } else { + let ident: syn::Ident = input.parse()?; + Ok((ident.to_string(), ident.span())) + } +} + +fn parse_security_scheme_type(value: &LitStr) -> syn::Result { + match value.value().as_str() { + "apiKey" => Ok(SecuritySchemeType::ApiKey), + "http" => Ok(SecuritySchemeType::Http), + "mutualTLS" => Ok(SecuritySchemeType::MutualTls), + "oauth2" => Ok(SecuritySchemeType::OAuth2), + "openIdConnect" => Ok(SecuritySchemeType::OpenIdConnect), + other => Err(syn::Error::new( + value.span(), + format!( + "invalid security scheme type: `{other}`. Expected `apiKey`, `http`, `mutualTLS`, `oauth2`, or `openIdConnect`" + ), + )), + } +} + +/// Parse merge values: merge = [`path::to::App`, `another::App`] +fn parse_merge_values(input: ParseStream) -> syn::Result> { + input.parse::()?; + + let content; + let _ = bracketed!(content in input); + let paths: Punctuated = + content.parse_terminated(syn::Path::parse, syn::Token![,])?; + Ok(paths.into_iter().collect()) +} + +fn parse_openapi_values(input: ParseStream) -> syn::Result> { + input.parse::()?; + + if input.peek(syn::token::Bracket) { + let content; + let _ = bracketed!(content in input); + let entries: Punctuated = + content.parse_terminated(syn::parse::ParseBuffer::parse::, syn::Token![,])?; + Ok(entries.into_iter().collect()) + } else { + let single: LitStr = input.parse()?; + Ok(vec![single]) + } +} + +/// Validate that a URL starts with http:// or https:// +fn validate_server_url(url: &LitStr) -> syn::Result { + let url_value = url.value(); + if !url_value.starts_with("http://") && !url_value.starts_with("https://") { + return Err(syn::Error::new( + url.span(), + format!( + "invalid server URL: `{url_value}`. URL must start with `http://` or `https://`" + ), + )); + } + Ok(url_value) +} + +/// Parse server values in various formats: +/// - `servers = "url"` - single URL +/// - `servers = ["url1", "url2"]` - multiple URLs (strings only) +/// - `servers = [("url", "description")]` - tuple format with descriptions +/// - `servers = [{url = "...", description = "..."}]` - struct-like format +/// - `servers = {url = "...", description = "..."}` - single server struct-like format +fn parse_servers_values(input: ParseStream) -> syn::Result> { + use syn::token::{Brace, Paren}; + + input.parse::()?; + + if input.peek(syn::token::Bracket) { + // Array format: [...] + let content; + let _ = bracketed!(content in input); + + let mut servers = Vec::new(); + + while !content.is_empty() { + if content.peek(Paren) { + // Parse tuple: ("url", "description") + let tuple_content; + syn::parenthesized!(tuple_content in content); + let url: LitStr = tuple_content.parse()?; + let url_value = validate_server_url(&url)?; + let description = if tuple_content.peek(syn::Token![,]) { + tuple_content.parse::()?; + Some(tuple_content.parse::()?.value()) + } else { + None + }; + servers.push(ServerConfig { + url: url_value, + description, + }); + } else if content.peek(Brace) { + // Parse struct-like: {url = "...", description = "..."} + let server = parse_server_struct(&content)?; + servers.push(server); + } else { + // Parse simple string: "url" + let url: LitStr = content.parse()?; + let url_value = validate_server_url(&url)?; + servers.push(ServerConfig { + url: url_value, + description: None, + }); + } + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + Ok(servers) + } else if input.peek(syn::token::Brace) { + // Single struct-like format: servers = {url = "...", description = "..."} + let server = parse_server_struct(input)?; + Ok(vec![server]) + } else { + // Single string: servers = "url" + let single: LitStr = input.parse()?; + let url_value = validate_server_url(&single)?; + Ok(vec![ServerConfig { + url: url_value, + description: None, + }]) + } +} + +/// Parse a single server in struct-like format: {url = "...", description = "..."} +fn parse_server_struct(input: ParseStream) -> syn::Result { + let content; + syn::braced!(content in input); + + let mut url: Option = None; + let mut description: Option = None; + // Reject a repeated server field (e.g. `url = ..., url = ...`) with a + // spanned error instead of silently letting the later value overwrite the + // earlier one — matches the top-level `vespera!` arg parser and + // `parse_security_scheme_struct`. + let mut seen_fields = HashSet::::new(); + + while !content.is_empty() { + let ident: syn::Ident = content.parse()?; + let ident_str = ident.to_string(); + if !seen_fields.insert(ident_str.clone()) { + return Err(syn::Error::new( + ident.span(), + format!("duplicate server field: `{ident_str}`"), + )); + } + + match ident_str.as_str() { + "url" => { + content.parse::()?; + let url_lit: LitStr = content.parse()?; + url = Some(validate_server_url(&url_lit)?); + } + "description" => { + content.parse::()?; + description = Some(content.parse::()?.value()); + } + _ => { + return Err(syn::Error::new( + ident.span(), + format!("unknown field: `{ident_str}`. Expected `url` or `description`"), + )); + } + } + + if content.peek(syn::Token![,]) { + content.parse::()?; + } else { + break; + } + } + + let url = url.ok_or_else(|| syn::Error::new(proc_macro2::Span::call_site(), "vespera! macro: server configuration missing required `url` field. Use format: `servers = { url = \"http://localhost:3000\" }` or `servers = { url = \"...\", description = \"...\" }`."))?; + + Ok(ServerConfig { url, description }) +} + +/// Processed vespera input with extracted values +pub struct ProcessedVesperaInput { + pub folder_name: String, + pub openapi_file_names: Vec, + pub title: Option, + pub version: Option, + pub docs_url: Option, + pub redoc_url: Option, + pub servers: Option>, + pub security_schemes: Option>, + pub security: Option>>>, + pub tag_descriptions: Option>, + /// Apps to merge (`syn::Path` for code generation) + pub merge: Vec, +} + +/// Process `AutoRouterInput` into extracted values +pub fn process_vespera_input(input: AutoRouterInput) -> ProcessedVesperaInput { + ProcessedVesperaInput { + folder_name: input + .dir + .map_or_else(|| "routes".to_string(), |f| f.value()), + openapi_file_names: input + .openapi + .unwrap_or_default() + .into_iter() + .map(|f| f.value()) + .collect(), + title: input.title.map(|t| t.value()), + version: input.version.map(|v| v.value()), + docs_url: input.docs_url.map(|u| u.value()), + redoc_url: input.redoc_url.map(|u| u.value()), + servers: input.servers.map(|svrs| { + svrs.into_iter() + .map(|s| Server { + url: s.url, + description: s.description, + variables: None, + }) + .collect() + }), + security_schemes: input.security_schemes.and_then(|schemes| { + let schemes = schemes + .into_iter() + .map(|scheme| (scheme.name, scheme.scheme)) + .collect::>(); + if schemes.is_empty() { + None + } else { + Some(schemes) + } + }), + security: input.security.map(security_requirements), + tag_descriptions: input.tags.and_then(|tags| { + let tags = tags + .into_iter() + .filter_map(|tag| tag.description.map(|description| (tag.name, description))) + .collect::>(); + if tags.is_empty() { None } else { Some(tags) } + }), + merge: input.merge.unwrap_or_default(), + } +} + +#[cfg(test)] +#[path = "input_tests.rs"] +mod tests; diff --git a/crates/vespera_macro/src/router_codegen/input_tests.rs b/crates/vespera_macro/src/router_codegen/input_tests.rs new file mode 100644 index 00000000..4da08de3 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/input_tests.rs @@ -0,0 +1,676 @@ +use super::*; + +#[test] +fn test_parse_openapi_values_single() { + // Test that single string openapi value parses correctly via AutoRouterInput + let tokens = quote::quote!(openapi = "openapi.json"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 1); + assert_eq!(openapi[0].value(), "openapi.json"); +} + +#[test] +fn test_parse_openapi_values_array() { + // Test that array openapi value parses correctly via AutoRouterInput + let tokens = quote::quote!(openapi = ["openapi.json", "api.json"]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 2); + assert_eq!(openapi[0].value(), "openapi.json"); + assert_eq!(openapi[1].value(), "api.json"); +} + +#[test] +fn test_validate_server_url_valid_http() { + let lit = LitStr::new("http://localhost:3000", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "http://localhost:3000"); +} + +#[test] +fn test_validate_server_url_valid_https() { + let lit = LitStr::new("https://api.example.com", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "https://api.example.com"); +} + +#[test] +fn test_validate_server_url_invalid() { + let lit = LitStr::new("ftp://example.com", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_err()); +} + +#[test] +fn test_validate_server_url_no_scheme() { + let lit = LitStr::new("example.com", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_err()); +} + +#[test] +fn test_auto_router_input_parse_dir_only() { + let tokens = quote::quote!(dir = "api"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.dir.unwrap().value(), "api"); + assert!(input.openapi.is_none()); +} + +#[test] +fn test_auto_router_input_parse_string_as_dir() { + let tokens = quote::quote!("routes"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.dir.unwrap().value(), "routes"); +} + +#[test] +fn test_auto_router_input_parse_openapi_single() { + let tokens = quote::quote!(openapi = "openapi.json"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 1); + assert_eq!(openapi[0].value(), "openapi.json"); +} + +#[test] +fn test_auto_router_input_parse_openapi_array() { + let tokens = quote::quote!(openapi = ["a.json", "b.json"]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 2); +} + +#[test] +fn test_auto_router_input_parse_title_version() { + let tokens = quote::quote!(title = "My API", version = "2.0.0"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.title.unwrap().value(), "My API"); + assert_eq!(input.version.unwrap().value(), "2.0.0"); +} + +#[test] +fn test_auto_router_input_parse_docs_redoc() { + let tokens = quote::quote!(docs_url = "/docs", redoc_url = "/redoc"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.docs_url.unwrap().value(), "/docs"); + assert_eq!(input.redoc_url.unwrap().value(), "/redoc"); +} + +#[test] +fn test_auto_router_input_parse_servers_single() { + let tokens = quote::quote!(servers = "http://localhost:3000"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert!(servers[0].description.is_none()); +} + +#[test] +fn test_auto_router_input_parse_servers_array_strings() { + let tokens = quote::quote!(servers = ["http://localhost:3000", "https://api.example.com"]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 2); +} + +#[test] +fn test_auto_router_input_parse_servers_tuple() { + let tokens = quote::quote!(servers = [("http://localhost:3000", "Development")]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert_eq!(servers[0].description, Some("Development".to_string())); +} + +#[test] +fn test_auto_router_input_parse_servers_struct() { + let tokens = quote::quote!(servers = [{ url = "http://localhost:3000", description = "Dev" }]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert_eq!(servers[0].description, Some("Dev".to_string())); +} + +#[test] +fn test_auto_router_input_parse_servers_single_struct() { + let tokens = quote::quote!(servers = { url = "https://api.example.com" }); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "https://api.example.com"); +} + +#[test] +fn test_auto_router_input_parse_security_schemes() { + let tokens = quote::quote!( + security_schemes = [ + { name = "bearerAuth", type = "http", scheme = "bearer", bearer_format = "JWT" }, + { name = "apiKey", type = "apiKey", in = "header", header_name = "X-API-Key" } + ] + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let schemes = input.security_schemes.unwrap(); + assert_eq!(schemes.len(), 2); + assert_eq!(schemes[0].name, "bearerAuth"); + assert_eq!(schemes[0].scheme.r#type, SecuritySchemeType::Http); + assert_eq!(schemes[0].scheme.scheme.as_deref(), Some("bearer")); + assert_eq!(schemes[0].scheme.bearer_format.as_deref(), Some("JWT")); + assert_eq!(schemes[1].name, "apiKey"); + assert_eq!(schemes[1].scheme.r#type, SecuritySchemeType::ApiKey); + assert_eq!(schemes[1].scheme.r#in.as_deref(), Some("header")); + assert_eq!(schemes[1].scheme.name.as_deref(), Some("X-API-Key")); +} + +#[test] +fn test_auto_router_input_parse_global_security() { + let tokens = quote::quote!(security = ["bearerAuth", "apiKey"]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!( + input.security, + Some(vec!["bearerAuth".to_string(), "apiKey".to_string()]) + ); +} + +#[test] +fn test_process_vespera_input_security() { + let tokens = quote::quote!( + security_schemes = [{ name = "bearerAuth", type = "http", scheme = "bearer" }], + security = ["bearerAuth"] + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + assert!( + processed + .security_schemes + .as_ref() + .is_some_and(|schemes| schemes.contains_key("bearerAuth")) + ); + assert_eq!(processed.security.as_ref().map(Vec::len), Some(1)); +} + +#[test] +fn test_auto_router_input_parse_tags_with_descriptions() { + let tokens = quote::quote!( + tags = [ + { name = "users", description = "User operations" }, + { name = "admin", description = "Admin operations" } + ] + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let tags = input.tags.unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0].name, "users"); + assert_eq!(tags[0].description.as_deref(), Some("User operations")); + assert_eq!(tags[1].name, "admin"); + assert_eq!(tags[1].description.as_deref(), Some("Admin operations")); +} + +#[test] +fn test_auto_router_input_parse_tags_missing_name_errors() { + let tokens = quote::quote!(tags = [{ description = "Missing name" }]); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); +} + +#[test] +fn test_process_vespera_input_tag_descriptions() { + let tokens = quote::quote!(tags = [{ name = "users", description = "User operations" }]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + assert_eq!( + processed + .tag_descriptions + .as_ref() + .and_then(|tags| tags.get("users")) + .map(String::as_str), + Some("User operations") + ); +} + +#[test] +fn test_auto_router_input_parse_unknown_field() { + let tokens = quote::quote!(unknown_field = "value"); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); +} + +#[test] +fn test_auto_router_input_parse_all_fields() { + let tokens = quote::quote!( + dir = "api", + openapi = "openapi.json", + title = "Test API", + version = "1.0.0", + docs_url = "/docs", + redoc_url = "/redoc", + servers = "http://localhost:3000", + security_schemes = [{ name = "bearerAuth", type = "http", scheme = "bearer" }], + security = ["bearerAuth"], + tags = [{ name = "users", description = "User operations" }] + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert!(input.dir.is_some()); + assert!(input.openapi.is_some()); + assert!(input.title.is_some()); + assert!(input.version.is_some()); + assert!(input.docs_url.is_some()); + assert!(input.redoc_url.is_some()); + assert!(input.servers.is_some()); + assert!(input.security_schemes.is_some()); + assert!(input.security.is_some()); + assert!(input.tags.is_some()); +} + +#[test] +fn test_parse_server_struct_url_only() { + // Test server struct parsing via AutoRouterInput + let tokens = quote::quote!(servers = { url = "http://localhost:3000" }); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert!(servers[0].description.is_none()); +} + +#[test] +fn test_parse_server_struct_with_description() { + let tokens = quote::quote!(servers = { url = "http://localhost:3000", description = "Local" }); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers[0].description, Some("Local".to_string())); +} + +#[test] +fn test_parse_server_struct_unknown_field() { + let tokens = quote::quote!(servers = { url = "http://localhost:3000", unknown = "test" }); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); +} + +#[test] +fn test_parse_server_struct_missing_url() { + let tokens = quote::quote!(servers = { description = "test" }); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); +} + +#[test] +fn test_parse_servers_tuple_url_only() { + let tokens = quote::quote!(servers = [("http://localhost:3000")]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert!(servers[0].description.is_none()); +} + +#[test] +fn test_parse_servers_invalid_url() { + let tokens = quote::quote!(servers = "invalid-url"); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); +} + +#[test] +fn test_auto_router_input_parse_invalid_token() { + // Test line 149: neither ident nor string literal triggers lookahead error + let tokens = quote::quote!(123); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); +} + +#[test] +fn test_auto_router_input_empty() { + // Test empty input - should use defaults/env vars + let tokens = quote::quote!(); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_ok()); +} + +#[test] +fn test_auto_router_input_multiple_commas() { + // Test input with trailing comma + let tokens = quote::quote!(dir = "api",); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_ok()); +} + +#[test] +fn test_auto_router_input_no_comma() { + // Test input without comma between fields (should stop at second field) + let tokens = quote::quote!(dir = "api" title = "Test"); + let result: syn::Result = syn::parse2(tokens); + // This should fail or only parse first field + assert!(result.is_err()); +} + +#[test] +fn test_process_vespera_input_defaults() { + let tokens = quote::quote!(); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + assert_eq!(processed.folder_name, "routes"); + assert!(processed.openapi_file_names.is_empty()); + assert!(processed.title.is_none()); + assert!(processed.docs_url.is_none()); +} + +#[test] +fn test_process_vespera_input_all_fields() { + let tokens = quote::quote!( + dir = "api", + openapi = ["openapi.json", "api.json"], + title = "My API", + version = "1.0.0", + docs_url = "/docs", + redoc_url = "/redoc", + servers = "http://localhost:3000" + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + assert_eq!(processed.folder_name, "api"); + assert_eq!( + processed.openapi_file_names, + vec!["openapi.json", "api.json"] + ); + assert_eq!(processed.title, Some("My API".to_string())); + assert_eq!(processed.version, Some("1.0.0".to_string())); + assert_eq!(processed.docs_url, Some("/docs".to_string())); + assert_eq!(processed.redoc_url, Some("/redoc".to_string())); + let servers = processed.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); +} + +#[test] +fn test_process_vespera_input_servers_with_description() { + let tokens = quote::quote!( + servers = [{ url = "https://api.example.com", description = "Production" }] + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + let servers = processed.servers.unwrap(); + assert_eq!(servers[0].url, "https://api.example.com"); + assert_eq!(servers[0].description, Some("Production".to_string())); +} + +// ========== Tests for parse_merge_values ========== + +#[test] +fn test_parse_merge_values_single() { + let tokens = quote::quote!(merge = [some::path::App]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let merge = input.merge.unwrap(); + assert_eq!(merge.len(), 1); + // Check the path segments + let path = &merge[0]; + let segments: Vec<_> = path.segments.iter().map(|s| s.ident.to_string()).collect(); + assert_eq!(segments, vec!["some", "path", "App"]); +} + +#[test] +fn test_parse_merge_values_multiple() { + let tokens = quote::quote!(merge = [first::App, second::Other]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let merge = input.merge.unwrap(); + assert_eq!(merge.len(), 2); +} + +#[test] +fn test_parse_merge_values_empty() { + let tokens = quote::quote!(merge = []); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let merge = input.merge.unwrap(); + assert!(merge.is_empty()); +} + +#[test] +fn test_parse_merge_values_with_trailing_comma() { + let tokens = quote::quote!(merge = [app::MyApp,]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let merge = input.merge.unwrap(); + assert_eq!(merge.len(), 1); +} + +#[test] +#[serial_test::serial] +fn test_auto_router_input_server_env_var_fallback() { + // Test lines 181-183: VESPERA_SERVER_URL env var fallback + // `#[serial]` serializes this with every other env-mutating test so + // the process-global VESPERA_SERVER_* vars cannot race across the + // parallel test threads. + let test_url = "https://vespera-test-unique-12345.example.com"; + let test_desc = "Vespera Test Server 12345"; + + // Save current state + let old_server_url = std::env::var("VESPERA_SERVER_URL").ok(); + let old_server_desc = std::env::var("VESPERA_SERVER_DESCRIPTION").ok(); + + // SAFETY: Single-threaded test context + unsafe { + std::env::set_var("VESPERA_SERVER_URL", test_url); + std::env::set_var("VESPERA_SERVER_DESCRIPTION", test_desc); + } + + // Parse empty input - should pick up env vars + let tokens = quote::quote!(); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + + // Restore env vars immediately after parsing + unsafe { + if let Some(url) = old_server_url { + std::env::set_var("VESPERA_SERVER_URL", url); + } else { + std::env::remove_var("VESPERA_SERVER_URL"); + } + if let Some(desc) = old_server_desc { + std::env::set_var("VESPERA_SERVER_DESCRIPTION", desc); + } else { + std::env::remove_var("VESPERA_SERVER_DESCRIPTION"); + } + } + + // Check if servers was set - may not be if another test interfered + if let Some(servers) = input.servers { + // If we got servers, verify they match our test values + if servers.len() == 1 && servers[0].url == test_url { + assert_eq!(servers[0].description, Some(test_desc.to_string())); + } + // Otherwise another test's values were picked up, which is fine + } + // If servers is None, another test may have cleared the env var - acceptable +} + +#[test] +#[serial_test::serial] +fn test_auto_router_input_server_env_var_invalid_url_filtered() { + // Test that invalid URLs (not http/https) are filtered out by the .filter() call + // This exercises the filter branch, not lines 181-183 directly + let old_server_url = std::env::var("VESPERA_SERVER_URL").ok(); + + // SAFETY: Single-threaded test context + unsafe { + std::env::set_var("VESPERA_SERVER_URL", "ftp://invalid-url-test.com"); + } + + let tokens = quote::quote!(); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + + // Restore env var + unsafe { + if let Some(url) = old_server_url { + std::env::set_var("VESPERA_SERVER_URL", url); + } else { + std::env::remove_var("VESPERA_SERVER_URL"); + } + } + + // If servers is Some, it means another test set a valid URL - acceptable + // If servers is None, our invalid URL was correctly filtered + if let Some(servers) = &input.servers { + // Another test set a valid URL, check it's not our invalid one + assert!( + servers.is_empty() || servers[0].url != "ftp://invalid-url-test.com", + "Invalid ftp:// URL should have been filtered" + ); + } +} + +#[test] +fn test_duplicate_field_rejected() { + let tokens = quote::quote!(title = "A", title = "B"); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err(), "duplicate `title` must be rejected"); + assert!( + result + .err() + .unwrap() + .to_string() + .contains("duplicate field") + ); +} + +#[test] +fn test_duplicate_field_distinct_ok() { + let tokens = quote::quote!(title = "A", version = "1.0.0"); + let input: AutoRouterInput = syn::parse2(tokens).expect("distinct fields parse"); + assert_eq!(input.title.unwrap().value(), "A"); + assert_eq!(input.version.unwrap().value(), "1.0.0"); +} + +#[test] +fn test_security_scheme_apikey_valid() { + let tokens = quote::quote!(security_schemes = [ + { name = "apiKey", type = "apiKey", header_name = "X-API-Key", in = "header" } + ]); + let input: AutoRouterInput = syn::parse2(tokens).expect("valid apiKey scheme parses"); + let schemes = input.security_schemes.unwrap(); + assert_eq!(schemes.len(), 1); + assert_eq!(schemes[0].scheme.name.as_deref(), Some("X-API-Key")); +} + +#[test] +fn test_security_scheme_apikey_missing_in_rejected() { + let tokens = quote::quote!(security_schemes = [ + { name = "apiKey", type = "apiKey", header_name = "X-API-Key" } + ]); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err(), "apiKey without `in` must be rejected"); + assert!( + result + .err() + .unwrap() + .to_string() + .contains("required field `in`") + ); +} + +#[test] +fn test_security_scheme_apikey_bad_in_rejected() { + let tokens = quote::quote!(security_schemes = [ + { name = "apiKey", type = "apiKey", header_name = "X-API-Key", in = "body" } + ]); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err(), "invalid `in` value must be rejected"); +} + +#[test] +fn test_security_scheme_http_missing_scheme_rejected() { + let tokens = quote::quote!(security_schemes = [ + { name = "bearerAuth", type = "http" } + ]); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err(), "http without `scheme` must be rejected"); + assert!(result.err().unwrap().to_string().contains("scheme")); +} + +#[test] +fn test_security_scheme_http_valid() { + let tokens = quote::quote!(security_schemes = [ + { name = "bearerAuth", type = "http", scheme = "bearer", bearer_format = "JWT" } + ]); + let input: AutoRouterInput = syn::parse2(tokens).expect("valid http scheme parses"); + assert_eq!(input.security_schemes.unwrap().len(), 1); +} + +#[test] +fn test_security_scheme_oauth2_rejected() { + let tokens = quote::quote!(security_schemes = [ + { name = "oauth", type = "oauth2" } + ]); + let result: syn::Result = syn::parse2(tokens); + assert!( + result.is_err(), + "oauth2 (no flows support) must be rejected" + ); + assert!(result.err().unwrap().to_string().contains("flows")); +} + +#[test] +fn test_security_scheme_openidconnect_requires_url() { + let missing = quote::quote!(security_schemes = [ + { name = "oidc", type = "openIdConnect" } + ]); + assert!( + syn::parse2::(missing).is_err(), + "openIdConnect without url must be rejected" + ); + + let ok = quote::quote!(security_schemes = [ + { name = "oidc", type = "openIdConnect", open_id_connect_url = "https://example.com/.well-known/openid-configuration" } + ]); + let input: AutoRouterInput = syn::parse2(ok).expect("openIdConnect with url parses"); + let schemes = input.security_schemes.unwrap(); + assert_eq!( + schemes[0].scheme.open_id_connect_url.as_deref(), + Some("https://example.com/.well-known/openid-configuration") + ); +} + +#[test] +fn test_security_scheme_duplicate_field_rejected() { + let tokens = quote::quote!(security_schemes = [ + { name = "a", name = "b", type = "http", scheme = "bearer" } + ]); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err(), "duplicate scheme field must be rejected"); + assert!(result.err().unwrap().to_string().contains("duplicate")); +} + +#[test] +fn test_tag_duplicate_field_rejected() { + // A repeated tag field (e.g. `name = ..., name = ...`) must be a spanned + // compile error, not a silent last-wins overwrite. + let tokens = quote::quote!(tags = [{ name = "a", name = "b" }]); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err(), "duplicate tag field must be rejected"); + assert!( + result + .err() + .unwrap() + .to_string() + .contains("duplicate tag field") + ); +} + +#[test] +fn test_server_duplicate_field_rejected() { + // A repeated server field (e.g. `url = ..., url = ...`) must be a spanned + // compile error, not a silent last-wins overwrite. + let tokens = + quote::quote!(servers = [{ url = "http://localhost:3000", url = "http://other:3000" }]); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err(), "duplicate server field must be rejected"); + assert!( + result + .err() + .unwrap() + .to_string() + .contains("duplicate server field") + ); +} diff --git a/crates/vespera_macro/src/schema_impl.rs b/crates/vespera_macro/src/schema_impl.rs index eb352d68..0b1cce69 100644 --- a/crates/vespera_macro/src/schema_impl.rs +++ b/crates/vespera_macro/src/schema_impl.rs @@ -32,15 +32,107 @@ use std::{ collections::{BTreeMap, HashMap}, - path::Path, - sync::{LazyLock, Mutex}, + path::{Path, PathBuf}, + sync::{Arc, LazyLock, Mutex}, }; use crate::metadata::StructMetadata; +use crate::schema_macro::file_cache::{FileFingerprint, get_file_fingerprint}; -pub static SCHEMA_STORAGE: LazyLock>> = +/// Per-crate registry of `#[derive(Schema)]` metadata. +/// +/// The OUTER key is [`current_crate_key`] (the consuming crate's +/// `CARGO_MANIFEST_DIR`); the inner map is `schema name -> metadata` exactly +/// as before. Scoping by crate stops a long-lived rust-analyzer proc-macro +/// server — which expands MANY crates in ONE process — from leaking crate +/// A's schemas into crate B's generated `openapi.json`. A plain `cargo build` +/// runs each crate in its own process, so the outer map only ever holds one +/// bucket there; the scoping matters only for the shared-server (IDE) case. +pub static SCHEMA_STORAGE: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +static DEFAULT_FUNCTION_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +/// Crate-identity key for the process-global metadata registries +/// ([`SCHEMA_STORAGE`], `ROUTE_STORAGE`, `CRON_STORAGE`). +/// +/// Uses `CARGO_MANIFEST_DIR` (set per-crate by cargo, and re-set per expanded +/// crate by the rust-analyzer proc-macro server). When unset — a non-cargo +/// invocation — all entries share one empty-string bucket, i.e. the prior +/// un-scoped global behaviour, which is correct for that single-build case. +#[must_use] +pub fn current_crate_key() -> String { + std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default() +} + +/// Register a `#[derive(Schema)]` metadata entry for the current crate. +/// +/// Returns `Err(())` when a DIFFERENT source item is already registered under +/// `name` for THIS crate (the silent duplicate-schema-name footgun) so the +/// caller can raise a spanned compile error. Re-registration from the same +/// source identity replaces the previous metadata, which keeps long-lived +/// proc-macro servers correct across IDE edits. +pub fn register_schema(name: String, metadata: StructMetadata) -> Result<(), ()> { + let mut guard = SCHEMA_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let bucket = guard.entry(current_crate_key()).or_default(); + if let Some(existing) = bucket.get(&name) { + if existing.definition == metadata.definition + || (existing.source_identity.is_some() + && existing.source_identity == metadata.source_identity) + { + bucket.insert(name, metadata); + return Ok(()); + } + return Err(()); + } + bucket.insert(name, metadata); + Ok(()) +} + +fn derive_source_identity(input: &syn::DeriveInput) -> Option { + proc_macro2::Span::call_site() + .local_file() + .map(|path| format!("{}::{}", path.display(), input.ident)) +} + +/// Overwrite-insert a schema for the current crate — the +/// `schema_type!(.., ignore)` pre-registration path, which has no +/// duplicate-name semantics. +pub fn insert_schema(name: String, metadata: StructMetadata) { + SCHEMA_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry(current_crate_key()) + .or_default() + .insert(name, metadata); +} + +/// Snapshot of the current crate's registered schemas — a clone of just this +/// crate's bucket (small at compile time), so every consumer keeps operating +/// on a `HashMap` exactly as before the per-crate +/// scoping was introduced. +#[must_use] +pub fn current_crate_schemas() -> HashMap { + SCHEMA_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(¤t_crate_key()) + .cloned() + .unwrap_or_default() +} + +#[derive(Clone)] +struct DefaultFunctionCacheEntry { + fingerprint: FileFingerprint, + /// `Arc` so a cache hit hands back a single pointer-clone instead of + /// deep-cloning the whole `field -> default JSON` map on every derive that + /// shares a file (the previous `BTreeMap` clone copied every entry). + values: Arc>, +} + /// Extract custom schema name from #[schema(name = "...")] attribute pub fn extract_schema_name_attr(attrs: &[syn::Attribute]) -> Option { for attr in attrs { @@ -51,6 +143,14 @@ pub fn extract_schema_name_attr(attrs: &[syn::Attribute]) -> Option { let value = meta.value()?; let lit: syn::LitStr = value.parse()?; custom_name = Some(lit.value()); + } else if let Ok(value) = meta.value() { + // Consume (and discard) other `key = ` items — e.g. + // `ref`, or any field-level constraint key — so + // `parse_nested_meta` does NOT bail before reaching a + // later `name`. Those keys are handled by their own + // parsers; here we only extract `name`. Bare flags have no + // `= value` and are simply skipped. + let _ = value.parse::(); } Ok(()) }); @@ -65,9 +165,21 @@ pub fn extract_schema_name_attr(attrs: &[syn::Attribute]) -> Option { /// Process derive input and return metadata + expanded code pub fn process_derive_schema( input: &syn::DeriveInput, -) -> (StructMetadata, proc_macro2::TokenStream) { +) -> (Option, proc_macro2::TokenStream) { let name = &input.ident; + if let syn::Data::Struct(data_struct) = &input.data + && let syn::Fields::Named(fields_named) = &data_struct.fields + { + for field in &fields_named.named { + if let Err(error) = + crate::parser::schema::schema_attrs::try_extract_schema_constraints(&field.attrs) + { + return (None, error.to_compile_error()); + } + } + } + // Check for custom schema name from #[schema(name = "...")] attribute let schema_name = extract_schema_name_attr(&input.attrs).unwrap_or_else(|| name.to_string()); @@ -81,6 +193,9 @@ pub fn process_derive_schema( // Schema-derived types appear in OpenAPI spec (include_in_openapi: true) let mut metadata = StructMetadata::new(schema_name, quote::quote!(#input).to_string()); + if let Some(source_identity) = derive_source_identity(input) { + metadata = metadata.with_source_identity(source_identity); + } if input .attrs .iter() @@ -114,7 +229,7 @@ pub fn process_derive_schema( // The emit function returns an empty `TokenStream` when no field // requests a runtime rule or when the feature is off. let expanded = crate::garde_emit::emit_garde_validate(input); - (metadata, expanded) + (Some(metadata), expanded) } /// Extract default values from `#[serde(default = "fn_name")]` attributes @@ -158,19 +273,80 @@ pub fn extract_field_defaults_from_path( return defaults; } - // Read and parse the file (cached via FileCache parsed_file_asts) - let Some(file_ast) = crate::schema_macro::file_cache::get_parsed_file(file_path) else { - return defaults; + defaults.extend(extract_defaults_from_path(&fn_defaults, file_path)); + defaults +} + +fn extract_defaults_from_path( + fn_defaults: &[(String, String)], + file_path: &Path, +) -> BTreeMap { + let Some(function_defaults) = cached_default_functions(file_path) else { + return BTreeMap::new(); }; + fn_defaults + .iter() + .filter_map(|(field_name, fn_name)| { + function_defaults + .get(fn_name) + .cloned() + .map(|value| (field_name.clone(), value)) + }) + .collect() +} - // Extract default values from functions - defaults.extend(extract_defaults_from_file(&fn_defaults, &file_ast)); - defaults +fn cached_default_functions(file_path: &Path) -> Option>> { + // Fingerprint via the SHARED per-epoch file cache: this populates the + // epoch cache so the `get_parsed_file` below reuses it instead of issuing + // a second `fs::metadata` syscall (the previous direct `fs::metadata` here + // double-stat'd every derive with function defaults). The mtime+len + // fingerprint also matches the file-content cache, so a size-changing + // timestamp-preserving edit invalidates this cache too. + let fingerprint = get_file_fingerprint(file_path)?; + if let Some(values) = DEFAULT_FUNCTION_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(file_path) + .and_then(|entry| (entry.fingerprint == fingerprint).then(|| Arc::clone(&entry.values))) + { + return Some(values); + } + + let file_ast = crate::schema_macro::file_cache::get_parsed_file(file_path)?; + let values = Arc::new(extract_default_functions_from_file(&file_ast)); + DEFAULT_FUNCTION_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + file_path.to_path_buf(), + DefaultFunctionCacheEntry { + fingerprint, + values: Arc::clone(&values), + }, + ); + Some(values) +} + +fn extract_default_functions_from_file( + file_ast: &syn::File, +) -> BTreeMap { + file_ast + .items + .iter() + .filter_map(|item| { + let syn::Item::Fn(func) = item else { + return None; + }; + crate::openapi_generator::extract_default_value_from_function(func) + .map(|value| (func.sig.ident.to_string(), value)) + }) + .collect() } /// Extract default values by finding functions in the given file AST. /// Separated from `extract_field_defaults` for testability (proc_macro2::Span /// is not available in unit tests). +#[cfg(test)] pub fn extract_defaults_from_file( fn_defaults: &[(String, String)], file_ast: &syn::File, @@ -199,6 +375,7 @@ mod tests { } }; let (metadata, _expanded) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "User"); assert!(metadata.definition.contains("struct User")); } @@ -212,6 +389,7 @@ mod tests { } }; let (metadata, _expanded) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "Status"); assert!(metadata.definition.contains("enum Status")); } @@ -224,6 +402,7 @@ mod tests { } }; let (metadata, _expanded) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "Container"); } @@ -274,6 +453,7 @@ mod tests { } }; let (metadata, _tokens) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "User"); assert!(metadata.definition.contains("User")); } @@ -287,6 +467,7 @@ mod tests { } }; let (metadata, _) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "CustomUserSchema"); } @@ -298,6 +479,7 @@ mod tests { } }; let (metadata, _tokens) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "Container"); } @@ -374,14 +556,17 @@ mod tests { #[test] fn test_extract_schema_name_attr_schema_with_unknown_key_value() { - // #[schema(other = "x", name = "MyName")] — parse_nested_meta bails on unhandled - // key=value (other = "x") since the value isn't consumed. Error is silently ignored. + // `#[schema(other = "x", name = "MyName")]` — the unknown `other` + // key's value is now consumed so `parse_nested_meta` reaches `name` + // instead of bailing early; the custom name is no longer lost. let attrs: Vec = syn::parse_quote! { #[schema(other = "x", name = "MyName")] }; - let result = extract_schema_name_attr(&attrs); - // parse_nested_meta fails at `other = "x"` (value not consumed), so `name` is never reached - assert_eq!(result, None); + assert_eq!( + extract_schema_name_attr(&attrs), + Some("MyName".to_string()), + "a `name` after an unknown key must still be extracted" + ); } #[test] @@ -403,6 +588,7 @@ mod tests { struct Unit; }; let (metadata, tokens) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "Unit"); assert!(metadata.definition.contains("Unit")); assert!(tokens.is_empty(), "Token stream should be empty"); @@ -414,6 +600,7 @@ mod tests { struct Pair(i32, String); }; let (metadata, tokens) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "Pair"); assert!(metadata.definition.contains("Pair")); assert!(tokens.is_empty()); @@ -425,6 +612,7 @@ mod tests { struct Empty {} }; let (metadata, _) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "Empty"); } @@ -436,6 +624,7 @@ mod tests { } }; let (metadata, _) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "Ref"); } @@ -450,6 +639,7 @@ mod tests { } }; let (metadata, _) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "UserResponse"); assert!(metadata.definition.contains("camelCase")); assert!(metadata.definition.contains("skip")); @@ -463,6 +653,7 @@ mod tests { struct Visible { x: i32 } }; let (metadata, _) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert!( metadata.include_in_openapi, "Schema-derived types must have include_in_openapi=true" @@ -479,6 +670,7 @@ mod tests { } }; let (metadata, _) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert!(metadata.definition.contains("id")); assert!(metadata.definition.contains("u64")); assert!(metadata.definition.contains("name")); @@ -488,61 +680,193 @@ mod tests { // ========== Coverage: SCHEMA_STORAGE direct usage ========== + /// Remove a schema entry from the current crate's bucket (test cleanup). + fn remove_current_crate_schema(key: &str) { + let mut guard = SCHEMA_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(bucket) = guard.get_mut(¤t_crate_key()) { + bucket.remove(key); + } + } + #[test] fn test_schema_storage_insert_and_get() { - let storage = SCHEMA_STORAGE.lock().unwrap(); let key = "__test_coverage_type__".to_string(); - // Clean up if previous test left data - drop(storage); + remove_current_crate_schema(&key); - { - let mut storage = SCHEMA_STORAGE.lock().unwrap(); - storage.insert( - key.clone(), - StructMetadata::new(key.clone(), "struct __test_coverage_type__ {}".to_string()), - ); - } + insert_schema( + key.clone(), + StructMetadata::new(key.clone(), "struct __test_coverage_type__ {}".to_string()), + ); - { - let storage = SCHEMA_STORAGE.lock().unwrap(); - let meta = storage.get(&key); - assert!(meta.is_some(), "Inserted metadata should be retrievable"); - let meta = meta.unwrap(); - assert_eq!(meta.name, key); - assert!(meta.include_in_openapi); - } + let schemas = current_crate_schemas(); + let meta = schemas.get(&key); + assert!(meta.is_some(), "Inserted metadata should be retrievable"); + let meta = meta.unwrap(); + assert_eq!(meta.name, key); + assert!(meta.include_in_openapi); - // Cleanup - { - let mut storage = SCHEMA_STORAGE.lock().unwrap(); - storage.remove(&key); - } + remove_current_crate_schema(&key); } #[test] fn test_schema_storage_overwrite() { let key = "__test_overwrite_type__".to_string(); - { - let mut storage = SCHEMA_STORAGE.lock().unwrap(); - storage.insert( + remove_current_crate_schema(&key); + insert_schema( + key.clone(), + StructMetadata::new(key.clone(), "struct V1 {}".to_string()), + ); + insert_schema( + key.clone(), + StructMetadata::new(key.clone(), "struct V2 {}".to_string()), + ); + let schemas = current_crate_schemas(); + let meta = schemas.get(&key).unwrap(); + assert!(meta.definition.contains("V2"), "Last insert should win"); + remove_current_crate_schema(&key); + } + + #[test] + fn test_register_schema_rejects_conflicting_definition() { + let key = "__test_conflict_type__".to_string(); + remove_current_crate_schema(&key); + // First registration wins. + assert!( + register_schema( key.clone(), - StructMetadata::new(key.clone(), "struct V1 {}".to_string()), - ); - storage.insert( + StructMetadata::new(key.clone(), "struct A { x: i32 }".to_string()), + ) + .is_ok() + ); + // Identical re-registration is idempotent. + assert!( + register_schema( key.clone(), - StructMetadata::new(key.clone(), "struct V2 {}".to_string()), - ); - } - { - let storage = SCHEMA_STORAGE.lock().unwrap(); - let meta = storage.get(&key).unwrap(); - assert!(meta.definition.contains("V2"), "Last insert should win"); - } - // Cleanup + StructMetadata::new(key.clone(), "struct A { x: i32 }".to_string()), + ) + .is_ok() + ); + // A DIFFERENT definition under the same name is rejected. + assert!( + register_schema( + key.clone(), + StructMetadata::new(key.clone(), "struct A { y: u64 }".to_string()), + ) + .is_err() + ); + remove_current_crate_schema(&key); + } + + #[test] + fn test_register_schema_replaces_same_source_identity() { + let key = "__test_same_source_replacement__".to_string(); + remove_current_crate_schema(&key); + let source_identity = "src/models/user.rs::User".to_string(); + + assert!( + register_schema( + key.clone(), + StructMetadata::new(key.clone(), "struct User { id: i32 }".to_string()) + .with_source_identity(source_identity.clone()), + ) + .is_ok() + ); + assert!( + register_schema( + key.clone(), + StructMetadata::new(key.clone(), "struct User { id: i64 }".to_string()) + .with_source_identity(source_identity), + ) + .is_ok() + ); + + let schemas = current_crate_schemas(); + let meta = schemas.get(&key).expect("schema should remain registered"); + assert!(meta.definition.contains("i64")); + remove_current_crate_schema(&key); + } + + #[test] + fn test_register_schema_rejects_different_source_identity() { + let key = "__test_distinct_source_conflict__".to_string(); + remove_current_crate_schema(&key); + + assert!( + register_schema( + key.clone(), + StructMetadata::new(key.clone(), "struct UserA { id: i32 }".to_string()) + .with_source_identity("src/a.rs::User".to_string()), + ) + .is_ok() + ); + assert!( + register_schema( + key.clone(), + StructMetadata::new(key.clone(), "struct UserB { id: i32 }".to_string()) + .with_source_identity("src/b.rs::User".to_string()), + ) + .is_err() + ); + remove_current_crate_schema(&key); + } + + #[test] + fn test_invalid_derive_schema_does_not_register_or_poison_storage() { + let key = "__InvalidConstraintDoesNotPoison".to_string(); + remove_current_crate_schema(&key); + let invalid: syn::DeriveInput = syn::parse_quote! { + struct __InvalidConstraintDoesNotPoison { + #[schema(min_length = "bad")] + name: String, + } + }; + + let (metadata, tokens) = process_derive_schema(&invalid); + assert!( + metadata.is_none(), + "invalid constraints must skip registration" + ); + assert!(tokens.to_string().contains("compile_error")); + + let valid: syn::DeriveInput = syn::parse_quote! { + struct __InvalidConstraintDoesNotPoison { + name: String, + } + }; + let (metadata, tokens) = process_derive_schema(&valid); + assert!(tokens.is_empty()); + let metadata = metadata.expect("valid schema metadata"); + assert!(register_schema(key.clone(), metadata).is_ok()); + remove_current_crate_schema(&key); + } + + #[test] + fn test_schema_storage_crate_scoping_isolation() { + // A schema registered under a DIFFERENT crate's bucket must never leak + // into the current crate's snapshot — the cross-crate contamination + // fix for long-lived rust-analyzer proc-macro servers. + let fake_crate = "__fake_other_crate_dir__".to_string(); + let key = "__isolated_schema__".to_string(); { - let mut storage = SCHEMA_STORAGE.lock().unwrap(); - storage.remove(&key); + let mut guard = SCHEMA_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + guard.entry(fake_crate.clone()).or_default().insert( + key.clone(), + StructMetadata::new(key.clone(), "struct Isolated {}".to_string()), + ); } + let mine = current_crate_schemas(); + assert!( + !mine.contains_key(&key), + "another crate's schema must not leak into this crate's snapshot" + ); + SCHEMA_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&fake_crate); } #[test] @@ -650,6 +974,7 @@ struct Config { }; let (metadata, tokens) = process_derive_schema(&input); + let metadata = metadata.expect("valid schema metadata"); assert_eq!(metadata.name, "UserSchema"); assert!(!metadata.include_in_openapi); assert!(tokens.is_empty()); diff --git a/crates/vespera_macro/src/schema_macro/circular.rs b/crates/vespera_macro/src/schema_macro/circular.rs index 70cbfe9f..aa04d3b5 100644 --- a/crates/vespera_macro/src/schema_macro/circular.rs +++ b/crates/vespera_macro/src/schema_macro/circular.rs @@ -5,13 +5,15 @@ use std::collections::HashMap; -use super::type_utils::normalize_token_str; use proc_macro2::TokenStream; use quote::quote; use super::{ seaorm::extract_belongs_to_from_field, - type_utils::{capitalize_first, is_option_type, is_seaorm_relation_type}, + type_utils::{ + SeaOrmRelationKind, capitalize_first, first_generic_type_arg, is_option_type, + is_seaorm_relation_type, seaorm_relation_inner_type, seaorm_relation_kind, + }, }; use crate::parser::extract_skip; @@ -70,19 +72,14 @@ pub fn analyze_circular_refs(source_module_path: &[String], definition: &str) -> .iter() .filter_map(|f| f.ident.as_ref().map(|id| (id.to_string(), f))) .collect(); - // Precompute format strings used for circular reference detection - let schema_pattern = format!("{source_module}::Schema"); - let entity_pattern = format!("{source_module}::Entity"); let capitalized_pattern = format!("{}Schema", capitalize_first(source_module)); for field in &fields_named.named { // FieldsNamed guarantees all fields have identifiers let field_ident = field.ident.as_ref().expect("named field has ident"); let field_name = field_ident.to_string(); - let ty_str = normalize_token_str("e!(#field.ty)); - // --- has_fk_relations logic --- - if ty_str.contains("HasOne<") || ty_str.contains("BelongsTo<") { + if seaorm_relation_kind(&field.ty).is_some_and(SeaOrmRelationKind::is_fk_backed) { has_fk = true; // --- is_circular_relation_required logic (for ALL FK fields) --- @@ -95,18 +92,9 @@ pub fn analyze_circular_refs(source_module_path: &[String], definition: &str) -> } // --- detect_circular_fields logic --- - // Skip HasMany — they are excluded by default and don't create circular refs - if !ty_str.contains("HasMany<") { - let is_circular = (ty_str.contains("HasOne<") - || ty_str.contains("BelongsTo<") - || ty_str.contains("Box<")) - && (ty_str.contains(&schema_pattern) - || ty_str.contains(&entity_pattern) - || ty_str.contains(&capitalized_pattern)); - - if is_circular { - circular_fields.push(field_name); - } + // Skip HasMany — they are excluded by default and don't create circular refs. + if is_circular_relation_type(&field.ty, source_module, &capitalized_pattern) { + circular_fields.push(field_name); } } @@ -117,6 +105,62 @@ pub fn analyze_circular_refs(source_module_path: &[String], definition: &str) -> } } +fn is_circular_relation_type( + ty: &syn::Type, + source_module: &str, + capitalized_schema: &str, +) -> bool { + match seaorm_relation_kind(ty) { + Some(SeaOrmRelationKind::HasMany) => false, + Some(SeaOrmRelationKind::HasOne | SeaOrmRelationKind::BelongsTo) => { + seaorm_relation_inner_type(ty).is_some_and(|inner| { + type_targets_source_schema(inner, source_module, capitalized_schema) + }) + } + None => type_targets_source_schema(ty, source_module, capitalized_schema), + } +} + +fn transparent_inner_type<'a>(ty: &'a syn::Type, wrapper: &str) -> Option<&'a syn::Type> { + let syn::Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if segment.ident != wrapper { + return None; + } + first_generic_type_arg(segment) +} + +fn type_targets_source_schema( + ty: &syn::Type, + source_module: &str, + capitalized_schema: &str, +) -> bool { + if let Some(inner) = + transparent_inner_type(ty, "Option").or_else(|| transparent_inner_type(ty, "Box")) + { + return type_targets_source_schema(inner, source_module, capitalized_schema); + } + let syn::Type::Path(type_path) = ty else { + return false; + }; + let segments: Vec<_> = type_path + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect(); + match segments.as_slice() { + [last] => last == capitalized_schema, + [.., module, last] => { + module == source_module && (last == "Schema" || last == "Entity") + || last == capitalized_schema + } + [] => false, + } +} + /// Generate a default value for a `SeaORM` relation field in inline construction. /// /// - `HasMany` -> `vec![]` @@ -128,13 +172,11 @@ pub fn generate_default_for_relation_field( field_attrs: &[syn::Attribute], all_fields: &syn::FieldsNamed, ) -> TokenStream { - let ty_str = normalize_token_str("e!(#ty)); - - // Check the SeaORM relation type - if ty_str.contains("HasMany<") { + // Check the SeaORM relation type using the parsed AST rather than rendered tokens. + if seaorm_relation_kind(ty) == Some(SeaOrmRelationKind::HasMany) { // HasMany -> Vec -> empty vec quote! { #field_ident: vec![] } - } else if ty_str.contains("HasOne<") || ty_str.contains("BelongsTo<") { + } else if seaorm_relation_kind(ty).is_some_and(SeaOrmRelationKind::is_fk_backed) { // Check FK field optionality let fk_field = extract_belongs_to_from_field(field_attrs); let is_optional = fk_field.as_ref().is_none_or(|fk| { @@ -291,727 +333,4 @@ pub fn generate_inline_type_construction( } #[cfg(test)] -mod tests { - use rstest::rstest; - - use super::*; - - #[rstest] - #[case( - &["crate", "models", "memo"], - r"pub struct UserSchema { - pub id: i32, - pub memos: HasMany, - }", - vec![] // HasMany is not considered circular - )] - #[case( - &["crate", "models", "user"], - r"pub struct MemoSchema { - pub id: i32, - pub user: BelongsTo, - }", - vec!["user".to_string()] - )] - #[case( - &["crate", "models", "user"], - r"pub struct MemoSchema { - pub id: i32, - pub user: HasOne, - }", - vec!["user".to_string()] - )] - #[case( - &["crate", "models", "user"], - r"pub struct MemoSchema { - pub id: i32, - pub user: Box, - }", - vec!["user".to_string()] - )] - #[case( - &["crate", "models", "memo"], - r"pub struct UserSchema { - pub id: i32, - pub name: String, - }", - vec![] // No circular fields - )] - fn test_detect_circular_fields( - #[case] source_module_path: &[&str], - #[case] related_schema_def: &str, - #[case] expected: Vec, - ) { - let module_path: Vec = source_module_path - .iter() - .map(std::string::ToString::to_string) - .collect(); - let result = analyze_circular_refs(&module_path, related_schema_def).circular_fields; - assert_eq!(result, expected); - } - - #[test] - fn test_detect_circular_fields_invalid_struct() { - let result = - analyze_circular_refs(&["crate".to_string()], "not valid rust").circular_fields; - assert!(result.is_empty()); - } - - #[test] - fn test_detect_circular_fields_unnamed_fields() { - let result = analyze_circular_refs( - &[ - "crate".to_string(), - "models".to_string(), - "test".to_string(), - ], - "pub struct TupleStruct(i32, String);", - ) - .circular_fields; - assert!(result.is_empty()); - } - - #[rstest] - #[case( - r"pub struct Model { - pub id: i32, - pub user: BelongsTo, - }", - true - )] - #[case( - r"pub struct Model { - pub id: i32, - pub user: HasOne, - }", - true - )] - #[case( - r"pub struct Model { - pub id: i32, - pub name: String, - }", - false - )] - #[case( - r"pub struct Model { - pub id: i32, - pub items: HasMany, - }", - false // HasMany alone doesn't count as FK relation - )] - fn test_has_fk_relations(#[case] model_def: &str, #[case] expected: bool) { - assert_eq!( - analyze_circular_refs(&[], model_def).has_fk_relations, - expected - ); - } - - #[test] - fn test_has_fk_relations_invalid_struct() { - assert!(!analyze_circular_refs(&[], "not valid rust").has_fk_relations); - } - - #[test] - fn test_has_fk_relations_unnamed_fields() { - assert!( - !analyze_circular_refs(&[], "pub struct TupleStruct(i32, String);").has_fk_relations - ); - } - - #[test] - fn test_is_circular_relation_required_invalid_struct() { - assert!( - !analyze_circular_refs(&[], "not valid rust") - .circular_field_required - .get("user") - .copied() - .unwrap_or(false) - ); - } - - #[test] - fn test_is_circular_relation_required_unnamed_fields() { - assert!( - !analyze_circular_refs(&[], "pub struct TupleStruct(i32, String);") - .circular_field_required - .get("user") - .copied() - .unwrap_or(false) - ); - } - - #[test] - fn test_is_circular_relation_required_field_not_found() { - let model_def = r"pub struct Model { - pub id: i32, - pub name: String, - }"; - assert!( - !analyze_circular_refs(&[], model_def) - .circular_field_required - .get("nonexistent") - .copied() - .unwrap_or(false) - ); - } - - #[test] - fn test_generate_default_for_relation_field_has_many() { - let ty: syn::Type = syn::parse_str("HasMany").unwrap(); - let field_ident = syn::Ident::new("users", proc_macro2::Span::call_site()); - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub id: i32 }").unwrap(); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - assert!(output.contains("users : vec ! []")); - } - - #[test] - fn test_generate_default_for_relation_field_has_one_optional() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let field_ident = syn::Ident::new("user", proc_macro2::Span::call_site()); - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub user_id: Option }").unwrap(); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - assert!(output.contains("user : None")); - } - - #[test] - fn test_generate_default_for_relation_field_unknown_type() { - let ty: syn::Type = syn::parse_str("SomeUnknownType").unwrap(); - let field_ident = syn::Ident::new("field", proc_macro2::Span::call_site()); - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub id: i32 }").unwrap(); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - assert!(output.contains("Default :: default ()")); - } - - #[test] - fn test_generate_inline_struct_construction_invalid_struct() { - let schema_path = quote! { user::Schema }; - let tokens = - generate_inline_struct_construction(&schema_path, "not valid rust", &[], "model"); - let output = tokens.to_string(); - assert!(output.contains("From")); - } - - #[test] - fn test_generate_inline_struct_construction_tuple_struct() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - "pub struct TupleStruct(i32, String);", - &[], - "model", - ); - let output = tokens.to_string(); - assert!(output.contains("From")); - } - - #[test] - fn test_generate_inline_struct_construction_with_fields() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - r"pub struct UserSchema { - pub id: i32, - pub name: String, - }", - &[], - "r", - ); - let output = tokens.to_string(); - assert!(output.contains("user :: Schema")); - assert!(output.contains("id : r . id")); - assert!(output.contains("name : r . name")); - } - - #[test] - fn test_generate_inline_struct_construction_with_circular_field() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - r"pub struct UserSchema { - pub id: i32, - pub memos: HasMany, - }", - &["memos".to_string()], - "r", - ); - let output = tokens.to_string(); - assert!(output.contains("user :: Schema")); - assert!(output.contains("id : r . id")); - assert!(output.contains("memos : vec ! []")); - } - - #[test] - fn test_generate_inline_struct_construction_skip_serde_skip_fields() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - r"pub struct UserSchema { - pub id: i32, - #[serde(skip)] - pub internal: String, - }", - &[], - "r", - ); - let output = tokens.to_string(); - assert!(output.contains("id : r . id")); - assert!(!output.contains("internal : r . internal")); - } - - #[test] - fn test_generate_inline_type_construction_invalid_struct() { - let inline_type_name = syn::Ident::new("TestInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &["id".to_string()], - "not valid rust", - "model", - ); - let output = tokens.to_string(); - assert!(output.contains("Default :: default ()")); - } - - #[test] - fn test_generate_inline_type_construction_tuple_struct() { - let inline_type_name = syn::Ident::new("TestInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &["id".to_string()], - "pub struct TupleStruct(i32, String);", - "model", - ); - let output = tokens.to_string(); - assert!(output.contains("Default :: default ()")); - } - - #[test] - fn test_generate_inline_type_construction_with_fields() { - let inline_type_name = syn::Ident::new("UserInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &["id".to_string(), "name".to_string()], - r"pub struct Model { - pub id: i32, - pub name: String, - pub email: String, - }", - "r", - ); - let output = tokens.to_string(); - assert!(output.contains("UserInline")); - assert!(output.contains("id : r . id")); - assert!(output.contains("name : r . name")); - assert!(!output.contains("email : r . email")); - } - - #[test] - fn test_generate_inline_type_construction_skips_relations() { - let inline_type_name = syn::Ident::new("UserInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &["id".to_string(), "memos".to_string()], - r"pub struct Model { - pub id: i32, - pub memos: HasMany, - }", - "r", - ); - let output = tokens.to_string(); - assert!(output.contains("id : r . id")); - assert!(!output.contains("memos : r . memos")); - } - - // Additional coverage tests for circular_field_required via analyze_circular_refs - - #[test] - fn test_circular_field_required_has_one_with_required_fk() { - // Model has HasOne relation with a required (non-Option) FK field - let model_def = r#"pub struct Model { - pub id: i32, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "Column::UserId", to = "super::user::Column::Id")] - pub user: HasOne, - }"#; - // The FK field 'user_id' is i32 (required), so circular relation IS required - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("user") - .copied() - .unwrap_or(false); - // Without proper BelongsTo attribute parsing, this returns false - // because extract_belongs_to_from_field won't find the FK - assert!(!result); - } - - #[test] - fn test_circular_field_required_belongs_to_with_optional_fk() { - // Model has BelongsTo relation with optional FK field - let model_def = r#"pub struct Model { - pub id: i32, - pub user_id: Option, - #[sea_orm(belongs_to = "super::user::Entity", from = "Column::UserId", to = "super::user::Column::Id")] - pub user: BelongsTo, - }"#; - // FK field is Option, so circular relation is NOT required - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("user") - .copied() - .unwrap_or(false); - assert!(!result); - } - - #[test] - fn test_circular_field_required_non_relation_field() { - // Field exists but is not a relation type - let model_def = r"pub struct Model { - pub id: i32, - pub name: String, - }"; - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("name") - .copied() - .unwrap_or(false); - assert!(!result); - } - - #[test] - fn test_circular_field_required_field_without_ident() { - // Struct with fields that have no ident (tuple-like, but in braces - edge case) - let model_def = r"pub struct Model { - pub id: i32, - }"; - // Looking for a field that doesn't match - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("nonexistent_field") - .copied() - .unwrap_or(false); - assert!(!result); - } - - // Additional coverage tests for generate_default_for_relation_field - - #[test] - fn test_generate_default_for_relation_field_belongs_to_optional() { - let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - let field_ident = syn::Ident::new("user", proc_macro2::Span::call_site()); - // FK field is optional - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub user_id: Option }").unwrap(); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - // Should produce None for optional - assert!(output.contains("user : None")); - } - - #[test] - fn test_generate_default_for_relation_field_belongs_to_required() { - let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - let field_ident = syn::Ident::new("user", proc_macro2::Span::call_site()); - // FK field is required (not Option) - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub user_id: i32 }").unwrap(); - // Without FK attribute, it defaults to optional behavior - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - // Without belongs_to attribute, defaults to None - assert!(output.contains("user : None")); - } - - #[test] - fn test_generate_default_for_relation_field_has_one_no_fk_found() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let field_ident = syn::Ident::new("user", proc_macro2::Span::call_site()); - // No FK field in all_fields - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub id: i32 }").unwrap(); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[], &all_fields); - let output = tokens.to_string(); - // Without FK field found, defaults to None (optional behavior) - assert!(output.contains("user : None")); - } - - // Additional coverage tests for circular_fields via analyze_circular_refs - - #[test] - fn test_circular_fields_empty_module_path() { - // Edge case: empty module path - let result = - analyze_circular_refs(&[], "pub struct Schema { pub id: i32 }").circular_fields; - assert!(result.is_empty()); - } - - #[test] - fn test_circular_fields_option_box_pattern() { - // Test Option> pattern detection - let result = analyze_circular_refs( - &[ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ], - r"pub struct UserSchema { - pub id: i32, - pub memo: Option>, - }", - ) - .circular_fields; - assert_eq!(result, vec!["memo".to_string()]); - } - - #[test] - fn test_circular_fields_schema_suffix_pattern() { - // Test MemoSchema suffix pattern detection - let result = analyze_circular_refs( - &[ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ], - r"pub struct UserSchema { - pub id: i32, - pub memo: Box, - }", - ) - .circular_fields; - assert_eq!(result, vec!["memo".to_string()]); - } - - #[test] - fn test_circular_fields_field_without_ident() { - // Fields without identifiers (parsing edge case) - let result = analyze_circular_refs( - &["crate".to_string(), "test".to_string()], - r"pub struct Schema { - pub id: i32, - }", - ) - .circular_fields; - assert!(result.is_empty()); - } - - // Additional coverage for generate_inline_struct_construction - - #[test] - fn test_generate_inline_struct_construction_with_belongs_to_relation() { - let schema_path = quote! { memo::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - r"pub struct MemoSchema { - pub id: i32, - pub user_id: i32, - pub user: BelongsTo, - }", - &[], - "r", - ); - let output = tokens.to_string(); - assert!(output.contains("memo :: Schema")); - assert!(output.contains("id : r . id")); - assert!(output.contains("user_id : r . user_id")); - // BelongsTo should get default value - assert!(output.contains("user : None")); - } - - #[test] - fn test_generate_inline_struct_construction_with_has_one_relation() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - r"pub struct UserSchema { - pub id: i32, - pub profile: HasOne, - }", - &[], - "r", - ); - let output = tokens.to_string(); - assert!(output.contains("user :: Schema")); - assert!(output.contains("id : r . id")); - // HasOne should get default value - assert!(output.contains("profile : None")); - } - - // Additional coverage for generate_inline_type_construction - - #[test] - fn test_generate_inline_type_construction_skips_serde_skip() { - let inline_type_name = syn::Ident::new("TestInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &["id".to_string(), "internal".to_string()], - r"pub struct Model { - pub id: i32, - #[serde(skip)] - pub internal: String, - }", - "r", - ); - let output = tokens.to_string(); - assert!(output.contains("id : r . id")); - // serde(skip) field should be excluded - assert!(!output.contains("internal : r . internal")); - } - - #[test] - fn test_generate_inline_type_construction_empty_included_fields() { - let inline_type_name = syn::Ident::new("EmptyInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &[], // No fields included - r"pub struct Model { - pub id: i32, - pub name: String, - }", - "r", - ); - let output = tokens.to_string(); - // Should produce empty struct construction - assert!(output.contains("EmptyInline")); - assert!(!output.contains("id : r . id")); - assert!(!output.contains("name : r . name")); - } - - #[test] - fn test_generate_inline_type_construction_field_not_in_included() { - let inline_type_name = syn::Ident::new("PartialInline", proc_macro2::Span::call_site()); - let tokens = generate_inline_type_construction( - &inline_type_name, - &["id".to_string()], // Only id is included - r"pub struct Model { - pub id: i32, - pub name: String, - pub email: String, - }", - "r", - ); - let output = tokens.to_string(); - assert!(output.contains("id : r . id")); - // name and email should not be included - assert!(!output.contains("name : r . name")); - assert!(!output.contains("email : r . email")); - } - - // Tests for FK field lookup and required relation handling - - #[test] - fn test_circular_field_required_belongs_to_with_from_attr_required_fk() { - // Model has BelongsTo with sea_orm(from = "user_id") attribute and required FK - let model_def = r#"pub struct Model { - pub id: i32, - pub user_id: i32, - #[sea_orm(from = "user_id")] - pub user: BelongsTo, - }"#; - // FK field 'user_id' is i32 (required), so should return true - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("user") - .copied() - .unwrap_or(false); - assert!(result); - } - - #[test] - fn test_circular_field_required_belongs_to_with_from_attr_optional_fk() { - // Model has BelongsTo with sea_orm(from = "user_id") attribute and optional FK - let model_def = r#"pub struct Model { - pub id: i32, - pub user_id: Option, - #[sea_orm(from = "user_id")] - pub user: BelongsTo, - }"#; - // FK field 'user_id' is Option, so should return false - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("user") - .copied() - .unwrap_or(false); - assert!(!result); - } - - #[test] - fn test_circular_field_required_has_one_with_from_attr_required_fk() { - // Model has HasOne with sea_orm(from = "profile_id") attribute and required FK - let model_def = r#"pub struct Model { - pub id: i32, - pub profile_id: i64, - #[sea_orm(from = "profile_id")] - pub profile: HasOne, - }"#; - // FK field 'profile_id' is i64 (required), so should return true - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("profile") - .copied() - .unwrap_or(false); - assert!(result); - } - - #[test] - fn test_circular_field_required_from_attr_fk_field_not_found() { - // Model has from attribute but FK field doesn't exist - let model_def = r#"pub struct Model { - pub id: i32, - #[sea_orm(from = "nonexistent_field")] - pub user: BelongsTo, - }"#; - // FK field doesn't exist, so should return false - let result = analyze_circular_refs(&[], model_def) - .circular_field_required - .get("user") - .copied() - .unwrap_or(false); - assert!(!result); - } - - // Tests for generate_default_for_relation_field with required FK - - #[test] - fn test_generate_default_for_relation_field_belongs_to_with_from_attr_required() { - let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - let field_ident = syn::Ident::new("user", proc_macro2::Span::call_site()); - // FK field is required (not Option) - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub user_id: i32 }").unwrap(); - // Create proper sea_orm attribute with from - let attr: syn::Attribute = syn::parse_quote!(#[sea_orm(from = "user_id")]); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[attr], &all_fields); - let output = tokens.to_string(); - // Should produce Box::new(__parent_stub__.clone()) for required FK - assert!(output.contains("__parent_stub__")); - assert!(output.contains("Box :: new")); - } - - #[test] - fn test_generate_default_for_relation_field_has_one_with_from_attr_required() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let field_ident = syn::Ident::new("profile", proc_macro2::Span::call_site()); - // FK field is required (not Option) - let all_fields: syn::FieldsNamed = syn::parse_str("{ pub profile_id: i64 }").unwrap(); - // Create proper sea_orm attribute with from - let attr: syn::Attribute = syn::parse_quote!(#[sea_orm(from = "profile_id")]); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[attr], &all_fields); - let output = tokens.to_string(); - // Should produce Box::new(__parent_stub__.clone()) for required FK - assert!(output.contains("__parent_stub__")); - assert!(output.contains("Box :: new")); - } - - #[test] - fn test_generate_default_for_relation_field_has_one_with_from_attr_optional() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let field_ident = syn::Ident::new("profile", proc_macro2::Span::call_site()); - // FK field is optional - let all_fields: syn::FieldsNamed = - syn::parse_str("{ pub profile_id: Option }").unwrap(); - // Create proper sea_orm attribute with from - let attr: syn::Attribute = syn::parse_quote!(#[sea_orm(from = "profile_id")]); - let tokens = generate_default_for_relation_field(&ty, &field_ident, &[attr], &all_fields); - let output = tokens.to_string(); - // Should produce None for optional FK - assert!(output.contains("profile : None")); - } -} +mod tests; diff --git a/crates/vespera_macro/src/schema_macro/circular/tests.rs b/crates/vespera_macro/src/schema_macro/circular/tests.rs new file mode 100644 index 00000000..7194b720 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/circular/tests.rs @@ -0,0 +1,535 @@ +use quote::quote; +use rstest::rstest; + +use super::*; + +fn ident(name: &str) -> syn::Ident { + syn::Ident::new(name, proc_macro2::Span::call_site()) +} + +fn fields(src: &str) -> syn::FieldsNamed { + syn::parse_str(src).unwrap() +} + +fn required(def: &str, field: &str) -> bool { + analyze_circular_refs(&[], def) + .circular_field_required + .get(field) + .copied() + .unwrap_or(false) +} + +#[rstest] +#[case(&["crate", "models", "memo"], r"pub struct UserSchema { pub id: i32, pub memos: HasMany, }", vec![])] +#[case(&["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, pub user: BelongsTo, }", vec!["user".to_string()])] +#[case(&["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, pub user: HasOne, }", vec!["user".to_string()])] +#[case(&["crate", "models", "user"], r"pub struct MemoSchema { pub id: i32, pub user: Box, }", vec!["user".to_string()])] +#[case(&["crate", "models", "memo"], r"pub struct UserSchema { pub id: i32, pub name: String, }", vec![])] +fn test_detect_circular_fields( + #[case] source_module_path: &[&str], + #[case] related_schema_def: &str, + #[case] expected: Vec, +) { + let module_path: Vec = source_module_path.iter().map(ToString::to_string).collect(); + assert_eq!( + analyze_circular_refs(&module_path, related_schema_def).circular_fields, + expected + ); +} + +#[test] +fn test_detect_circular_fields_invalid_struct() { + assert!( + analyze_circular_refs(&["crate".to_string()], "not valid rust") + .circular_fields + .is_empty() + ); +} + +#[test] +fn test_detect_circular_fields_unnamed_fields() { + let path = vec![ + "crate".to_string(), + "models".to_string(), + "test".to_string(), + ]; + assert!( + analyze_circular_refs(&path, "pub struct TupleStruct(i32, String);") + .circular_fields + .is_empty() + ); +} + +#[rstest] +#[case( + r"pub struct Model { pub id: i32, pub user: BelongsTo, }", + true +)] +#[case( + r"pub struct Model { pub id: i32, pub user: HasOne, }", + true +)] +#[case(r"pub struct Model { pub id: i32, pub name: String, }", false)] +#[case( + r"pub struct Model { pub id: i32, pub items: HasMany, }", + false +)] +fn test_has_fk_relations(#[case] model_def: &str, #[case] expected: bool) { + assert_eq!( + analyze_circular_refs(&[], model_def).has_fk_relations, + expected + ); +} + +#[test] +fn test_has_fk_relations_invalid_struct() { + assert!(!analyze_circular_refs(&[], "not valid rust").has_fk_relations); +} + +#[test] +fn test_has_fk_relations_unnamed_fields() { + assert!(!analyze_circular_refs(&[], "pub struct TupleStruct(i32, String);").has_fk_relations); +} + +#[test] +fn test_is_circular_relation_required_invalid_struct() { + assert!(!required("not valid rust", "user")); +} + +#[test] +fn test_is_circular_relation_required_unnamed_fields() { + assert!(!required("pub struct TupleStruct(i32, String);", "user")); +} + +#[test] +fn test_is_circular_relation_required_field_not_found() { + assert!(!required( + "pub struct Model { pub id: i32, pub name: String, }", + "nonexistent" + )); +} + +#[test] +fn test_generate_default_for_relation_field_has_many() { + let ty: syn::Type = syn::parse_str("HasMany").unwrap(); + assert!( + generate_default_for_relation_field(&ty, &ident("users"), &[], &fields("{ pub id: i32 }")) + .to_string() + .contains("users : vec ! []") + ); +} + +#[test] +fn test_generate_default_for_relation_field_has_one_optional() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + assert!( + generate_default_for_relation_field( + &ty, + &ident("user"), + &[], + &fields("{ pub user_id: Option }") + ) + .to_string() + .contains("user : None") + ); +} + +#[test] +fn test_generate_default_for_relation_field_unknown_type() { + let ty: syn::Type = syn::parse_str("SomeUnknownType").unwrap(); + assert!( + generate_default_for_relation_field(&ty, &ident("field"), &[], &fields("{ pub id: i32 }")) + .to_string() + .contains("Default :: default ()") + ); +} + +#[test] +fn test_generate_inline_struct_construction_invalid_struct() { + assert!( + generate_inline_struct_construction( + "e! { user::Schema }, + "not valid rust", + &[], + "model" + ) + .to_string() + .contains("From") + ); +} + +#[test] +fn test_generate_inline_struct_construction_tuple_struct() { + assert!( + generate_inline_struct_construction( + "e! { user::Schema }, + "pub struct TupleStruct(i32, String);", + &[], + "model" + ) + .to_string() + .contains("From") + ); +} + +#[test] +fn test_generate_inline_struct_construction_with_fields() { + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, pub name: String, }", + &[], + "r", + ) + .to_string(); + assert!(output.contains("user :: Schema")); + assert!(output.contains("id : r . id")); + assert!(output.contains("name : r . name")); +} + +#[test] +fn test_generate_inline_struct_construction_with_circular_field() { + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, pub memos: HasMany, }", + &["memos".to_string()], + "r", + ) + .to_string(); + assert!(output.contains("user :: Schema")); + assert!(output.contains("id : r . id")); + assert!(output.contains("memos : vec ! []")); +} + +#[test] +fn test_generate_inline_struct_construction_skip_serde_skip_fields() { + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, #[serde(skip)] pub internal: String, }", + &[], + "r", + ) + .to_string(); + assert!(output.contains("id : r . id")); + assert!(!output.contains("internal : r . internal")); +} + +#[test] +fn test_generate_inline_type_construction_invalid_struct() { + assert!( + generate_inline_type_construction( + &ident("TestInline"), + &["id".to_string()], + "not valid rust", + "model" + ) + .to_string() + .contains("Default :: default ()") + ); +} + +#[test] +fn test_generate_inline_type_construction_tuple_struct() { + assert!( + generate_inline_type_construction( + &ident("TestInline"), + &["id".to_string()], + "pub struct TupleStruct(i32, String);", + "model" + ) + .to_string() + .contains("Default :: default ()") + ); +} + +#[test] +fn test_generate_inline_type_construction_with_fields() { + let output = generate_inline_type_construction( + &ident("UserInline"), + &["id".to_string(), "name".to_string()], + r"pub struct Model { pub id: i32, pub name: String, pub email: String, }", + "r", + ) + .to_string(); + assert!(output.contains("UserInline")); + assert!(output.contains("id : r . id")); + assert!(output.contains("name : r . name")); + assert!(!output.contains("email : r . email")); +} + +#[test] +fn test_generate_inline_type_construction_skips_relations() { + let output = generate_inline_type_construction( + &ident("UserInline"), + &["id".to_string(), "memos".to_string()], + r"pub struct Model { pub id: i32, pub memos: HasMany, }", + "r", + ) + .to_string(); + assert!(output.contains("id : r . id")); + assert!(!output.contains("memos : r . memos")); +} + +#[test] +fn test_circular_field_required_has_one_with_required_fk() { + assert!(!required( + r#"pub struct Model { pub id: i32, pub user_id: i32, #[sea_orm(belongs_to = "super::user::Entity", from = "Column::UserId", to = "super::user::Column::Id")] pub user: HasOne, }"#, + "user" + )); +} + +#[test] +fn test_circular_field_required_belongs_to_with_optional_fk() { + assert!(!required( + r#"pub struct Model { pub id: i32, pub user_id: Option, #[sea_orm(belongs_to = "super::user::Entity", from = "Column::UserId", to = "super::user::Column::Id")] pub user: BelongsTo, }"#, + "user" + )); +} + +#[test] +fn test_circular_field_required_non_relation_field() { + assert!(!required( + r"pub struct Model { pub id: i32, pub name: String, }", + "name" + )); +} + +#[test] +fn test_circular_field_required_field_without_ident() { + assert!(!required( + r"pub struct Model { pub id: i32, }", + "nonexistent_field" + )); +} + +#[test] +fn test_generate_default_for_relation_field_belongs_to_optional() { + let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); + assert!( + generate_default_for_relation_field( + &ty, + &ident("user"), + &[], + &fields("{ pub user_id: Option }") + ) + .to_string() + .contains("user : None") + ); +} + +#[test] +fn test_generate_default_for_relation_field_belongs_to_required() { + let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); + assert!( + generate_default_for_relation_field( + &ty, + &ident("user"), + &[], + &fields("{ pub user_id: i32 }") + ) + .to_string() + .contains("user : None") + ); +} + +#[test] +fn test_generate_default_for_relation_field_has_one_no_fk_found() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + assert!( + generate_default_for_relation_field(&ty, &ident("user"), &[], &fields("{ pub id: i32 }")) + .to_string() + .contains("user : None") + ); +} + +#[test] +fn test_circular_fields_empty_module_path() { + assert!( + analyze_circular_refs(&[], "pub struct Schema { pub id: i32 }") + .circular_fields + .is_empty() + ); +} + +#[test] +fn test_circular_fields_option_box_pattern() { + let path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + assert_eq!( + analyze_circular_refs( + &path, + r"pub struct UserSchema { pub id: i32, pub memo: Option>, }" + ) + .circular_fields, + vec!["memo".to_string()] + ); +} + +#[test] +fn test_circular_fields_schema_suffix_pattern() { + let path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + assert_eq!( + analyze_circular_refs( + &path, + r"pub struct UserSchema { pub id: i32, pub memo: Box, }" + ) + .circular_fields, + vec!["memo".to_string()] + ); +} + +#[test] +fn test_circular_fields_field_without_ident() { + let path = vec!["crate".to_string(), "test".to_string()]; + assert!( + analyze_circular_refs(&path, r"pub struct Schema { pub id: i32, }") + .circular_fields + .is_empty() + ); +} + +#[test] +fn test_generate_inline_struct_construction_with_belongs_to_relation() { + let output = generate_inline_struct_construction("e! { memo::Schema }, r"pub struct MemoSchema { pub id: i32, pub user_id: i32, pub user: BelongsTo, }", &[], "r").to_string(); + assert!(output.contains("memo :: Schema")); + assert!(output.contains("id : r . id")); + assert!(output.contains("user_id : r . user_id")); + assert!(output.contains("user : None")); +} + +#[test] +fn test_generate_inline_struct_construction_with_has_one_relation() { + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, pub profile: HasOne, }", + &[], + "r", + ) + .to_string(); + assert!(output.contains("user :: Schema")); + assert!(output.contains("id : r . id")); + assert!(output.contains("profile : None")); +} + +#[test] +fn test_generate_inline_type_construction_skips_serde_skip() { + let output = generate_inline_type_construction( + &ident("TestInline"), + &["id".to_string(), "internal".to_string()], + r"pub struct Model { pub id: i32, #[serde(skip)] pub internal: String, }", + "r", + ) + .to_string(); + assert!(output.contains("id : r . id")); + assert!(!output.contains("internal : r . internal")); +} + +#[test] +fn test_generate_inline_type_construction_empty_included_fields() { + let output = generate_inline_type_construction( + &ident("EmptyInline"), + &[], + r"pub struct Model { pub id: i32, pub name: String, }", + "r", + ) + .to_string(); + assert!(output.contains("EmptyInline")); + assert!(!output.contains("id : r . id")); + assert!(!output.contains("name : r . name")); +} + +#[test] +fn test_generate_inline_type_construction_field_not_in_included() { + let output = generate_inline_type_construction( + &ident("PartialInline"), + &["id".to_string()], + r"pub struct Model { pub id: i32, pub name: String, pub email: String, }", + "r", + ) + .to_string(); + assert!(output.contains("id : r . id")); + assert!(!output.contains("name : r . name")); + assert!(!output.contains("email : r . email")); +} + +#[test] +fn test_circular_field_required_belongs_to_with_from_attr_required_fk() { + assert!(required( + r#"pub struct Model { pub id: i32, pub user_id: i32, #[sea_orm(from = "user_id")] pub user: BelongsTo, }"#, + "user" + )); +} + +#[test] +fn test_circular_field_required_belongs_to_with_from_attr_optional_fk() { + assert!(!required( + r#"pub struct Model { pub id: i32, pub user_id: Option, #[sea_orm(from = "user_id")] pub user: BelongsTo, }"#, + "user" + )); +} + +#[test] +fn test_circular_field_required_has_one_with_from_attr_required_fk() { + assert!(required( + r#"pub struct Model { pub id: i32, pub profile_id: i64, #[sea_orm(from = "profile_id")] pub profile: HasOne, }"#, + "profile" + )); +} + +#[test] +fn test_circular_field_required_from_attr_fk_field_not_found() { + assert!(!required( + r#"pub struct Model { pub id: i32, #[sea_orm(from = "nonexistent_field")] pub user: BelongsTo, }"#, + "user" + )); +} + +#[test] +fn test_generate_default_for_relation_field_belongs_to_with_from_attr_required() { + let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); + let attr: syn::Attribute = syn::parse_quote!(#[sea_orm(from = "user_id")]); + let output = generate_default_for_relation_field( + &ty, + &ident("user"), + &[attr], + &fields("{ pub user_id: i32 }"), + ) + .to_string(); + assert!(output.contains("__parent_stub__")); + assert!(output.contains("Box :: new")); +} + +#[test] +fn test_generate_default_for_relation_field_has_one_with_from_attr_required() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let attr: syn::Attribute = syn::parse_quote!(#[sea_orm(from = "profile_id")]); + let output = generate_default_for_relation_field( + &ty, + &ident("profile"), + &[attr], + &fields("{ pub profile_id: i64 }"), + ) + .to_string(); + assert!(output.contains("__parent_stub__")); + assert!(output.contains("Box :: new")); +} + +#[test] +fn test_generate_default_for_relation_field_has_one_with_from_attr_optional() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let attr: syn::Attribute = syn::parse_quote!(#[sea_orm(from = "profile_id")]); + let output = generate_default_for_relation_field( + &ty, + &ident("profile"), + &[attr], + &fields("{ pub profile_id: Option }"), + ) + .to_string(); + assert!(output.contains("profile : None")); +} diff --git a/crates/vespera_macro/src/schema_macro/codegen.rs b/crates/vespera_macro/src/schema_macro/codegen.rs index c613b76a..6bf84d6f 100644 --- a/crates/vespera_macro/src/schema_macro/codegen.rs +++ b/crates/vespera_macro/src/schema_macro/codegen.rs @@ -1,230 +1,149 @@ -//! Code generation utilities for schema macros +//! Code generation for the `schema!` macro. //! -//! Provides functions to convert schema structures to `TokenStream` for code generation. - -use std::collections::HashSet; +//! `schema!(Type, pick/omit)` must return a runtime [`Schema`] that is +//! **identical** to the OpenAPI component schema generated for `Type`. +//! To guarantee that, this module does NOT re-implement schema +//! construction: it calls the shared [`parse_struct_to_schema`] path (the +//! single source of truth the OpenAPI generator also uses), applies the +//! `pick`/`omit` field filter, serializes the resulting [`Schema`] to JSON +//! at compile time, and emits a [`Schema::from_compiled_json`] call. The +//! runtime value is reconstructed byte-for-byte from that spec, so +//! `schema!` can never drift from the documented component schema +//! (required-by-nullability, doc descriptions, `flatten`/`transparent` +//! composition, field constraints, and `$ref` references). + +use std::collections::{HashMap, HashSet}; use proc_macro2::TokenStream; use quote::quote; -use vespera_core::schema::{Schema, SchemaRef, SchemaType}; +use vespera_core::schema::Schema; -use super::type_utils::is_option_type; use crate::{ metadata::StructMetadata, parser::{ - extract_default, extract_field_rename, extract_rename_all, extract_skip, - extract_skip_serializing_if, parse_type_to_schema_ref, rename_field, - strip_raw_prefix_owned, + extract_field_rename, extract_rename_all, extract_skip, parse_struct_to_schema, + rename_field, strip_raw_prefix_owned, }, }; -/// Generate Schema construction code with field filtering -#[allow(clippy::option_if_let_else)] +/// Generate a `schema!` expression: a runtime [`Schema`] identical to the +/// OpenAPI component schema for `struct_item`, after applying `pick`/`omit`. +/// +/// The schema is built through the shared [`parse_struct_to_schema`] path, +/// serialized at compile time, and reconstructed at runtime via +/// [`Schema::from_compiled_json`] — so the `schema!` result and the +/// generated OpenAPI component schema can never diverge. pub fn generate_filtered_schema( struct_item: &syn::ItemStruct, omit_set: &HashSet, pick_set: &HashSet, - schema_storage: &std::collections::HashMap, + schema_storage: &HashMap, ) -> TokenStream { - let rename_all = extract_rename_all(&struct_item.attrs); + let schema = build_filtered_schema(struct_item, omit_set, pick_set, schema_storage); + // Serialize at compile time; the runtime value is reconstructed from + // this spec so it cannot diverge from the OpenAPI component schema. + let json = serde_json::to_string(&schema).expect("Schema serialization is infallible"); + quote! { + vespera::schema::Schema::from_compiled_json(#json) + } +} - // Build known_schemas and struct_definitions for type resolution +/// Build the filtered [`Schema`] value for `schema!` — the OpenAPI +/// component schema for `struct_item` with the `pick`/`omit` field filter +/// applied. +/// +/// Split out from [`generate_filtered_schema`] so the filtering semantics +/// are unit-testable on the produced value (rather than on the emitted +/// token string). +fn build_filtered_schema( + struct_item: &syn::ItemStruct, + omit_set: &HashSet, + pick_set: &HashSet, + schema_storage: &HashMap, +) -> Schema { + // Same resolution context the OpenAPI component path builds: every + // known schema name (for `$ref` resolution) and its source definition + // (for generic expansion). let known_schemas: HashSet = schema_storage.keys().cloned().collect(); - let struct_definitions: std::collections::HashMap = schema_storage + let struct_definitions: HashMap = schema_storage .values() .map(|s| (s.name.clone(), s.definition.clone())) .collect(); - let mut property_tokens = Vec::new(); - let mut required_fields = Vec::new(); + // Single source of truth — identical logic to OpenAPI generation + // (required-by-nullability, doc descriptions, flatten/transparent, + // field constraints, `$ref` references). + let mut schema = parse_struct_to_schema(struct_item, &known_schemas, &struct_definitions); + + // `schema!` layers field filtering on top: keep only the picked / + // non-omitted properties (matched against BOTH the Rust identifier and + // the serde-renamed JSON name, as the prior hand-rolled walk did). + if let Some(keep) = compute_kept_json_names(struct_item, omit_set, pick_set) { + filter_schema_fields(&mut schema, &keep); + } + + schema +} +/// Compute the set of serde-renamed JSON field names that survive the +/// `pick`/`omit` filter, or `None` when no filtering is requested (both +/// sets empty → keep every field). +/// +/// Mirrors the OpenAPI field walk: `#[serde(skip)]` fields never qualify, +/// and a name matches `omit`/`pick` against EITHER its Rust identifier or +/// its serde-renamed JSON name. +fn compute_kept_json_names( + struct_item: &syn::ItemStruct, + omit_set: &HashSet, + pick_set: &HashSet, +) -> Option> { + if omit_set.is_empty() && pick_set.is_empty() { + return None; + } + let rename_all = extract_rename_all(&struct_item.attrs); + let mut keep = HashSet::new(); if let syn::Fields::Named(fields_named) = &struct_item.fields { for field in &fields_named.named { - // Skip if serde(skip) if extract_skip(&field.attrs) { continue; } - let rust_field_name = field.ident.as_ref().map_or_else( || "unknown".to_string(), |i| strip_raw_prefix_owned(i.to_string()), ); - - // Apply rename let field_name = extract_field_rename(&field.attrs) .unwrap_or_else(|| rename_field(&rust_field_name, rename_all.as_deref())); - - // Apply omit filter (check both rust name and json name) if !omit_set.is_empty() && (omit_set.contains(&rust_field_name) || omit_set.contains(&field_name)) { continue; } - - // Apply pick filter (check both rust name and json name) if !pick_set.is_empty() && !pick_set.contains(&rust_field_name) && !pick_set.contains(&field_name) { continue; } - - let field_type = &field.ty; - - // Generate schema for field type - let schema_ref = - parse_type_to_schema_ref(field_type, &known_schemas, &struct_definitions); - let schema_ref_tokens = schema_ref_to_tokens(&schema_ref); - - property_tokens.push(quote! { - properties.insert(#field_name.to_string(), #schema_ref_tokens); - }); - - // Check if field is required (not Option, no default, no skip_serializing_if) - let has_default = extract_default(&field.attrs).is_some(); - let has_skip_serializing_if = extract_skip_serializing_if(&field.attrs); - let is_optional = is_option_type(field_type); - - if !is_optional && !has_default && !has_skip_serializing_if { - required_fields.push(field_name.clone()); - } - } - } - - let required_tokens = if required_fields.is_empty() { - quote! { None } - } else { - let required_strs: Vec<&str> = required_fields - .iter() - .map(std::string::String::as_str) - .collect(); - quote! { Some(vec![#(#required_strs.to_string()),*]) } - }; - - quote! { - { - let mut properties = std::collections::BTreeMap::new(); - #(#property_tokens)* - vespera::schema::Schema { - schema_type: Some(vespera::schema::SchemaType::Object), - properties: if properties.is_empty() { None } else { Some(properties) }, - required: #required_tokens, - ..vespera::schema::Schema::default() - } + keep.insert(field_name); } } + Some(keep) } -/// Convert `SchemaType` enum variant to its `TokenStream` representation. -/// -/// `SchemaType` is a unit enum that derives `Copy`, so taking it by value -/// is strictly cheaper than borrowing (satisfies -/// `clippy::trivially_copy_pass_by_ref`). -fn schema_type_to_tokens(st: SchemaType) -> TokenStream { - let variant = match st { - SchemaType::String => "String", - SchemaType::Number => "Number", - SchemaType::Integer => "Integer", - SchemaType::Boolean => "Boolean", - SchemaType::Array => "Array", - SchemaType::Object => "Object", - SchemaType::Null => "Null", - }; - let ident = syn::Ident::new(variant, proc_macro2::Span::call_site()); - quote! { vespera::schema::SchemaType::#ident } -} - -/// Convert `SchemaRef` to `TokenStream` for code generation -pub fn schema_ref_to_tokens(schema_ref: &SchemaRef) -> TokenStream { - match schema_ref { - SchemaRef::Ref(reference) => { - let ref_path = &reference.ref_path; - quote! { - vespera::schema::SchemaRef::Ref(vespera::schema::Reference::new(#ref_path.to_string())) - } - } - SchemaRef::Inline(schema) => { - let schema_tokens = schema_to_tokens(schema); - quote! { - vespera::schema::SchemaRef::Inline(Box::new(#schema_tokens)) - } +/// Retain only `keep` properties (and matching `required` entries) on +/// `schema`, normalizing an emptied `properties`/`required` back to `None` +/// to match [`parse_struct_to_schema`]'s own representation. +fn filter_schema_fields(schema: &mut Schema, keep: &HashSet) { + if let Some(properties) = &mut schema.properties { + properties.retain(|name, _| keep.contains(name)); + if properties.is_empty() { + schema.properties = None; } } -} - -/// Convert Schema to `TokenStream` for code generation. -/// -/// Only emits non-None fields, using `..Default::default()` for the rest. -/// This reduces generated code volume by ~70% for typical schemas -/// (e.g., a String field: 3 tokens instead of 10). -pub fn schema_to_tokens(schema: &Schema) -> TokenStream { - let mut fields: Vec = Vec::with_capacity(4); - - // schema_type - if let Some(st) = schema.schema_type { - let st_tokens = schema_type_to_tokens(st); - fields.push(quote! { schema_type: Some(#st_tokens) }); - } - - // ref_path - if let Some(rp) = &schema.ref_path { - fields.push(quote! { ref_path: Some(#rp.to_string()) }); - } - - // format - if let Some(f) = &schema.format { - fields.push(quote! { format: Some(#f.to_string()) }); - } - - // nullable - if let Some(n) = schema.nullable { - fields.push(quote! { nullable: Some(#n) }); - } - - // items - if let Some(items) = &schema.items { - let inner = schema_ref_to_tokens(items); - fields.push(quote! { items: Some(Box::new(#inner)) }); - } - - // properties - if let Some(props) = &schema.properties { - let entries: Vec<_> = props - .iter() - .map(|(k, v)| { - let v_tokens = schema_ref_to_tokens(v); - quote! { (#k.to_string(), #v_tokens) } - }) - .collect(); - fields.push(quote! { - properties: Some({ - let mut map = std::collections::BTreeMap::new(); - #(map.insert(#entries.0, #entries.1);)* - map - }) - }); - } - - // required - if let Some(req) = &schema.required { - let req_strs: Vec<_> = req.iter().map(std::string::String::as_str).collect(); - fields.push(quote! { required: Some(vec![#(#req_strs.to_string()),*]) }); - } - - // minimum - if let Some(min) = schema.minimum { - fields.push(quote! { minimum: Some(#min) }); - } - - // maximum - if let Some(max) = schema.maximum { - fields.push(quote! { maximum: Some(#max) }); - } - - quote! { - vespera::schema::Schema { - #(#fields,)* - ..vespera::schema::Schema::default() + if let Some(required) = &mut schema.required { + required.retain(|name| keep.contains(name)); + if required.is_empty() { + schema.required = None; } } } @@ -233,250 +152,153 @@ pub fn schema_to_tokens(schema: &Schema) -> TokenStream { mod tests { use std::collections::{HashMap, HashSet}; - use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType}; - - use super::*; - - #[test] - fn test_generate_filtered_schema_empty_properties() { - let struct_item: syn::ItemStruct = syn::parse_str("pub struct Empty {}").unwrap(); - let omit_set = HashSet::new(); - let pick_set = HashSet::new(); - let output = generate_filtered_schema(&struct_item, &omit_set, &pick_set, &HashMap::new()) - .to_string(); - assert!(output.contains("properties")); - } - - #[test] - fn test_generate_filtered_schema_with_default_field() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - pub struct WithDefault { - #[serde(default)] - pub field: String, - } - ", - ) - .unwrap(); - let omit_set = HashSet::new(); - let pick_set = HashSet::new(); - let output = generate_filtered_schema(&struct_item, &omit_set, &pick_set, &HashMap::new()) - .to_string(); - assert!(output.contains("None")); - } - - #[test] - fn test_generate_filtered_schema_with_skip_serializing_if() { - let struct_item: syn::ItemStruct = syn::parse_str( - r#" - pub struct WithSkip { - #[serde(skip_serializing_if = "Option::is_none")] - pub field: String, - } - "#, - ) - .unwrap(); - let omit_set = HashSet::new(); - let pick_set = HashSet::new(); - let _output = generate_filtered_schema(&struct_item, &omit_set, &pick_set, &HashMap::new()); - } + use crate::metadata::StructMetadata; - #[test] - fn test_generate_filtered_schema_tuple_struct() { - let struct_item: syn::ItemStruct = - syn::parse_str("pub struct Tuple(i32, String);").unwrap(); - let omit_set = HashSet::new(); - let pick_set = HashSet::new(); - let _output = generate_filtered_schema(&struct_item, &omit_set, &pick_set, &HashMap::new()); - } + use super::{build_filtered_schema, generate_filtered_schema}; - #[test] - fn test_schema_ref_to_tokens_ref_variant() { - let schema_ref = SchemaRef::Ref(Reference::new("#/components/schemas/User".to_string())); - let tokens = schema_ref_to_tokens(&schema_ref); - let output = tokens.to_string(); - assert!(output.contains("SchemaRef :: Ref")); - assert!(output.contains("Reference :: new")); + fn empty_storage() -> HashMap { + HashMap::new() } - #[test] - fn test_schema_ref_to_tokens_inline_variant() { - let schema = Schema::new(SchemaType::String); - let schema_ref = SchemaRef::Inline(Box::new(schema)); - let tokens = schema_ref_to_tokens(&schema_ref); - let output = tokens.to_string(); - assert!(output.contains("SchemaRef :: Inline")); - assert!(output.contains("Box :: new")); + fn parse(src: &str) -> syn::ItemStruct { + syn::parse_str(src).expect("valid struct source") } + /// Regression for the schema!↔OpenAPI drift: a `#[serde(default)]` + /// non-`Option` field must be REQUIRED (required is nullability-only, + /// identical to the OpenAPI component schema). The prior `schema!` + /// path wrongly excluded defaulted / `skip_serializing_if` fields. #[test] - fn test_schema_to_tokens_string_type() { - let schema = Schema::new(SchemaType::String); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("SchemaType :: String")); - } - - #[test] - fn test_schema_to_tokens_integer_type() { - let schema = Schema::new(SchemaType::Integer); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("SchemaType :: Integer")); - } - - #[test] - fn test_schema_to_tokens_number_type() { - let schema = Schema::new(SchemaType::Number); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("SchemaType :: Number")); - } - - #[test] - fn test_schema_to_tokens_boolean_type() { - let schema = Schema::new(SchemaType::Boolean); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("SchemaType :: Boolean")); - } - - #[test] - fn test_schema_to_tokens_array_type() { - let schema = Schema::new(SchemaType::Array); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("SchemaType :: Array")); - } - - #[test] - fn test_schema_to_tokens_object_type() { - let schema = Schema::new(SchemaType::Object); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("SchemaType :: Object")); - } - - #[test] - fn test_schema_to_tokens_null_type() { - let schema = Schema::new(SchemaType::Null); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("SchemaType :: Null")); + fn default_field_is_required_matching_openapi() { + let item = parse(r"pub struct WithDefault { #[serde(default)] pub field: String }"); + let schema = + build_filtered_schema(&item, &HashSet::new(), &HashSet::new(), &empty_storage()); + let required = schema.required.expect("required set present"); + assert!( + required.iter().any(|f| f == "field"), + "a defaulted non-Option field must be required, got {required:?}" + ); } #[test] - fn test_schema_to_tokens_none_type() { - let schema = Schema { - schema_type: None, - ..Default::default() - }; - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - // With conditional emission, schema_type is omitted when None - // (..Default::default() provides None) - assert!(!output.contains("schema_type")); - assert!(output.contains("default")); + fn skip_serializing_if_field_is_required() { + let item = parse( + r#"pub struct WithSkip { #[serde(skip_serializing_if = "Option::is_none")] pub field: String }"#, + ); + let schema = + build_filtered_schema(&item, &HashSet::new(), &HashSet::new(), &empty_storage()); + assert!( + schema + .required + .expect("required present") + .iter() + .any(|f| f == "field"), + "skip_serializing_if must not affect required (nullability-only)" + ); } #[test] - fn test_schema_to_tokens_with_format() { - let mut schema = Schema::new(SchemaType::String); - schema.format = Some("date-time".to_string()); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("date-time")); + fn option_field_is_not_required() { + let item = parse(r"pub struct WithOpt { pub field: Option }"); + let schema = + build_filtered_schema(&item, &HashSet::new(), &HashSet::new(), &empty_storage()); + let still_required = schema + .required + .as_ref() + .is_some_and(|r| r.iter().any(|f| f == "field")); + assert!(!still_required, "an Option field must not be required"); } #[test] - fn test_schema_to_tokens_with_nullable() { - let mut schema = Schema::new(SchemaType::String); - schema.nullable = Some(true); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("Some (true)")); + fn omit_excludes_field_from_properties_and_required() { + let item = parse(r"pub struct S { pub a: String, pub b: i32 }"); + let mut omit = HashSet::new(); + omit.insert("b".to_string()); + let schema = build_filtered_schema(&item, &omit, &HashSet::new(), &empty_storage()); + let props = schema.properties.expect("properties present"); + assert!(props.contains_key("a")); + assert!(!props.contains_key("b"), "omitted field must be gone"); + assert!( + !schema.required.unwrap_or_default().iter().any(|f| f == "b"), + "omitted field must not remain required" + ); } #[test] - fn test_schema_to_tokens_nullable_false() { - let mut schema = Schema::new(SchemaType::String); - schema.nullable = Some(false); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("Some (false)")); + fn pick_keeps_only_selected_fields() { + let item = parse(r"pub struct S { pub a: String, pub b: i32, pub c: bool }"); + let mut pick = HashSet::new(); + pick.insert("a".to_string()); + let schema = build_filtered_schema(&item, &HashSet::new(), &pick, &empty_storage()); + let props = schema.properties.expect("properties present"); + assert_eq!(props.len(), 1); + assert!(props.contains_key("a")); } #[test] - fn test_schema_to_tokens_with_ref_path() { - let mut schema = Schema::new(SchemaType::Object); - schema.ref_path = Some("#/components/schemas/User".to_string()); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("#/components/schemas/User")); + fn serde_skip_field_excluded() { + let item = parse(r"pub struct S { pub a: String, #[serde(skip)] pub hidden: i32 }"); + let schema = + build_filtered_schema(&item, &HashSet::new(), &HashSet::new(), &empty_storage()); + let props = schema.properties.expect("properties present"); + assert!(props.contains_key("a")); + assert!(!props.contains_key("hidden"), "serde(skip) field excluded"); } #[test] - fn test_schema_to_tokens_with_items() { - let mut schema = Schema::new(SchemaType::Array); - let item_schema = Schema::new(SchemaType::String); - schema.items = Some(Box::new(SchemaRef::Inline(Box::new(item_schema)))); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("items")); - assert!(output.contains("Some (Box :: new")); + fn pick_matches_renamed_json_name() { + let item = parse( + r#"#[serde(rename_all = "camelCase")] pub struct S { pub user_name: String, pub age: i32 }"#, + ); + let mut pick = HashSet::new(); + pick.insert("userName".to_string()); + let schema = build_filtered_schema(&item, &HashSet::new(), &pick, &empty_storage()); + let props = schema.properties.expect("properties present"); + assert!(props.contains_key("userName")); + assert!(!props.contains_key("age")); } #[test] - fn test_schema_to_tokens_with_properties() { - use std::collections::BTreeMap; - - let mut schema = Schema::new(SchemaType::Object); - let mut props = BTreeMap::new(); - props.insert( - "name".to_string(), - SchemaRef::Inline(Box::new(Schema::new(SchemaType::String))), + fn omit_matches_rust_name_even_when_renamed() { + let item = parse( + r#"#[serde(rename_all = "camelCase")] pub struct S { pub user_name: String, pub age: i32 }"#, ); - schema.properties = Some(props); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("properties")); - assert!(output.contains("name")); + let mut omit = HashSet::new(); + omit.insert("user_name".to_string()); // Rust identifier, not the JSON name + let schema = build_filtered_schema(&item, &omit, &HashSet::new(), &empty_storage()); + let props = schema.properties.expect("properties present"); + assert!(!props.contains_key("userName"), "omit by Rust name works"); + assert!(props.contains_key("age")); } #[test] - fn test_schema_to_tokens_with_required() { - let mut schema = Schema::new(SchemaType::Object); - schema.required = Some(vec!["id".to_string(), "name".to_string()]); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!(output.contains("required")); - assert!(output.contains("id")); - assert!(output.contains("name")); + fn empty_struct_has_no_properties() { + let item = parse("pub struct Empty {}"); + let schema = + build_filtered_schema(&item, &HashSet::new(), &HashSet::new(), &empty_storage()); + assert!(schema.properties.is_none()); } #[test] - fn test_schema_to_tokens_with_minimum() { - let mut schema = Schema::new(SchemaType::Integer); - schema.minimum = Some(0.0); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); - assert!( - output.contains("minimum"), - "should contain minimum: {output}" - ); - assert!(output.contains("Some"), "should contain Some: {output}"); + fn tuple_struct_produces_no_properties() { + let item = parse("pub struct Tuple(i32, String);"); + let schema = + build_filtered_schema(&item, &HashSet::new(), &HashSet::new(), &empty_storage()); + assert!(schema.properties.is_none()); } #[test] - fn test_schema_to_tokens_with_maximum() { - let mut schema = Schema::new(SchemaType::Integer); - schema.maximum = Some(255.0); - let tokens = schema_to_tokens(&schema); - let output = tokens.to_string(); + fn generate_emits_from_compiled_json_call() { + let item = parse(r"pub struct S { pub a: String }"); + let output = + generate_filtered_schema(&item, &HashSet::new(), &HashSet::new(), &empty_storage()) + .to_string(); assert!( - output.contains("maximum"), - "should contain maximum: {output}" + output.contains("from_compiled_json"), + "schema! must emit a from_compiled_json reconstruction, got: {output}" ); - assert!(output.contains("Some"), "should contain Some: {output}"); + // The serialized spec carries the property + required set. + assert!(output.contains("properties"), "spec must carry properties"); + assert!(output.contains("required"), "spec must carry required"); } } diff --git a/crates/vespera_macro/src/schema_macro/defaults.rs b/crates/vespera_macro/src/schema_macro/defaults.rs new file mode 100644 index 00000000..c20f23fa --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/defaults.rs @@ -0,0 +1,254 @@ +//! SeaORM default-value attribute generation. +//! +//! Translates `#[sea_orm(default_value = ...)]` / `#[sea_orm(primary_key)]` +//! on source fields into `#[serde(default = "...")]` + `#[schema(default = "...")]` +//! attributes (plus companion default functions) on the generated struct. + +use proc_macro2::TokenStream; +use quote::quote; + +use super::seaorm::{ + extract_sea_orm_default_value, has_sea_orm_primary_key, is_sql_function_default, +}; +use super::type_utils; +use crate::parser::extract_default; + +/// Generate `#[serde(default = "...")]` and `#[schema(default = "...")]` attributes +/// from `#[sea_orm(default_value = ...)]` or `#[sea_orm(primary_key)]` on source fields. +/// +/// Returns `(serde_default_attr, schema_default_attr)` as `TokenStream`s. +/// - `serde_default_attr`: `#[serde(default = "default_structname_field")]` for deserialization +/// - `schema_default_attr`: `#[schema(default = "value")]` for OpenAPI default value +/// +/// Also generates a companion default function and appends it to `default_functions`. +/// +/// Handles three categories of defaults: +/// 1. **Literal defaults** (`default_value = "42"`, `"draft"`, `0.7`): +/// Generates parse-based default function + schema default. +/// 2. **SQL function defaults** (`default_value = "NOW()"`, `"gen_random_uuid()"`): +/// Generates type-specific default function + schema default with type's zero value. +/// 3. **Primary key** (implicit auto-increment): +/// Treated as having an implicit default — generates type-specific default. +/// +/// Skips serde default generation when: +/// - The field is wrapped in `Option` (partial mode or already optional) +/// - The field already has `#[serde(default)]` +/// - For literal defaults: the field type doesn't implement `FromStr` +pub(super) fn generate_sea_orm_default_attrs( + original_attrs: &[syn::Attribute], + struct_name: &syn::Ident, + field_name: &str, + original_ty: &syn::Type, + field_ty: &dyn quote::ToTokens, + is_optional_or_partial: bool, + default_functions: &mut Vec, +) -> (TokenStream, TokenStream) { + // Don't generate defaults for optional/partial fields + if is_optional_or_partial { + return (quote! {}, quote! {}); + } + + // Check for sea_orm(default_value) and sea_orm(primary_key) + let default_value = extract_sea_orm_default_value(original_attrs); + let has_pk = has_sea_orm_primary_key(original_attrs); + + // No default source found + if default_value.is_none() && !has_pk { + return (quote! {}, quote! {}); + } + + let has_existing_serde_default = extract_default(original_attrs).is_some(); + + match &default_value { + // Literal default (e.g., "42", "draft", "0.7") + Some(value) if !is_sql_function_default(value) => { + let schema_default_attr = quote! { #[schema(default = #value)] }; + + if has_existing_serde_default { + return (quote! {}, schema_default_attr); + } + + if !is_parseable_type(original_ty) { + return (quote! {}, schema_default_attr); + } + + let fn_name = format!("default_{struct_name}_{field_name}"); + let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); + + // Validate the literal against the field's type at macro-expansion + // time: a malformed `default_value` (e.g. `"abc"` on an `i32`, or + // `"300"` on a `u8`) becomes a COMPILE error pointing at the field + // instead of the runtime panic the generated `#value.parse().unwrap()` + // would raise the first time serde fills a missing field. A valid + // literal keeps the byte-identical prior `.parse().unwrap()` body, so + // no currently-valid default changes behaviour. + let fn_body = match validate_literal_default(value, original_ty) { + Ok(()) => quote! { #value.parse().unwrap() }, + Err(msg) => syn::Error::new_spanned(original_ty, msg).to_compile_error(), + }; + + default_functions.push(quote! { + #[allow(non_snake_case)] + fn #fn_ident() -> #field_ty { + #fn_body + } + }); + + let serde_default_attr = quote! { #[serde(default = #fn_name)] }; + (serde_default_attr, schema_default_attr) + } + // SQL function default (NOW(), gen_random_uuid(), etc.) or primary_key auto-increment + _ => { + let Some((default_expr, schema_default_str)) = + sql_function_default_for_type(original_ty) + else { + return (quote! {}, quote! {}); + }; + + let schema_default_attr = quote! { #[schema(default = #schema_default_str)] }; + + if has_existing_serde_default { + return (quote! {}, schema_default_attr); + } + + let fn_name = format!("default_{struct_name}_{field_name}"); + let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); + + default_functions.push(quote! { + #[allow(non_snake_case)] + fn #fn_ident() -> #field_ty { + #default_expr + } + }); + + let serde_default_attr = quote! { #[serde(default = #fn_name)] }; + (serde_default_attr, schema_default_attr) + } + } +} + +/// Return a type-appropriate (Rust default expression, OpenAPI default string) pair +/// for fields with SQL function defaults or implicit auto-increment. +/// +/// The Rust expression is used in the generated `#[serde(default = "fn")]` function body. +/// The OpenAPI string is used in `#[schema(default = "value")]`. +fn sql_function_default_for_type(original_ty: &syn::Type) -> Option<(TokenStream, String)> { + let syn::Type::Path(type_path) = original_ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + let type_name = segment.ident.to_string(); + + match type_name.as_str() { + "DateTimeWithTimeZone" | "DateTimeUtc" | "DateTime" => { + let expr = quote! { + vespera::chrono::DateTime::::UNIX_EPOCH.fixed_offset() + }; + Some((expr, "1970-01-01T00:00:00+00:00".to_string())) + } + "NaiveDateTime" => { + let expr = quote! { + vespera::chrono::NaiveDateTime::UNIX_EPOCH + }; + Some((expr, "1970-01-01T00:00:00".to_string())) + } + "NaiveDate" => { + let expr = quote! { + vespera::chrono::NaiveDate::default() + }; + Some((expr, "1970-01-01".to_string())) + } + "NaiveTime" | "Time" => { + let expr = quote! { + vespera::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap() + }; + Some((expr, "00:00:00".to_string())) + } + "Uuid" => Some(( + quote! { Default::default() }, + "00000000-0000-0000-0000-000000000000".to_string(), + )), + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128" + | "usize" | "f32" | "f64" | "Decimal" => { + Some((quote! { Default::default() }, "0".to_string())) + } + "bool" => Some((quote! { Default::default() }, "false".to_string())), + "String" => Some((quote! { Default::default() }, String::new())), + _ => None, + } +} + +/// Check if a type is known to implement `FromStr` and can use `.parse().unwrap()`. +/// +/// Returns true for primitive types, String, and Decimal. +/// Returns false for enums and unknown custom types. +pub(super) fn is_parseable_type(ty: &syn::Type) -> bool { + let syn::Type::Path(type_path) = ty else { + return false; + }; + let Some(segment) = type_path.path.segments.last() else { + return false; + }; + type_utils::PRIMITIVE_TYPE_NAMES.contains(&segment.ident.to_string().as_str()) +} + +/// Validate a literal `default_value` against the field's type **at +/// macro-expansion time**, mirroring exactly the runtime `#value.parse()` +/// the generated default function performs (no trimming — the generated +/// code does not trim either, so this predicts the runtime result precisely). +/// +/// Returns `Err(msg)` when the literal cannot parse to the concrete field +/// type, so the caller emits a `compile_error!` (pointing at the field) +/// instead of generating a `.parse().unwrap()` that panics the first time +/// serde fills a missing field. Types whose `FromStr` cannot be faithfully +/// reproduced here return `Ok(())`: +/// - `String` — its `FromStr` is infallible. +/// - `Decimal` — needs the `rust_decimal` runtime crate; left to runtime. +/// - any non-primitive / unknown type — already gated out by +/// [`is_parseable_type`] before this is reached. +fn validate_literal_default(value: &str, ty: &syn::Type) -> Result<(), String> { + let syn::Type::Path(type_path) = ty else { + return Ok(()); + }; + let Some(segment) = type_path.path.segments.last() else { + return Ok(()); + }; + let type_name = segment.ident.to_string(); + + // Parse against the EXACT field type so a range violation (e.g. `"300"` + // on a `u8`) is caught, not just a syntactic one. The message carries + // the offending value and type plus the underlying `FromStr` error — the + // same error the runtime `.unwrap()` would have panicked with. + macro_rules! check { + ($t:ty) => { + value + .parse::<$t>() + .map(|_| ()) + .map_err(|e| format!("invalid default_value {value:?} for `{type_name}`: {e}")) + }; + } + + match type_name.as_str() { + "i8" => check!(i8), + "i16" => check!(i16), + "i32" => check!(i32), + "i64" => check!(i64), + "i128" => check!(i128), + "isize" => check!(isize), + "u8" => check!(u8), + "u16" => check!(u16), + "u32" => check!(u32), + "u64" => check!(u64), + "u128" => check!(u128), + "usize" => check!(usize), + "f32" => check!(f32), + "f64" => check!(f64), + "bool" => check!(bool), + // `String::FromStr` is infallible; `Decimal` needs the runtime crate. + // Everything else is gated out by `is_parseable_type` before this call. + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/vespera_macro/src/schema_macro/defaults/tests.rs b/crates/vespera_macro/src/schema_macro/defaults/tests.rs new file mode 100644 index 00000000..445eb74b --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/defaults/tests.rs @@ -0,0 +1,695 @@ +use std::collections::HashMap; + +use super::*; +use crate::metadata::StructMetadata; +use crate::schema_macro::{SchemaTypeInput, generate_schema_type_code}; + +fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { + StructMetadata::new(name.to_string(), definition.to_string()) +} + +fn to_storage(items: Vec) -> HashMap { + items.into_iter().map(|s| (s.name.clone(), s)).collect() +} + +// ====================================== +// validate_literal_default tests +// ====================================== + +#[test] +fn validate_literal_default_accepts_valid_primitives() { + let i32_ty: syn::Type = syn::parse_str("i32").unwrap(); + assert!(validate_literal_default("42", &i32_ty).is_ok()); + let u8_ty: syn::Type = syn::parse_str("u8").unwrap(); + assert!(validate_literal_default("255", &u8_ty).is_ok()); + let f64_ty: syn::Type = syn::parse_str("f64").unwrap(); + assert!(validate_literal_default("0.7", &f64_ty).is_ok()); + let bool_ty: syn::Type = syn::parse_str("bool").unwrap(); + assert!(validate_literal_default("true", &bool_ty).is_ok()); + // String FromStr is infallible; Decimal is intentionally left to runtime. + let string_ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(validate_literal_default("anything at all", &string_ty).is_ok()); + let decimal_ty: syn::Type = syn::parse_str("Decimal").unwrap(); + assert!(validate_literal_default("not-validated-here", &decimal_ty).is_ok()); +} + +#[test] +fn validate_literal_default_rejects_unparseable_and_out_of_range() { + let i32_ty: syn::Type = syn::parse_str("i32").unwrap(); + assert!(validate_literal_default("abc", &i32_ty).is_err()); + // Range violation caught against the EXACT type, not a generic integer. + let u8_ty: syn::Type = syn::parse_str("u8").unwrap(); + assert!(validate_literal_default("300", &u8_ty).is_err()); + let bool_ty: syn::Type = syn::parse_str("bool").unwrap(); + assert!(validate_literal_default("maybe", &bool_ty).is_err()); + let f64_ty: syn::Type = syn::parse_str("f64").unwrap(); + assert!(validate_literal_default("3.14.15", &f64_ty).is_err()); +} + +// ====================================== +// generate_sea_orm_default_attrs tests +// ====================================== + +#[test] +fn test_sea_orm_default_attrs_valid_literal_keeps_parse_unwrap() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, _schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, false, &mut fns); + assert!(serde.to_string().contains("serde")); + assert_eq!(fns.len(), 1); + let body = fns[0].to_string(); + assert!(body.contains("parse"), "valid literal keeps parse: {body}"); + assert!( + body.contains("unwrap"), + "valid literal keeps unwrap: {body}" + ); + assert!( + !body.contains("compile_error"), + "valid literal must not emit compile_error: {body}" + ); +} + +#[test] +fn test_sea_orm_default_attrs_invalid_literal_emits_compile_error() { + // `"abc"` cannot parse to i32: the generated default function body must + // be a compile_error (pointing at the field) instead of a runtime + // `.parse().unwrap()` that would panic when serde fills a missing field. + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "abc")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, _schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, false, &mut fns); + assert!(serde.to_string().contains("serde")); + assert_eq!(fns.len(), 1); + let body = fns[0].to_string(); + assert!( + body.contains("compile_error"), + "invalid literal must emit compile_error: {body}" + ); + assert!( + !body.contains("unwrap"), + "invalid literal must not emit a runtime parse().unwrap(): {body}" + ); +} + +#[test] +fn test_sea_orm_default_attrs_optional_field_skips() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, true, &mut fns); + assert!(serde.is_empty()); + assert!(schema.is_empty()); + assert!(fns.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_no_default_and_no_pk() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(unique)])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("String").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "email", &ty, &ty, false, &mut fns); + assert!(serde.is_empty()); + assert!(schema.is_empty()); + assert!(fns.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_primary_key_generates_defaults() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "primary_key should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains('0'), + "primary_key i32 should have schema default 0: {schema_str}" + ); + assert_eq!(fns.len(), 1, "should generate a default function"); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_generates_defaults() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "SQL function default should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01"), + "DateTimeWithTimeZone should have epoch default: {schema_str}" + ); + assert_eq!(fns.len(), 1, "should generate a default function"); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_uuid() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(primary_key, default_value = "gen_random_uuid()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("Uuid").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "UUID SQL default should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("00000000-0000-0000-0000-000000000000"), + "Uuid should have nil UUID default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_unknown_type_skips() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "SOME_FUNC()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("MyCustomType").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "field", &ty, &ty, false, &mut fns); + assert!(serde.is_empty(), "unknown type should skip serde default"); + assert!(schema.is_empty(), "unknown type should skip schema default"); + assert!(fns.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_existing_serde_default() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(default_value = "42")]), + syn::parse_quote!(#[serde(default)]), + ]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, false, &mut fns); + // serde attr should be empty (already has serde default) + assert!(serde.is_empty()); + // schema attr should still be generated + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + assert!( + fns.is_empty(), + "no default fn needed when serde(default) exists" + ); +} + +#[test] +fn test_sea_orm_default_attrs_non_parseable_type() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "Active")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("MyEnum").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "status", &ty, &ty, false, &mut fns); + // serde attr empty (non-parseable type) + assert!(serde.is_empty()); + // schema attr still generated + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + assert!(fns.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_full_generation() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("i32").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, false, &mut fns); + // Both serde and schema attrs should be generated + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "should have serde attr: {serde_str}" + ); + assert!( + serde_str.contains("default_Test_count"), + "should reference generated fn: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + // Default function should be generated + assert_eq!(fns.len(), 1, "should generate one default function"); + let fn_str = fns[0].to_string(); + assert!( + fn_str.contains("default_Test_count"), + "fn name should match: {fn_str}" + ); +} + +#[test] +fn test_generate_schema_type_code_with_partial_all() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub bio: Option }", + )]); + + let tokens = quote!(UpdateUser from User, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("Option < i32 >")); + assert!(output.contains("Option < String >")); +} + +#[test] +fn test_generate_schema_type_code_with_partial_fields() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub email: String }", + )]); + + let tokens = quote!(UpdateUser from User, partial = ["name"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!( + output.contains("UpdateUser"), + "should contain generated struct name: {output}" + ); +} + +// ============================================================ +// Coverage: omit_default in generate_schema_type_code (line 180) +// ============================================================ + +#[test] +fn test_generate_schema_type_code_with_omit_default() { + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "items")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + #[sea_orm(default_value = "NOW()")] + pub created_at: DateTimeWithTimeZone, + }"#, + )]); + + let tokens = quote!(CreateItemRequest from Model, omit_default); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // id (primary_key) and created_at (default_value) should be omitted + assert!( + !output.contains("id :"), + "id should be omitted by omit_default: {output}" + ); + assert!( + !output.contains("created_at"), + "created_at should be omitted by omit_default: {output}" + ); + // name should remain + assert!(output.contains("name"), "name should remain: {output}"); +} + +// ============================================================ +// Coverage: SQL function default with existing serde default (line 554) +// ============================================================ + +#[test] +fn test_sea_orm_default_attrs_sql_function_with_existing_serde_default() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(default_value = "NOW()")]), + syn::parse_quote!(#[serde(default)]), + ]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + // serde attr should be empty (already has serde default) + assert!(serde.is_empty()); + // schema attr should still be generated + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + assert!( + schema_str.contains("1970-01-01"), + "should have epoch default: {schema_str}" + ); + assert!( + fns.is_empty(), + "no default fn needed when serde(default) exists" + ); +} + +// ============================================================ +// Coverage: sql_function_default_for_type branches (lines 580-615) +// ============================================================ + +#[test] +fn test_sea_orm_default_attrs_sql_function_non_path_type() { + // Non-Path type (reference) triggers early return None in sql_function_default_for_type + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "field", &ty, &ty, false, &mut fns); + assert!(serde.is_empty(), "non-Path type should skip serde default"); + assert!( + schema.is_empty(), + "non-Path type should skip schema default" + ); + assert!(fns.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_datetime() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "DateTime should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01T00:00:00+00:00"), + "DateTime should have epoch default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_naive_datetime() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveDateTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "NaiveDateTime should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01T00:00:00"), + "NaiveDateTime should have epoch default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_naive_date() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveDate").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "date_field", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "NaiveDate should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01"), + "NaiveDate should have date default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_naive_time() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "time_field", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "NaiveTime should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("00:00:00"), + "NaiveTime should have time default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_time_type() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("Time").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "time_field", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "Time should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("00:00:00"), + "Time should have time default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +// --- Coverage: is_parseable_type empty segments --- + +#[test] +fn test_is_parseable_type_empty_segments() { + // Synthetically construct a Type::Path with empty segments (impossible through parsing) + let ty = syn::Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }); + assert!(!is_parseable_type(&ty)); +} + +#[test] +fn test_generate_schema_type_code_partial_nonexistent_field() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UpdateUser from User, partial = ["nonexistent"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not exist")); + assert!(err.contains("nonexistent")); +} + +#[test] +fn test_generate_schema_type_code_partial_from_impl_wraps_some() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UpdateUser from User, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("Some (source . id)")); + assert!(output.contains("Some (source . name)")); +} + +#[test] +fn test_generate_schema_type_code_preserves_struct_doc() { + let input = SchemaTypeInput { + new_type: syn::Ident::new("NewUser", proc_macro2::Span::call_site()), + source_type: syn::parse_str("User").unwrap(), + omit: None, + pick: None, + rename: None, + add: None, + derive_clone: true, + partial: None, + schema_name: None, + ignore_schema: false, + rename_all: None, + multipart: false, + omit_default: false, + }; + let struct_def = StructMetadata { + name: "User".to_string(), + definition: r" + /// User struct documentation + pub struct User { + /// The user ID + pub id: i32, + /// The user name + pub name: String, + } + " + .to_string(), + include_in_openapi: true, + field_defaults: std::collections::BTreeMap::new(), + source_identity: None, + }; + let storage = to_storage(vec![struct_def]); + let result = generate_schema_type_code(&input, &storage); + assert!(result.is_ok()); + let (tokens, _) = result.unwrap(); + let tokens_str = tokens.to_string(); + assert!(tokens_str.contains("User struct documentation") || tokens_str.contains("doc")); +} + +// Tests for serde attribute filtering from source struct + +#[test] +fn test_generate_schema_type_code_inherits_source_rename_all() { + // Source struct has serde(rename_all = "snake_case") + let storage = to_storage(vec![create_test_struct_metadata( + "User", + r#"#[serde(rename_all = "snake_case")] + pub struct User { pub id: i32, pub user_name: String }"#, + )]); + + let tokens = quote!(UserResponse from User); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should use snake_case from source + assert!(output.contains("rename_all")); + assert!(output.contains("snake_case")); +} + +#[test] +fn test_generate_schema_type_code_override_rename_all() { + // Source has snake_case, but we override with camelCase + let storage = to_storage(vec![create_test_struct_metadata( + "User", + r#"#[serde(rename_all = "snake_case")] + pub struct User { pub id: i32, pub user_name: String }"#, + )]); + + let tokens = quote!(UserResponse from User, rename_all = "camelCase"); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should use camelCase (our override) + assert!(output.contains("camelCase")); +} diff --git a/crates/vespera_macro/src/schema_macro/file_cache.rs b/crates/vespera_macro/src/schema_macro/file_cache.rs index 59313549..0659e43a 100644 --- a/crates/vespera_macro/src/schema_macro/file_cache.rs +++ b/crates/vespera_macro/src/schema_macro/file_cache.rs @@ -10,34 +10,180 @@ //! are not `Send`/`Sync`, and proc-macros run single-threaded anyway. //! The mtime check handles rust-analyzer's proc-macro server, which may persist //! across file edits. +//! +//! ## Epoch caching +//! +//! `fs::metadata` costs ~1–10 µs per call. Projects with 100+ source files +//! previously paid that cost on every cache lookup, even on hits. +//! +//! The epoch mechanism amortises this: each file-cache-reaching top-level macro +//! invocation (`#[derive(Schema)]`, `schema!`, `schema_type!`, `vespera!`, and +//! `export_app!`) calls [`bump_epoch`] once at entry. Within that epoch, a given +//! path's mtime is fetched from `fs::metadata` **at most once** and stored in +//! `mtime_epoch_cache`. Subsequent lookups for the same path in the same epoch +//! reuse the cached mtime without a syscall. `#[route]`, `#[cron]`, and +//! `#[derive(Multipart)]` do not call into this module: they parse only the +//! annotated item tokens and update in-memory macro storage, so they are +//! intentionally exempt. +//! +//! Across epochs the full mtime check still runs, preserving the existing +//! invalidation semantics (important for rust-analyzer's long-lived server). use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, hash_map::DefaultHasher}; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::SystemTime; +// Test-only thread-local counter: number of `fs::metadata` calls made on +// this thread. Thread-local so parallel test threads don't interfere with +// each other's counts. +#[cfg(test)] +thread_local! { + static METADATA_CALL_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Reset the test-only metadata call counter to zero for this thread. +#[cfg(test)] +pub fn reset_metadata_call_count() { + METADATA_CALL_COUNT.with(|c| c.set(0)); +} + +/// Return the current value of the test-only metadata call counter for this thread. +#[cfg(test)] +pub fn metadata_call_count() -> usize { + METADATA_CALL_COUNT.with(std::cell::Cell::get) +} + +// Test-only thread-local counter: number of `extract_struct_names` +// tokenisation passes (the per-file source scan). Lets the H1 regression +// benchmark prove that a single-file edit re-tokenises only the changed +// file instead of every file in the directory. +#[cfg(test)] +thread_local! { + static EXTRACT_STRUCT_NAMES_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Reset the test-only `extract_struct_names` call counter for this thread. +#[cfg(test)] +pub fn reset_extract_struct_names_count() { + EXTRACT_STRUCT_NAMES_COUNT.with(|c| c.set(0)); +} + +/// Current value of the test-only `extract_struct_names` call counter. +#[cfg(test)] +pub fn extract_struct_names_count() -> usize { + EXTRACT_STRUCT_NAMES_COUNT.with(std::cell::Cell::get) +} + use super::circular::CircularAnalysis; use super::file_lookup::collect_rs_files_recursive; use crate::metadata::StructMetadata; +/// Phase-4 path-string resolution caches (struct / FK / module-path / circular +/// lookups), split into the `lookups` sidecar to keep this file within the +/// source-size budget. They share the parent `FILE_CACHE` + the +/// `ensure_file_list` / `get_fingerprint_cached` helpers via `super::` but +/// operate on a disjoint set of `FileCache` fields. +mod lookups; +pub use lookups::{ + get_circular_analysis, get_fk_column, get_module_path_from_schema_path, + get_struct_from_schema_path, +}; + +/// Combined per-file fingerprint: modification time **and** byte length, +/// both read from a single `fs::metadata` call. +/// +/// Pairing length with mtime catches a **timestamp-preserving edit that +/// changes the file size** — a `git checkout`, a `cp -p`, or a build-cache +/// restore that resets mtime — which a bare-`SystemTime` cache silently +/// served stale. This matches the route-folder cache's mtime+size +/// fingerprint, so every file cache in this module now shares the same +/// (stronger) invalidation. A same-mtime *and* same-size edit remains +/// undetectable — a fundamental mtime-cache limitation, not introduced here. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct FileFingerprint { + mtime: SystemTime, + len: u64, +} + +/// Cached directory walk for a single `src_dir`. +/// +/// `fingerprint` is a SipHash over the sorted `(path, mtime)` pairs of +/// every `.rs` file under the directory. Within the same macro +/// invocation (matched via `last_epoch_validated == cache.epoch`) the +/// entry is trusted without rewalking; across invocations the directory +/// is rewalked once and the fingerprint comparison decides whether the +/// cached `files` (and the dependent `struct_index`) stay live. +/// +/// Replaces the prior bare `Arc<[PathBuf]>` cache, which silently +/// missed `.rs` files added in long-lived rust-analyzer proc-macro +/// servers. +#[derive(Clone)] +struct DirEntry { + fingerprint: u64, + last_epoch_validated: u64, + files: Arc<[PathBuf]>, +} + +#[derive(Clone)] +struct PathLookupEntry { + value: T, + fingerprint: u64, + last_epoch_validated: u64, +} + /// Internal cache state. struct FileCache { - /// Cached `.rs` file lists per source directory. - file_lists: HashMap>, + /// Cached `.rs` file lists per source directory with a directory + /// fingerprint for cross-invocation invalidation. + /// + /// See [`DirEntry`] for the invalidation semantics. + file_lists: HashMap, - /// Cached file contents: file path → (mtime, content string). - /// Mtime is checked to invalidate stale entries in long-lived processes. + /// Cached file contents: file path → (fingerprint, content string). + /// The mtime+len [`FileFingerprint`] is checked to invalidate stale + /// entries in long-lived processes. /// /// `Arc` lets the cache hand out cheap pointer-clones instead of /// copying the entire file body on every lookup. The previous `String` /// variant cloned `O(file_size)` bytes per cache hit and a second time /// on insert; both become single-word `Arc::clone`s. - file_contents: HashMap)>, + file_contents: HashMap)>, + + /// Epoch-scoped negative cache for paths whose metadata/content lookup + /// fails. Missing `{module}.rs` / `{module}/mod.rs` candidates are probed + /// repeatedly during path resolution; once a path is known absent in the + /// current macro invocation, avoid re-running `read_to_string` for it. + missing_file_content_epoch: HashMap, - /// Struct name candidate index: (src_dir, struct_name) → files containing that name. - /// Built from cheap `String::contains` search, not full parsing. - struct_candidates: HashMap<(PathBuf, String), Vec>, + /// Per-`src_dir` struct identifier index: struct name → files that + /// define it (as a top-level `struct ` declaration found via + /// cheap source-text tokenisation in [`extract_struct_names`]). + /// + /// Built lazily on the first `get_struct_candidates` call for a + /// directory; dropped alongside its `file_lists` entry whenever the + /// directory fingerprint changes. + /// + /// Replaces the prior per-`(src_dir, name)` full-source + /// `String::contains` scan (`struct_candidates`), which was + /// O(N×M) for N struct lookups across M files. The index is O(M) + /// tokenisation passes to build, then O(1) per lookup. + struct_index: HashMap>>, + + /// Per-file mtime-validated cache of the struct names defined in each + /// `.rs` file (the [`extract_struct_names`] tokenisation result). + /// + /// The `struct_index` above is dropped wholesale whenever a directory's + /// fingerprint changes (any file added / removed / modified — the common + /// rust-analyzer edit). Without this per-file layer the rebuild + /// re-tokenised **every** file in the directory; with it, a file whose + /// mtime is unchanged returns its cached names in O(1), so only the + /// genuinely changed file pays the O(file_size) tokenisation. The index + /// rebuild then costs one tokenisation per *edited* file instead of one + /// per file in the directory. + file_struct_names: HashMap)>, // NOTE: We CANNOT cache `syn::File` or `syn::ItemStruct` across proc-macro // invocations. Both `syn` and `proc_macro2` types contain `proc_macro::Span` @@ -63,19 +209,27 @@ struct FileCache { // --- Phase 4 caches --- /// Cached circular reference analysis results: (module_path, definition) → analysis. circular_analysis: HashMap<(String, String), CircularAnalysis>, - /// Cached struct lookups by schema path: path_str → Option. + /// Cached struct lookups by schema path plus dependency fingerprint. /// `None` values are cached (negative cache) to avoid repeated failed lookups. - struct_lookup: HashMap>, - /// Cached FK column lookups: (schema_path, via_rel) → Option. - fk_column_lookup: HashMap<(String, String), Option>, + /// `Arc` because `StructMetadata.definition` holds the full struct + /// source text — cloning it per hit copied kilobytes. + struct_lookup: HashMap>>>, + /// Cached FK column lookups plus dependency fingerprint. + fk_column_lookup: HashMap<(String, String), PathLookupEntry>>, /// Cached module path extraction from schema paths: path_str → Vec. module_path_cache: HashMap>, - /// Cached struct definitions from files: file_path → (mtime, struct_name → definition_string). + /// Cached struct definitions from files: file_path → (fingerprint, struct_name → definition_string). /// Unlike `syn::File`, strings have no `proc_macro::Span` handles, safe to cache. - struct_definitions: HashMap)>, - /// Cached CARGO_MANIFEST_DIR value to avoid repeated syscalls. - /// Within a single compilation, this never changes. + struct_definitions: HashMap)>, + /// Cached `CARGO_MANIFEST_DIR` value to avoid repeated `std::env::var` + /// reads. Constant within one compilation, but revalidated once per + /// epoch (see [`get_manifest_dir`]) so a long-lived rust-analyzer + /// proc-macro server reused across crates picks up the new manifest dir + /// instead of resolving paths against the previous crate forever. manifest_dir: Option, + /// Epoch [`FileCache::manifest_dir`] was last read in (for the per-epoch + /// revalidation above). + manifest_dir_epoch: u64, // --- Phase 4 profiling counters --- circular_cache_hits: usize, @@ -83,13 +237,30 @@ struct FileCache { fk_column_cache_hits: usize, module_path_cache_hits: usize, struct_def_cache_hits: usize, + + // --- Epoch caching --- + /// Monotonically increasing counter. Bumped once at the start of each + /// file-cache-reaching top-level macro invocation (`#[derive(Schema)]`, + /// `schema!`, `schema_type!`, `vespera!`, `export_app!`). + epoch: u64, + /// Retained for cache-format/test compatibility; path lookup caches now + /// survive epoch bumps and rely on the lower mtime-validated file caches. + path_lookup_epoch: u64, + /// Per-epoch fingerprint cache: path → (epoch_when_checked, fingerprint_result). + /// + /// When the stored epoch equals `self.epoch`, the fingerprint was already + /// fetched during this invocation and `fs::metadata` is skipped. + /// When the epoch differs the entry is stale and the syscall runs again. + mtime_epoch_cache: HashMap)>, } thread_local! { static FILE_CACHE: RefCell = RefCell::new(FileCache { file_lists: HashMap::with_capacity(4), file_contents: HashMap::with_capacity(32), - struct_candidates: HashMap::with_capacity(32), + missing_file_content_epoch: HashMap::with_capacity(32), + struct_index: HashMap::with_capacity(4), + file_struct_names: HashMap::with_capacity(32), file_disk_reads: 0, content_cache_hits: 0, struct_parses: 0, @@ -99,27 +270,103 @@ thread_local! { fk_column_lookup: HashMap::with_capacity(16), module_path_cache: HashMap::with_capacity(32), manifest_dir: None, + manifest_dir_epoch: 0, circular_cache_hits: 0, struct_lookup_cache_hits: 0, fk_column_cache_hits: 0, module_path_cache_hits: 0, struct_definitions: HashMap::with_capacity(32), struct_def_cache_hits: 0, + epoch: 0, + path_lookup_epoch: 0, + mtime_epoch_cache: HashMap::with_capacity(32), }); } +/// Advance the per-invocation epoch counter. +/// +/// Call this **once** at the start of each file-cache-reaching top-level macro +/// invocation (`#[derive(Schema)]`, `schema!`, `schema_type!`, `vespera!`, +/// `export_app!`). `#[route]`, `#[cron]`, and `#[derive(Multipart)]` are exempt +/// because they do not read files through this module. Within a single epoch, +/// `fs::metadata` is called at most once per path; subsequent lookups for the +/// same path reuse the cached mtime without a syscall. +/// +/// Across epochs the full mtime check still runs, preserving the existing +/// invalidation semantics for long-lived processes (e.g. rust-analyzer). +pub fn bump_epoch() { + FILE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + cache.epoch = cache.epoch.wrapping_add(1); + }); +} + +/// Fetch the [`FileFingerprint`] (mtime + byte length) for `path`, using the +/// epoch cache to avoid redundant `fs::metadata` syscalls within a single +/// macro invocation. +/// +/// Both fields come from ONE `fs::metadata` call, so adding the length costs +/// no extra syscall over the previous mtime-only fetch. Returns `None` if the +/// file does not exist or its mtime is unavailable. +fn get_fingerprint_cached(cache: &mut FileCache, path: &Path) -> Option { + let current_epoch = cache.epoch; + if let Some(&(entry_epoch, fingerprint)) = cache.mtime_epoch_cache.get(path) + && entry_epoch == current_epoch + { + return fingerprint; + } + #[cfg(test)] + METADATA_CALL_COUNT.with(|c| c.set(c.get() + 1)); + let fingerprint = std::fs::metadata(path).ok().and_then(|m| { + // `len()` is already materialised in the same `Metadata`, so pairing + // it with mtime is free — no second syscall. + m.modified().ok().map(|mtime| FileFingerprint { + mtime, + len: m.len(), + }) + }); + cache + .mtime_epoch_cache + .insert(path.to_path_buf(), (current_epoch, fingerprint)); + fingerprint +} + +/// Public accessor for a path's [`FileFingerprint`], routed through the shared +/// per-epoch cache. +/// +/// Lets callers outside this module (e.g. the `schema_impl` default-function +/// cache) validate their own caches against the SAME mtime+len fingerprint +/// **without an extra `fs::metadata` syscall**: the first lookup this epoch +/// populates the epoch cache, and a subsequent [`get_parsed_file`] / +/// content read for the same path reuses it instead of stat-ing again — the +/// previous code stat'd the file twice (once here, once inside +/// `get_parsed_file`) on every derive carrying `#[serde(default = "fn")]`. +pub fn get_file_fingerprint(path: &Path) -> Option { + FILE_CACHE.with(|cache| get_fingerprint_cached(&mut cache.borrow_mut(), path)) +} + /// Get `CARGO_MANIFEST_DIR` from cache, or read from env and cache. /// -/// Within a single compilation, this value never changes. Caching avoids -/// repeated syscalls (previously 20+ calls per `schema_type!` expansion). +/// Constant within one compilation, so the value is cached and reused for +/// the rest of the epoch — avoiding the 20+ `std::env::var` reads a single +/// `schema_type!` expansion would otherwise make. It is revalidated **once +/// per epoch**, though: a long-lived rust-analyzer proc-macro server can +/// reuse this thread to expand a DIFFERENT crate whose `CARGO_MANIFEST_DIR` +/// differs, and a stale value would resolve every cross-file lookup against +/// the previous crate's `src/`. pub fn get_manifest_dir() -> Option { FILE_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); - if let Some(ref dir) = cache.manifest_dir { + let epoch = cache.epoch; + // Trust the cached value only within the epoch it was read in. + if cache.manifest_dir_epoch == epoch + && let Some(ref dir) = cache.manifest_dir + { return Some(dir.clone()); } let dir = std::env::var("CARGO_MANIFEST_DIR").ok(); cache.manifest_dir.clone_from(&dir); + cache.manifest_dir_epoch = epoch; dir }) } @@ -148,61 +395,216 @@ fn parse_file_cached(cache: &mut FileCache, path: &Path) -> Option { syn::parse_file(&content).ok() } -/// Get candidate files that likely contain `struct_name`, using cache when available. +/// Walk every `.rs` file under `dir` and produce a content-stable +/// fingerprint of `(sorted path, mtime)` pairs. +/// +/// The fingerprint is a `DefaultHasher` (SipHash) digest computed in +/// path-sorted order so it is determinstic and stable across runs. It +/// changes iff a `.rs` file under `dir` is added, removed, or modified +/// in a way that perturbs its mtime — which is exactly the trigger we +/// need to invalidate the cached file list and the dependent struct +/// identifier index. +/// +/// Fingerprint lookups reuse the per-epoch [`get_fingerprint_cached`] so this +/// is effectively one `fs::metadata` per file per epoch, and zero subsequent +/// `fs::metadata` calls for the same path within the same epoch. +fn walk_and_fingerprint(cache: &mut FileCache, dir: &Path) -> (Vec, u64) { + let mut files = Vec::new(); + collect_rs_files_recursive(dir, &mut files); + files.sort(); + + let mut hasher = DefaultHasher::new(); + for path in &files { + path.hash(&mut hasher); + if let Some(fp) = get_fingerprint_cached(cache, path) { + if let Ok(duration) = fp.mtime.duration_since(std::time::UNIX_EPOCH) { + duration.as_secs().hash(&mut hasher); + duration.subsec_nanos().hash(&mut hasher); + } + // Fold the byte length in too: a size-changing, + // timestamp-preserving edit now perturbs the directory fingerprint + // (and thus invalidates the file list + struct index), matching the + // per-file `FileFingerprint` invalidation. + fp.len.hash(&mut hasher); + } + } + (files, hasher.finish()) +} + +/// Validate (or build) the [`DirEntry`] for `src_dir` and return its file list. +/// +/// * Same epoch (`last_epoch_validated == cache.epoch`) → trust cache, +/// no rewalk, no `fs::metadata` calls — pure `Arc::clone`. +/// * New epoch, identical fingerprint → refresh `last_epoch_validated` +/// to suppress further work in the rest of the epoch; cached +/// [`FileCache::struct_index`] entry stays live. +/// * New epoch, different fingerprint → drop the dependent +/// [`FileCache::struct_index`] entry; install a fresh `DirEntry`. +fn ensure_file_list(cache: &mut FileCache, src_dir: &Path) -> Arc<[PathBuf]> { + let current_epoch = cache.epoch; + + if let Some(entry) = cache.file_lists.get(src_dir) + && entry.last_epoch_validated == current_epoch + { + return Arc::clone(&entry.files); + } + + let (files_vec, fp) = walk_and_fingerprint(cache, src_dir); + + if let Some(entry) = cache.file_lists.get_mut(src_dir) { + if entry.fingerprint == fp { + // Unchanged directory: refresh the validation epoch IN PLACE and + // hand back a single `Arc::clone`. The previous code rebuilt the + // whole `DirEntry` (a `to_path_buf` key allocation) and cloned the + // `Arc` twice — once for the cache, once to return. + entry.last_epoch_validated = current_epoch; + return Arc::clone(&entry.files); + } + // Directory changed: the dependent index is now stale. + cache.struct_index.remove(src_dir); + } + + let files: Arc<[PathBuf]> = files_vec.into(); + cache.file_lists.insert( + src_dir.to_path_buf(), + DirEntry { + fingerprint: fp, + last_epoch_validated: current_epoch, + files: Arc::clone(&files), + }, + ); + files +} + +/// Cheap source-text tokeniser: extract every `struct ` identifier +/// from `content`. +/// +/// Splits on the standard Rust identifier-character class and walks the +/// resulting token stream looking for the literal `struct` followed by +/// a valid identifier. This is intentionally lighter than `syn::parse_file` +/// — false positives in comments or strings are acceptable (the eventual +/// [`get_struct_definition`] still does the exact match), but `struct` +/// keywords inside string literals are exceedingly rare in real source +/// and false negatives are not possible for any actually-defined struct. +fn extract_struct_names(content: &str) -> Vec { + #[cfg(test)] + EXTRACT_STRUCT_NAMES_COUNT.with(|c| c.set(c.get() + 1)); + let mut names = Vec::new(); + let mut tokens = content + .split(|c: char| !(c == '_' || c.is_ascii_alphanumeric())) + .filter(|token| !token.is_empty()); + + while let Some(token) = tokens.next() { + if token == "struct" + && let Some(name) = tokens.next() + && name + .chars() + .next() + .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) + { + names.push(name.to_string()); + } + } + + names +} + +/// Struct names defined in `path`, served from a per-file mtime-validated +/// cache so the directory struct-index rebuild re-tokenises only files whose +/// mtime actually changed. +/// +/// On an mtime match the cached `Arc<[String]>` is cloned (O(1), no source +/// scan); otherwise the file content is read (via the mtime-validated content +/// cache) and re-tokenised once, then cached. A file that cannot be read +/// yields an empty name list — the caller simply contributes no candidates +/// for it, matching the prior inline `continue`-on-read-miss behaviour. +fn get_file_struct_names(cache: &mut FileCache, path: &Path) -> Arc<[String]> { + let current_fp = get_fingerprint_cached(cache, path); + + if let Some(fp) = current_fp + && let Some((cached_fp, names)) = cache.file_struct_names.get(path) + && *cached_fp == fp + { + return Arc::clone(names); + } + + let names: Arc<[String]> = get_file_content_inner(cache, path).map_or_else( + || Vec::new().into(), + |content| extract_struct_names(&content).into(), + ); + + if let Some(fp) = current_fp { + cache + .file_struct_names + .insert(path.to_path_buf(), (fp, Arc::clone(&names))); + } + + names +} + +/// Get candidate files that likely contain `struct_name`. /// -/// Performs a cheap text-based search (`String::contains`) on file contents. -/// False positives are acceptable (struct name in comments/strings), but false -/// negatives are not. Results are cached per `(src_dir, struct_name)` pair. -pub fn get_struct_candidates(src_dir: &Path, struct_name: &str) -> Vec { +/// Uses the per-`src_dir` struct identifier index built lazily on first +/// access. Once built, subsequent lookups for *any* struct name under +/// the same `src_dir` are O(1) — replacing the prior per-name +/// full-source `String::contains` scan (O(N×M) for N lookups across +/// M files). +/// +/// The index lives alongside the directory fingerprint in +/// [`FileCache::file_lists`]; both are dropped together whenever the +/// fingerprint changes (file added/removed/modified), so newly added +/// `.rs` files become visible after the next `bump_epoch` in long-lived +/// rust-analyzer servers. +pub fn get_struct_candidates(src_dir: &Path, struct_name: &str) -> Arc<[PathBuf]> { FILE_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); - let key = (src_dir.to_path_buf(), struct_name.to_string()); - if let Some(candidates) = cache.struct_candidates.get(&key) { - return candidates.clone(); + // Validate / build the `.rs` file list under fingerprint control + // (handles ADD/REMOVE/MODIFY invalidation across epochs). + let files = ensure_file_list(&mut cache, src_dir); + + // Build the per-src_dir struct identifier index on first miss. + // Subsequent calls for any name under the same src_dir short + // circuit to an O(1) lookup. + if !cache.struct_index.contains_key(src_dir) { + let mut grouped: HashMap> = HashMap::new(); + for path in files.iter() { + // Per-file mtime-validated names: unchanged files return their + // cached tokenisation (O(1)); only an added/modified file pays + // the source scan, so this rebuild costs one tokenisation per + // *edited* file instead of one per file in the directory. + for name in get_file_struct_names(&mut cache, path).iter() { + grouped.entry(name.clone()).or_default().push(path.clone()); + } + } + let index: HashMap> = grouped + .into_iter() + .map(|(name, paths)| (name, paths.into())) + .collect(); + cache.struct_index.insert(src_dir.to_path_buf(), index); } - // Ensure file list is cached - let files = if let Some(files) = cache.file_lists.get(src_dir) { - files.clone() - } else { - let mut files = Vec::new(); - collect_rs_files_recursive(src_dir, &mut files); - cache - .file_lists - .insert(src_dir.to_path_buf(), files.clone()); - files - }; - - // Filter using cheap text search, caching file contents along the way - let candidates: Vec = files - .into_iter() - .filter(|path| { - let content = get_file_content_inner(&mut cache, path); - content.is_some_and(|c| c.contains(struct_name)) - }) - .collect(); - - cache.struct_candidates.insert(key, candidates.clone()); - candidates + cache + .struct_index + .get(src_dir) + .and_then(|idx| idx.get(struct_name).cloned()) + .unwrap_or_else(|| Vec::::new().into()) }) } /// Ensure struct definitions are extracted and cached for the given file. /// On first call, parses the file and caches all struct definitions as strings. -/// On subsequent calls, checks mtime to validate cache. +/// On subsequent calls, checks the mtime+len fingerprint to validate cache. fn ensure_struct_definitions(cache: &mut FileCache, path: &Path) -> bool { - let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()); + let current_fp = get_fingerprint_cached(cache, path); - if let Some(mtime) = current_mtime - && let Some((cached_mtime, _)) = cache.struct_definitions.get(path) - && *cached_mtime == mtime + if let Some(fp) = current_fp + && let Some((cached_fp, _)) = cache.struct_definitions.get(path) + && *cached_fp == fp { cache.struct_def_cache_hits += 1; return true; } - // Cache miss — parse file and extract all struct definitions. - // Uses parse_file_cached: single syn::parse_file entry point. let Some(file_ast) = parse_file_cached(cache, path) else { return false; }; @@ -216,10 +618,10 @@ fn ensure_struct_definitions(cache: &mut FileCache, path: &Path) -> bool { } } - if let Some(mtime) = current_mtime { + if let Some(fp) = current_fp { cache .struct_definitions - .insert(path.to_path_buf(), (mtime, defs)); + .insert(path.to_path_buf(), (fp, defs)); } true @@ -249,29 +651,46 @@ pub fn get_struct_definition(path: &Path, struct_name: &str) -> Option { } /// Internal helper: get file content from cache or read from disk. -/// Checks mtime for invalidation. +/// Checks the mtime+len fingerprint for invalidation. /// /// Returns `Arc` so callers share a single allocation instead of /// cloning the whole file body per lookup. fn get_file_content_inner(cache: &mut FileCache, path: &Path) -> Option> { - let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()); + let current_fp = get_fingerprint_cached(cache, path); + let current_epoch = cache.epoch; - if let Some(mtime) = current_mtime - && let Some((cached_mtime, content)) = cache.file_contents.get(path) - && *cached_mtime == mtime + if let Some(fp) = current_fp + && let Some((cached_fp, content)) = cache.file_contents.get(path) + && *cached_fp == fp { cache.content_cache_hits += 1; return Some(Arc::clone(content)); } - // Cache miss or stale — read and cache - let content = Arc::new(std::fs::read_to_string(path).ok()?); + if current_fp.is_none() + && cache + .missing_file_content_epoch + .get(path) + .is_some_and(|epoch| *epoch == current_epoch) + { + return None; + } + + let Some(content) = std::fs::read_to_string(path).ok().map(Arc::new) else { + if current_fp.is_none() { + cache + .missing_file_content_epoch + .insert(path.to_path_buf(), current_epoch); + } + return None; + }; cache.file_disk_reads += 1; - if let Some(mtime) = current_mtime { + if let Some(fp) = current_fp { + cache.missing_file_content_epoch.remove(path); cache .file_contents - .insert(path.to_path_buf(), (mtime, Arc::clone(&content))); + .insert(path.to_path_buf(), (fp, Arc::clone(&content))); } Some(content) @@ -292,127 +711,6 @@ pub fn parse_struct_cached(definition: &str) -> Result CircularAnalysis { - let key = (source_module_path.join("::"), definition.to_string()); - - // 1. Check cache — borrow dropped at end of closure - let cached = FILE_CACHE.with(|cache| cache.borrow().circular_analysis.get(&key).cloned()); - if let Some(result) = cached { - FILE_CACHE.with(|cache| cache.borrow_mut().circular_cache_hits += 1); - return result; - } - - // 2. Compute — this re-enters FILE_CACHE via parse_struct_cached (safe: our borrow is dropped) - let result = super::circular::analyze_circular_refs(source_module_path, definition); - - // 3. Store — new borrow - FILE_CACHE.with(|cache| { - cache - .borrow_mut() - .circular_analysis - .insert(key, result.clone()); - }); - - result -} - -/// Get or compute struct lookup by schema path, with caching. -/// -/// Wraps `find_struct_from_schema_path` with a `HashMap>` -/// cache. `None` values are cached too (negative cache) to avoid repeated failed lookups. -pub fn get_struct_from_schema_path(path_str: &str) -> Option { - // 1. Check cache — borrow dropped at end of closure - let cached = FILE_CACHE.with(|cache| cache.borrow().struct_lookup.get(path_str).cloned()); - if let Some(result) = cached { - FILE_CACHE.with(|cache| cache.borrow_mut().struct_lookup_cache_hits += 1); - return result; - } - - // 2. Compute — this re-enters FILE_CACHE via get_struct_definition (safe: our borrow is dropped) - let result = super::file_lookup::find_struct_from_schema_path(path_str); - - // 3. Store — new borrow - FILE_CACHE.with(|cache| { - cache - .borrow_mut() - .struct_lookup - .insert(path_str.to_string(), result.clone()); - }); - - result -} - -/// Get or compute FK column lookup, with caching. -/// -/// Wraps `find_fk_column_from_target_entity` with a `HashMap<(String, String), Option>` -/// cache. Negative results (`None`) are cached to avoid repeated file lookups. -pub fn get_fk_column(schema_path: &str, via_rel: &str) -> Option { - let key = (schema_path.to_string(), via_rel.to_string()); - - // 1. Check cache — borrow dropped at end of closure - let cached = FILE_CACHE.with(|cache| cache.borrow().fk_column_lookup.get(&key).cloned()); - if let Some(result) = cached { - FILE_CACHE.with(|cache| cache.borrow_mut().fk_column_cache_hits += 1); - return result; - } - - // 2. Compute — this re-enters FILE_CACHE via get_struct_definition (safe: our borrow is dropped) - let result = super::file_lookup::find_fk_column_from_target_entity(schema_path, via_rel); - - // 3. Store — new borrow - FILE_CACHE.with(|cache| { - cache - .borrow_mut() - .fk_column_lookup - .insert(key, result.clone()); - }); - - result -} - -/// Get or compute module path from schema path, with caching. -/// -/// Wraps `extract_module_path_from_schema_path` logic with a `HashMap>` -/// cache. The `schema_path` TokenStream is stringified once for both cache key and computation, -/// avoiding the double `.to_string()` that would occur when calling the uncached function. -pub fn get_module_path_from_schema_path(schema_path: &proc_macro2::TokenStream) -> Vec { - let path_str = schema_path.to_string(); - - // 1. Check cache — borrow dropped at end of closure - let cached = FILE_CACHE.with(|cache| cache.borrow().module_path_cache.get(&path_str).cloned()); - if let Some(result) = cached { - FILE_CACHE.with(|cache| cache.borrow_mut().module_path_cache_hits += 1); - return result; - } - - // 2. Compute directly: collect once, pop the trailing schema segment. - // The previous version built an intermediate `Vec<&str>` and then - // re-allocated it into a `Vec` (one wasted allocation per - // cache miss). - let mut result: Vec = path_str - .split("::") - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToString::to_string) - .collect(); - result.pop(); // drop the trailing segment (the schema name itself) - - // 3. Store — new borrow - FILE_CACHE.with(|cache| { - cache - .borrow_mut() - .module_path_cache - .insert(path_str, result.clone()); - }); - - result -} - /// Print profiling summary to stderr if `VESPERA_PROFILE` env var is set. /// /// Call this at the end of macro execution to output cache statistics. @@ -432,10 +730,10 @@ pub fn print_profile_summary() { eprintln!(" struct parses: {}", cache.struct_parses); eprintln!(" AST parses: {}", cache.ast_parses); eprintln!( - " cache entries: {} file lists, {} file contents, {} struct candidates", + " cache entries: {} file lists, {} file contents, {} struct index dirs", cache.file_lists.len(), cache.file_contents.len(), - cache.struct_candidates.len() + cache.struct_index.len() ); eprintln!( " circular analysis: {} cache hits, {} entries", @@ -466,122 +764,47 @@ pub fn print_profile_summary() { } /// Inject a fake struct definition into the cache for testing. -/// Uses the file's real mtime so `ensure_struct_definitions` won't invalidate the cache. +/// Uses the file's real mtime+len fingerprint so `ensure_struct_definitions` +/// won't invalidate the cache. /// Enables tests to simulate scenarios where `get_struct_definition` succeeds /// but `parse_struct_cached` fails (defensive code path). #[cfg(test)] pub fn inject_struct_definition_for_test(path: &std::path::Path, name: &str, definition: &str) { FILE_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); - let mtime = std::fs::metadata(path) - .ok() - .and_then(|m| m.modified().ok()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + let fingerprint = std::fs::metadata(path).ok().map_or( + FileFingerprint { + mtime: std::time::SystemTime::UNIX_EPOCH, + len: 0, + }, + |m| FileFingerprint { + mtime: m + .modified() + .ok() + .unwrap_or(std::time::SystemTime::UNIX_EPOCH), + len: m.len(), + }, + ); let entry = cache .struct_definitions .entry(path.to_path_buf()) - .or_insert_with(|| (mtime, HashMap::new())); - entry.0 = mtime; + .or_insert_with(|| (fingerprint, HashMap::new())); + entry.0 = fingerprint; entry.1.insert(name.to_string(), definition.to_string()); }); } +/// Test-only: whether the FK-column lookup cache currently holds an entry +/// for `(schema_path, via_rel)`. Used to assert epoch-scoped invalidation. #[cfg(test)] -mod tests { - - use tempfile::TempDir; - - use super::*; - - #[test] - fn test_get_struct_candidates_filters_correctly() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::write( - src_dir.join("has_model.rs"), - "pub struct Model { pub id: i32 }", - ) - .unwrap(); - std::fs::write( - src_dir.join("no_model.rs"), - "pub struct Other { pub x: i32 }", - ) - .unwrap(); - - let candidates = get_struct_candidates(src_dir, "Model"); - assert_eq!(candidates.len(), 1); - assert!(candidates[0].ends_with("has_model.rs")); - } - - #[test] - fn test_get_struct_candidates_caches_result() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::write(src_dir.join("file.rs"), "pub struct Target { pub id: i32 }").unwrap(); - - let c1 = get_struct_candidates(src_dir, "Target"); - let c2 = get_struct_candidates(src_dir, "Target"); - assert_eq!(c1, c2, "Cached candidates should be identical"); - } - - #[test] - fn test_get_struct_candidates_file_list_cache_hit() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::write( - src_dir.join("file_a.rs"), - "pub struct Alpha { pub id: i32 }", - ) - .unwrap(); - std::fs::write( - src_dir.join("file_b.rs"), - "pub struct Beta { pub name: String }", - ) - .unwrap(); - - // First call: populates file_lists cache for src_dir - let result1 = get_struct_candidates(src_dir, "Alpha"); - assert_eq!(result1.len(), 1); - - // Second call: same src_dir, different struct_name - // struct_candidates cache MISS (different key), but file_lists cache HIT → line 125 - let result2 = get_struct_candidates(src_dir, "Beta"); - assert_eq!(result2.len(), 1); - } - - #[test] - fn test_get_fk_column_cache_hit() { - // First call: computes and caches result (None since path doesn't exist) - let result1 = get_fk_column("nonexistent::path::Schema", "SomeRelation"); - // Second call: hits cache → lines 259-260 - let result2 = get_fk_column("nonexistent::path::Schema", "SomeRelation"); - assert_eq!(result1, result2); - } - - #[serial_test::serial] - #[test] - fn test_print_profile_summary_with_profile_env() { - // Set VESPERA_PROFILE to enable profiling output - unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; - - // This should print profile summary to stderr (lines 311-321) - print_profile_summary(); - - // Clean up - unsafe { std::env::remove_var("VESPERA_PROFILE") }; - // Test passes if no panic — output goes to stderr - } - - #[serial_test::serial] - #[test] - fn test_print_profile_summary_without_profile_env() { - // Ensure VESPERA_PROFILE is not set - unsafe { std::env::remove_var("VESPERA_PROFILE") }; - - // Should early-return at line 308 without printing anything - print_profile_summary(); - } +pub fn fk_lookup_contains(schema_path: &str, via_rel: &str) -> bool { + FILE_CACHE.with(|cache| { + cache + .borrow() + .fk_column_lookup + .contains_key(&(schema_path.to_string(), via_rel.to_string())) + }) } + +#[cfg(test)] +mod tests; diff --git a/crates/vespera_macro/src/schema_macro/file_cache/lookups.rs b/crates/vespera_macro/src/schema_macro/file_cache/lookups.rs new file mode 100644 index 00000000..01bb821d --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/file_cache/lookups.rs @@ -0,0 +1,317 @@ +//! Phase-4 path-string resolution caches, split out of `file_cache.rs` to +//! keep that module within the project's source-size budget. +//! +//! These caches key on schema PATH strings (not file paths) and resolve +//! through the lower file-content / struct-definition mtime caches in the +//! parent [`super`] module. They form a conceptually distinct layer (path +//! resolution: struct / FK / module-path / circular lookups) from the raw +//! file/dir/content caching that remains in `file_cache.rs`, and operate on a +//! disjoint set of [`super::FileCache`] fields (`circular_analysis`, +//! `struct_lookup`, `fk_column_lookup`, `module_path_cache`). They share the +//! parent's `FILE_CACHE` thread-local plus the `ensure_file_list` / +//! `get_fingerprint_cached` helpers via `super::`. +//! +//! Pure code move out of `file_cache.rs` — no logic or behaviour change. + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::Path; +use std::sync::Arc; + +use crate::metadata::StructMetadata; +use crate::schema_macro::circular::{CircularAnalysis, analyze_circular_refs}; +use crate::schema_macro::file_lookup::{ + find_fk_column_from_target_entity, find_struct_from_schema_path, +}; + +use super::{FILE_CACHE, FileCache, PathLookupEntry, ensure_file_list, get_fingerprint_cached}; + +/// Outcome of probing a path-keyed lookup cache. +/// +/// `Hit` short-circuits with the cached value. `Miss` carries the +/// `path_lookup_fingerprint` already computed during the probe, so the insert +/// path can reuse it instead of recomputing the (potentially `src`-tree-walking) +/// fingerprint a second time — the redundant double-fingerprint the previous +/// read-then-insert pair paid on every cache miss. +enum Probe { + Hit(T), + Miss(u64), +} + +/// Get or compute circular reference analysis, with caching. +/// +/// The cache key is `(source_module_path_joined, definition)` since the same +/// model definition analyzed from the same module context always produces +/// the same result. +pub fn get_circular_analysis(source_module_path: &[String], definition: &str) -> CircularAnalysis { + let key = (source_module_path.join("::"), definition.to_string()); + + // The borrow must end before analyzing: analysis re-enters FILE_CACHE. + let cached = FILE_CACHE.with(|cache| cache.borrow().circular_analysis.get(&key).cloned()); + if let Some(result) = cached { + FILE_CACHE.with(|cache| cache.borrow_mut().circular_cache_hits += 1); + return result; + } + + let result = analyze_circular_refs(source_module_path, definition); + + FILE_CACHE.with(|cache| { + cache + .borrow_mut() + .circular_analysis + .insert(key, result.clone()); + }); + + result +} + +/// Re-stamp the path-keyed lookup caches (`struct_lookup`, `fk_column_lookup`) +/// to the current epoch. +/// +/// These caches **deliberately survive epoch bumps** (see the +/// `path_lookup_epoch` field): keeping resolved path lookups warm across +/// invocations lets repeated `schema_type!` / `#[derive(Schema)]` expansions in +/// one crate build share path-resolution work. They key on a schema PATH string +/// (not a file), so a cache MISS re-resolves through the lower file-content / +/// struct-definition mtime caches; within a single `cargo build` no source file +/// changes mid-build, so a surviving entry only ever returns the result a +/// re-resolution would produce. The epoch stamp is retained only for +/// cache-format / test compatibility. +/// +/// (A long-lived rust-analyzer proc-macro server therefore keeps a resolved +/// entry until the server restarts — the accepted cost of the shared-work +/// optimisation. A future mtime-aware path cache could be both warm AND fresh, +/// but that is a design change, not a one-line tweak.) +fn path_lookup_fingerprint(cache: &mut FileCache, path_str: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + path_str.hash(&mut hasher); + + let Some(manifest_dir) = get_manifest_dir_inner(cache) else { + return hasher.finish(); + }; + let src_dir = Path::new(&manifest_dir).join("src"); + src_dir.hash(&mut hasher); + + let segments: Vec<&str> = path_str + .split("::") + .map(str::trim) + .filter(|s| !s.is_empty()) + .filter(|s| *s != "crate" && *s != "self" && *s != "super") + .collect(); + + if segments.len() <= 1 { + let files = ensure_file_list(cache, &src_dir); + for path in files.iter() { + fingerprint_path(cache, path, &mut hasher); + } + return hasher.finish(); + } + + let module_segments = &segments[..segments.len() - 1]; + let joined = module_segments.join("/"); + let candidates = [ + src_dir.join(format!("{joined}.rs")), + src_dir.join(format!("{joined}/mod.rs")), + ]; + for path in &candidates { + fingerprint_path(cache, path, &mut hasher); + } + + hasher.finish() +} + +fn get_manifest_dir_inner(cache: &mut FileCache) -> Option { + let epoch = cache.epoch; + if cache.manifest_dir_epoch == epoch + && let Some(ref dir) = cache.manifest_dir + { + return Some(dir.clone()); + } + let dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + cache.manifest_dir.clone_from(&dir); + cache.manifest_dir_epoch = epoch; + dir +} + +fn fingerprint_path(cache: &mut FileCache, path: &Path, hasher: &mut DefaultHasher) { + path.hash(hasher); + match get_fingerprint_cached(cache, path) { + Some(fp) => { + "fp:some".hash(hasher); + if let Ok(duration) = fp.mtime.duration_since(std::time::UNIX_EPOCH) { + duration.as_secs().hash(hasher); + duration.subsec_nanos().hash(hasher); + } + // Length folds in alongside mtime so a size-changing, + // timestamp-preserving edit re-resolves the path lookup. + fp.len.hash(hasher); + } + None => "fp:none".hash(hasher), + } +} + +fn ensure_path_lookup_caches_fresh(cache: &mut FileCache) { + cache.path_lookup_epoch = cache.epoch; +} + +/// Get or compute struct lookup by schema path, with caching. +/// +/// Wraps `find_struct_from_schema_path` with a +/// `HashMap>>` cache. `None` values +/// are cached too (negative cache) to avoid repeated failed lookups. +/// The `Arc` makes cache hits O(1) instead of cloning the full struct +/// definition text per lookup. +/// +/// The cache **survives epoch bumps** (see +/// [`ensure_path_lookup_caches_fresh`]): entries key on a schema PATH string, +/// and a cache MISS re-resolves through the lower file-content / +/// struct-definition mtime caches — so within one `cargo build` (no source +/// file changes mid-build) a surviving entry only ever returns the result a +/// re-resolution would produce, while keeping repeated lookups O(1). A +/// long-lived rust-analyzer proc-macro server therefore keeps a resolved +/// entry until the server restarts — the documented cost of the shared-work +/// optimisation (a future mtime-aware path cache could be warm AND fresh). +pub fn get_struct_from_schema_path(path_str: &str) -> Option> { + // Re-stamp the path-lookup epoch (entries deliberately SURVIVE bumps — see + // `ensure_path_lookup_caches_fresh`), then read the cache. The borrow ends + // before the lookup below, which re-enters FILE_CACHE. + let probe = FILE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + ensure_path_lookup_caches_fresh(&mut cache); + let epoch = cache.epoch; + // Epoch-hit fast path: an entry already validated THIS epoch is fresh + // WITHOUT recomputing the fingerprint — which, for a single-segment + // path, walks the entire `src` tree. The previous code computed the + // fingerprint unconditionally even when the epoch stamp already proved + // freshness. + if let Some(entry) = cache.struct_lookup.get(path_str) + && entry.last_epoch_validated == epoch + { + return Probe::Hit(entry.value.clone()); + } + // Cross-epoch / absent: compute the fingerprint ONCE and reuse it for + // the insert below on a miss. + let fingerprint = path_lookup_fingerprint(&mut cache, path_str); + if let Some(entry) = cache.struct_lookup.get_mut(path_str) + && entry.fingerprint == fingerprint + { + // Re-stamp so further lookups this epoch take the fast path. + entry.last_epoch_validated = epoch; + return Probe::Hit(entry.value.clone()); + } + Probe::Miss(fingerprint) + }); + let fingerprint = match probe { + Probe::Hit(value) => { + FILE_CACHE.with(|cache| cache.borrow_mut().struct_lookup_cache_hits += 1); + return value; + } + Probe::Miss(fingerprint) => fingerprint, + }; + + let result = find_struct_from_schema_path(path_str).map(Arc::new); + + FILE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + let epoch = cache.epoch; + cache.struct_lookup.insert( + path_str.to_string(), + PathLookupEntry { + value: result.clone(), + fingerprint, + last_epoch_validated: epoch, + }, + ); + }); + + result +} + +/// Get or compute FK column lookup, with caching. +/// +/// Wraps `find_fk_column_from_target_entity` with a `HashMap<(String, String), Option>` +/// cache. Negative results (`None`) are cached to avoid repeated file lookups. +pub fn get_fk_column(schema_path: &str, via_rel: &str) -> Option { + let key = (schema_path.to_string(), via_rel.to_string()); + + // Re-stamp the path-lookup epoch (entries deliberately SURVIVE bumps — see + // `ensure_path_lookup_caches_fresh`), then read this epoch's cache. The + // borrow ends before the lookup below, which re-enters FILE_CACHE. + let probe = FILE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + ensure_path_lookup_caches_fresh(&mut cache); + let epoch = cache.epoch; + // Epoch-hit fast path: skip the (possibly `src`-tree-walking) + // fingerprint when the entry was already validated this epoch. + if let Some(entry) = cache.fk_column_lookup.get(&key) + && entry.last_epoch_validated == epoch + { + return Probe::Hit(entry.value.clone()); + } + // Cross-epoch / absent: compute the fingerprint ONCE, reuse on miss. + let fingerprint = path_lookup_fingerprint(&mut cache, schema_path); + if let Some(entry) = cache.fk_column_lookup.get_mut(&key) + && entry.fingerprint == fingerprint + { + entry.last_epoch_validated = epoch; + return Probe::Hit(entry.value.clone()); + } + Probe::Miss(fingerprint) + }); + let fingerprint = match probe { + Probe::Hit(value) => { + FILE_CACHE.with(|cache| cache.borrow_mut().fk_column_cache_hits += 1); + return value; + } + Probe::Miss(fingerprint) => fingerprint, + }; + + let result = find_fk_column_from_target_entity(schema_path, via_rel); + + FILE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + let epoch = cache.epoch; + cache.fk_column_lookup.insert( + key, + PathLookupEntry { + value: result.clone(), + fingerprint, + last_epoch_validated: epoch, + }, + ); + }); + + result +} + +/// Get or compute module path from schema path, with caching. +/// +/// Wraps `extract_module_path_from_schema_path` logic with a `HashMap>` +/// cache. The `schema_path` TokenStream is stringified once for both cache key and computation, +/// avoiding the double `.to_string()` that would occur when calling the uncached function. +pub fn get_module_path_from_schema_path(schema_path: &proc_macro2::TokenStream) -> Vec { + let path_str = schema_path.to_string(); + + let cached = FILE_CACHE.with(|cache| cache.borrow().module_path_cache.get(&path_str).cloned()); + if let Some(result) = cached { + FILE_CACHE.with(|cache| cache.borrow_mut().module_path_cache_hits += 1); + return result; + } + + let mut result: Vec = path_str + .split("::") + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .collect(); + result.pop(); + + FILE_CACHE.with(|cache| { + cache + .borrow_mut() + .module_path_cache + .insert(path_str, result.clone()); + }); + + result +} diff --git a/crates/vespera_macro/src/schema_macro/file_cache/tests.rs b/crates/vespera_macro/src/schema_macro/file_cache/tests.rs new file mode 100644 index 00000000..662cbdcd --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/file_cache/tests.rs @@ -0,0 +1,517 @@ +use tempfile::TempDir; + +use super::*; + +#[test] +fn test_get_struct_candidates_filters_correctly() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + + std::fs::write( + src_dir.join("has_model.rs"), + "pub struct Model { pub id: i32 }", + ) + .unwrap(); + std::fs::write( + src_dir.join("no_model.rs"), + "pub struct Other { pub x: i32 }", + ) + .unwrap(); + + let candidates = get_struct_candidates(src_dir, "Model"); + assert_eq!(candidates.len(), 1); + assert!(candidates[0].ends_with("has_model.rs")); +} + +#[test] +fn test_get_struct_candidates_caches_result() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + + std::fs::write(src_dir.join("file.rs"), "pub struct Target { pub id: i32 }").unwrap(); + + let c1 = get_struct_candidates(src_dir, "Target"); + let c2 = get_struct_candidates(src_dir, "Target"); + assert_eq!(c1, c2, "Cached candidates should be identical"); +} + +#[test] +fn test_get_struct_candidates_file_list_cache_hit() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + + std::fs::write( + src_dir.join("file_a.rs"), + "pub struct Alpha { pub id: i32 }", + ) + .unwrap(); + std::fs::write( + src_dir.join("file_b.rs"), + "pub struct Beta { pub name: String }", + ) + .unwrap(); + + let result1 = get_struct_candidates(src_dir, "Alpha"); + assert_eq!(result1.len(), 1); + + let result2 = get_struct_candidates(src_dir, "Beta"); + assert_eq!(result2.len(), 1); +} + +#[test] +fn test_get_fk_column_cache_hit() { + let result1 = get_fk_column("nonexistent::path::Schema", "SomeRelation"); + let result2 = get_fk_column("nonexistent::path::Schema", "SomeRelation"); + assert_eq!(result1, result2); +} + +/// Path-keyed lookup caches survive epoch bumps so repeated `schema_type!` +/// expansions in one crate share path resolution work. Staleness is guarded +/// by the lower file-content / struct-definition mtime caches. +#[serial_test::serial] +#[test] +fn path_lookup_caches_survive_epoch_bumps() { + // Fresh epoch; cache a (negative) FK result for this epoch. + bump_epoch(); + let _ = get_fk_column("ra::stale::Schema", "Rel"); + assert!( + fk_lookup_contains("ra::stale::Schema", "Rel"), + "result must be cached within the same epoch" + ); + // A second access in the SAME epoch keeps the cache populated. + let _ = get_fk_column("ra::stale::Schema", "Rel"); + assert!(fk_lookup_contains("ra::stale::Schema", "Rel")); + // Advancing the epoch (the next macro invocation) must not drop the + // path-keyed caches anymore. + bump_epoch(); + let _ = get_fk_column("ra::trigger::Schema", "Rel"); + assert!( + fk_lookup_contains("ra::stale::Schema", "Rel"), + "lookup entry must remain cached when the epoch advances" + ); +} + +#[serial_test::serial] +#[test] +fn path_lookup_revalidates_when_resolved_file_mtime_changes() { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + match self.0.take() { + Some(v) => unsafe { std::env::set_var("CARGO_MANIFEST_DIR", v) }, + None => unsafe { std::env::remove_var("CARGO_MANIFEST_DIR") }, + } + } + } + + let temp_dir = TempDir::new().unwrap(); + let models_dir = temp_dir.path().join("src").join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model_path = models_dir.join("user.rs"); + std::fs::write(&model_path, "pub struct Model { pub id: i32 }").unwrap(); + + let _restore = Restore(std::env::var("CARGO_MANIFEST_DIR").ok()); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + bump_epoch(); + let first = get_struct_from_schema_path("crate::models::user::Model") + .expect("initial model should resolve"); + assert!(first.definition.contains("id : i32")); + + std::thread::sleep(std::time::Duration::from_millis(30)); + std::fs::write(&model_path, "pub struct Model { pub name: String }").unwrap(); + + bump_epoch(); + let second = get_struct_from_schema_path("crate::models::user::Model") + .expect("edited model should resolve"); + assert!( + second.definition.contains("name : String"), + "path lookup must invalidate stale resolved-file entries after mtime changes: {}", + second.definition + ); +} + +#[serial_test::serial] +#[test] +fn test_print_profile_summary_with_profile_env() { + unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; + + print_profile_summary(); + + unsafe { std::env::remove_var("VESPERA_PROFILE") }; +} + +#[serial_test::serial] +#[test] +fn test_print_profile_summary_without_profile_env() { + unsafe { std::env::remove_var("VESPERA_PROFILE") }; + + print_profile_summary(); +} + +/// Verify that within one epoch a path's mtime is checked via `fs::metadata` +/// exactly once, and that bumping the epoch causes a re-check. +/// +/// Layout: +/// epoch N → read path twice → 1 metadata call (second read hits epoch cache) +/// bump → epoch N+1 +/// epoch N+1 → read path once → 1 more metadata call (epoch cache stale) +/// +/// Total expected: 2 metadata calls for 3 reads across 2 epochs. +#[serial_test::serial] +#[test] +fn test_epoch_skips_metadata_syscall() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("target.rs"); + std::fs::write(&file_path, "pub struct Foo { pub x: i32 }").unwrap(); + + // Reset the global counter and start a fresh epoch so this test is + // independent of whatever other tests ran on this thread before. + reset_metadata_call_count(); + bump_epoch(); + + let before = metadata_call_count(); + + // First read in epoch N — must call fs::metadata (epoch cache miss). + let c1 = get_struct_definition(&file_path, "Foo"); + assert!(c1.is_some(), "struct should be found"); + assert_eq!( + metadata_call_count() - before, + 1, + "first read should trigger exactly 1 metadata call" + ); + + // Second read in epoch N — epoch cache hit, no additional metadata call. + let c2 = get_struct_definition(&file_path, "Foo"); + assert_eq!(c1, c2); + assert_eq!( + metadata_call_count() - before, + 1, + "second read in same epoch must NOT call metadata again" + ); + + // Advance to epoch N+1. + bump_epoch(); + + // First read in epoch N+1 — epoch cache is stale, must re-check metadata. + let c3 = get_struct_definition(&file_path, "Foo"); + assert_eq!(c1, c3); + assert_eq!( + metadata_call_count() - before, + 2, + "read after epoch bump must call metadata exactly once more" + ); +} + +#[serial_test::serial] +#[test] +fn test_missing_file_content_is_negative_cached_within_epoch() { + let temp_dir = TempDir::new().unwrap(); + let missing_path = temp_dir.path().join("missing.rs"); + + reset_metadata_call_count(); + bump_epoch(); + let before = metadata_call_count(); + + assert!(get_struct_definition(&missing_path, "Missing").is_none()); + assert!(get_struct_definition(&missing_path, "Missing").is_none()); + + assert_eq!( + metadata_call_count() - before, + 1, + "missing file should be stat'd once in one epoch" + ); +} + +/// Verify cross-entry invalidation semantics. +/// +/// In a long-lived rust-analyzer proc-macro server the same thread handles +/// multiple successive macro invocations. Each entry point (`derive_schema`, +/// `schema_type!`, `schema!`, `export_app!`, `vespera!`) calls `bump_epoch()` +/// as its first statement. This test simulates two successive invocations +/// from *different* entry points and confirms that: +/// +/// 1. Within invocation A (epoch N): path checked once, second access free. +/// 2. Invocation B starts (epoch N+1 via bump): path re-checked exactly once. +/// 3. Within invocation B: second access still free. +/// +/// The test uses `bump_epoch()` directly (the same call each entry point +/// makes) so it exercises the exact mechanism without needing a real +/// proc-macro expansion. +/// +/// NOTE: `bump_epoch()` is the *only* mechanism that separates invocations; +/// the call sites in lib.rs are the authoritative hook locations: +/// - `derive_schema` → reaches file_cache via extract_field_defaults_from_path +/// - `schema` → reaches file_cache via parse_struct_cached +/// - `schema_type!` → reaches file_cache via generate_schema_type_code +/// - `export_app!` → reaches file_cache via collect_metadata +/// - `vespera!` → reaches file_cache via collect_metadata +#[serial_test::serial] +#[test] +fn test_epoch_cross_entry_invalidation() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("cross.rs"); + std::fs::write(&file_path, "pub struct Bar { pub y: u64 }").unwrap(); + + reset_metadata_call_count(); + + // ── Invocation A (simulates e.g. derive_schema entry) ────────────── + bump_epoch(); // what every entry point does first + let before_a = metadata_call_count(); + + let r1 = get_struct_definition(&file_path, "Bar"); + assert!(r1.is_some()); + assert_eq!( + metadata_call_count() - before_a, + 1, + "invocation A: first access must call metadata once" + ); + + // Second access within the same invocation — epoch cache hit. + let r2 = get_struct_definition(&file_path, "Bar"); + assert_eq!(r1, r2); + assert_eq!( + metadata_call_count() - before_a, + 1, + "invocation A: second access must NOT call metadata again" + ); + + // ── Invocation B (simulates e.g. schema_type! entry) ─────────────── + bump_epoch(); // new invocation → new epoch + let before_b = metadata_call_count(); + + // First access in invocation B — epoch cache stale, must re-check. + let r3 = get_struct_definition(&file_path, "Bar"); + assert_eq!(r1, r3); + assert_eq!( + metadata_call_count() - before_b, + 1, + "invocation B: first access must re-check metadata (cross-entry invalidation)" + ); + + // Second access within invocation B — epoch cache hit again. + let r4 = get_struct_definition(&file_path, "Bar"); + assert_eq!(r1, r4); + assert_eq!( + metadata_call_count() - before_b, + 1, + "invocation B: second access must NOT call metadata again" + ); +} + +/// Regression test for the original [`FileCache::file_lists`] bug: a +/// `.rs` file added to a `src_dir` between two epochs must become +/// visible to `get_struct_candidates` after the next [`bump_epoch`], +/// because the directory fingerprint changes. +/// +/// In the pre-fix world the file list was cached forever per `src_dir` +/// with no invalidation mechanism — long-lived rust-analyzer servers +/// silently missed newly added files. This test would have hit the +/// 0-length assertion on the post-bump query. +#[serial_test::serial] +#[test] +fn test_struct_index_invalidates_when_new_file_added() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + + std::fs::write(src_dir.join("first.rs"), "pub struct First { pub id: i32 }").unwrap(); + + bump_epoch(); + let first = get_struct_candidates(src_dir, "First"); + assert_eq!(first.len(), 1, "first.rs must be picked up"); + let missing = get_struct_candidates(src_dir, "Second"); + assert_eq!(missing.len(), 0, "Second is not yet defined"); + + // Simulate a long-lived rust-analyzer session adding a new file + // between two top-level macro invocations. + std::fs::write( + src_dir.join("second.rs"), + "pub struct Second { pub name: String }", + ) + .unwrap(); + bump_epoch(); + + let second = get_struct_candidates(src_dir, "Second"); + assert_eq!( + second.len(), + 1, + "newly added second.rs must appear after the directory fingerprint changes", + ); + // First.rs must still be reachable — the rebuild does not lose + // previously indexed structs. + let first_again = get_struct_candidates(src_dir, "First"); + assert_eq!(first_again.len(), 1, "First must remain after rebuild"); +} + +/// Within a single epoch, repeated `get_struct_candidates` calls must +/// not rewalk the directory. The first call walks + builds; subsequent +/// calls in the same epoch reuse the cached `DirEntry` with no +/// `fs::metadata` syscalls. +#[serial_test::serial] +#[test] +fn test_file_list_skips_walk_within_same_epoch() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write(src_dir.join("a.rs"), "pub struct Alpha { pub id: i32 }").unwrap(); + std::fs::write(src_dir.join("b.rs"), "pub struct Beta { pub name: String }").unwrap(); + + reset_metadata_call_count(); + bump_epoch(); + let before = metadata_call_count(); + + let _ = get_struct_candidates(src_dir, "Alpha"); + let after_first = metadata_call_count(); + assert!( + after_first > before, + "first call must walk the directory (mtime syscalls expected)", + ); + + // Subsequent calls in the same epoch reuse the validated + // `DirEntry` — zero new mtime syscalls for the file-list walk. + let _ = get_struct_candidates(src_dir, "Beta"); + let _ = get_struct_candidates(src_dir, "Alpha"); + assert_eq!( + metadata_call_count(), + after_first, + "same-epoch lookups must not rewalk the directory", + ); +} + +/// Sanity check: the struct identifier index returns *every* file +/// that defines a struct of the given name. Disambiguation by +/// schema-name hint happens in +/// [`super::file_lookup::find_struct_by_name_in_all_files`] *after* +/// the candidate set is returned, so this layer must not pre-filter. +#[serial_test::serial] +#[test] +fn test_struct_index_preserves_disambiguation_candidates() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("user.rs"), + "pub struct Model { pub id: i32, pub name: String }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("memo.rs"), + "pub struct Model { pub id: i32, pub title: String }", + ) + .unwrap(); + + bump_epoch(); + let candidates = get_struct_candidates(src_dir, "Model"); + assert_eq!( + candidates.len(), + 2, + "both files defining Model must be returned for the disambiguation layer", + ); +} + +/// `get_manifest_dir` caches within an epoch but revalidates across epochs, +/// so a long-lived rust-analyzer proc-macro server reused for a DIFFERENT +/// crate (different `CARGO_MANIFEST_DIR`) stops resolving cross-file lookups +/// against the previous crate's `src/`. +#[serial_test::serial] +#[test] +fn manifest_dir_revalidates_across_epochs() { + // Restore the (load-bearing) env var even if an assertion panics. + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + match self.0.take() { + Some(v) => unsafe { std::env::set_var("CARGO_MANIFEST_DIR", v) }, + None => unsafe { std::env::remove_var("CARGO_MANIFEST_DIR") }, + } + } + } + let _restore = Restore(std::env::var("CARGO_MANIFEST_DIR").ok()); + + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", "/vespera_test/crate_a") }; + bump_epoch(); + assert_eq!(get_manifest_dir().as_deref(), Some("/vespera_test/crate_a")); + + // Same epoch: cached even though the env changed underneath. + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", "/vespera_test/crate_b") }; + assert_eq!( + get_manifest_dir().as_deref(), + Some("/vespera_test/crate_a"), + "manifest dir must be cached within an epoch" + ); + + // New epoch: revalidated → picks up the new crate's manifest dir. + bump_epoch(); + assert_eq!( + get_manifest_dir().as_deref(), + Some("/vespera_test/crate_b"), + "manifest dir must revalidate when the epoch advances" + ); +} + +/// H1 benchmark + regression: when a single file is added to a directory +/// (the common rust-analyzer edit between two macro invocations), the +/// struct-index rebuild must re-tokenise ONLY the changed file — not every +/// file in the directory. +/// +/// `extract_struct_names` (the per-file source tokeniser) is the dominant +/// cost of the rebuild that fires whenever the directory fingerprint changes. +/// Before the per-file name cache the rebuild re-tokenised all N files on +/// every edit; after it, only the new/changed file is re-scanned. The +/// tokenisation count is deterministic, so it is the noise-free signal for +/// this compile-time win (printed as `VESPERA_H1 ...`). +#[serial_test::serial] +#[test] +fn h1_single_file_add_reextracts_only_changed_file() { + const N: usize = 20; + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + + for i in 0..N { + std::fs::write( + src_dir.join(format!("model_{i}.rs")), + format!("pub struct Model{i} {{ pub id: i32 }}"), + ) + .unwrap(); + } + + // Cold index build — tokenises every file once (both before and after + // the fix; the win is on the incremental rebuild below). + reset_extract_struct_names_count(); + bump_epoch(); + let first = get_struct_candidates(src_dir, "Model0"); + assert_eq!(first.len(), 1, "Model0 must be indexed"); + let initial_build = extract_struct_names_count(); + + // Add ONE new file and advance the epoch: the directory fingerprint + // changes, so the struct index is dropped and rebuilt on the next query. + std::fs::write( + src_dir.join("model_new.rs"), + "pub struct ModelNew { pub id: i32 }", + ) + .unwrap(); + reset_extract_struct_names_count(); + bump_epoch(); + let added = get_struct_candidates(src_dir, "ModelNew"); + let rebuild = extract_struct_names_count(); + + eprintln!( + "VESPERA_H1 N={N} initial_build_tokenisations={initial_build} \ + single_add_rebuild_tokenisations={rebuild}" + ); + + assert_eq!(added.len(), 1, "newly added ModelNew must be indexed"); + // Correctness: pre-existing structs survive the rebuild. + assert_eq!( + get_struct_candidates(src_dir, "Model0").len(), + 1, + "Model0 must remain reachable after the rebuild" + ); + // The win: only the newly added file is re-tokenised, not all N+1. + assert_eq!( + rebuild, + 1, + "rebuild after a single-file add must re-tokenise only the new file \ + (got {rebuild}; pre-fix this re-tokenised all N+1 = {} files)", + N + 1 + ); +} diff --git a/crates/vespera_macro/src/schema_macro/file_lookup.rs b/crates/vespera_macro/src/schema_macro/file_lookup.rs index 53f87f04..13a929a1 100644 --- a/crates/vespera_macro/src/schema_macro/file_lookup.rs +++ b/crates/vespera_macro/src/schema_macro/file_lookup.rs @@ -1,1647 +1,357 @@ -//! File system operations for finding struct definitions -//! -//! Provides functions to locate struct definitions in source files. - -use std::path::Path; - -use syn::Type; - -use crate::metadata::StructMetadata; -use std::path::PathBuf; - -/// Build candidate file paths from module segments. -/// -/// Given a source directory and module segments (e.g., `["models", "memo"]`), -/// returns both `{src_dir}/models/memo.rs` and `{src_dir}/models/memo/mod.rs`. -#[inline] -fn candidate_file_paths(src_dir: &Path, module_segments: &[&str]) -> [PathBuf; 2] { - let joined = module_segments.join("/"); - [ - src_dir.join(format!("{joined}.rs")), - src_dir.join(format!("{joined}/mod.rs")), - ] -} - -/// Try to find a struct definition from a module path by reading source files. -/// -/// This allows `schema_type`! to work with structs defined in other files, like: -/// ```ignore -/// // In src/routes/memos.rs -/// schema_type!(CreateMemoRequest from models::memo::Model, pick = ["title", "content"]); -/// ``` -/// -/// The function will: -/// 1. Parse the path (e.g., `models::memo::Model` or `crate::models::memo::Model`) -/// 2. Convert to file path (e.g., `src/models/memo.rs`) -/// 3. Read and parse the file to find the struct definition -/// -/// For simple names (e.g., just `Model` without module path), it will scan all `.rs` -/// files in `src/` to find the struct. This supports same-file usage like: -/// ```ignore -/// pub struct Model { ... } -/// vespera::schema_type!(Schema from Model, name = "UserSchema"); -/// ``` -/// -/// The `schema_name_hint` is used to disambiguate when multiple structs with the same -/// name exist. For example, with `name = "UserSchema"`, it will prefer `user.rs`. -/// -/// Returns `(StructMetadata, Vec)` where the Vec is the module path. -/// For qualified paths, this is extracted from the type itself. -/// For simple names, it's inferred from the file location. -pub fn find_struct_from_path( - ty: &Type, - schema_name_hint: Option<&str>, -) -> Option<(StructMetadata, Vec)> { - // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) - let manifest_dir = super::file_cache::get_manifest_dir()?; - let src_dir = Path::new(&manifest_dir).join("src"); - - // Extract path segments from the type - let Type::Path(type_path) = ty else { - return None; - }; - - let segments: Vec = type_path - .path - .segments - .iter() - .map(|s| s.ident.to_string()) - .collect(); - - if segments.is_empty() { - return None; - } - - // The last segment is the struct name - let struct_name = segments.last()?.clone(); - - // Build possible file paths from the module path - // e.g., models::memo::Model -> src/models/memo.rs or src/models/memo/mod.rs - // e.g., crate::models::memo::Model -> src/models/memo.rs - let module_segments: Vec<&str> = segments[..segments.len() - 1] - .iter() - .filter(|s| *s != "crate" && *s != "self" && *s != "super") - .map(std::string::String::as_str) - .collect(); - - // If no module path (simple name like `Model`), scan all files with schema_name hint - if module_segments.is_empty() { - return find_struct_by_name_in_all_files(&src_dir, &struct_name, schema_name_hint); - } - - // For qualified paths, the module path is extracted from the type itself - // e.g., crate::models::memo::Model -> ["crate", "models", "memo"] - let type_module_path: Vec = segments[..segments.len() - 1].to_vec(); - - // Try different file path patterns - let file_paths = candidate_file_paths(&src_dir, &module_segments); - - for file_path in file_paths { - if !file_path.exists() { - continue; - } - if let Some(definition) = super::file_cache::get_struct_definition(&file_path, &struct_name) - { - return Some(( - StructMetadata::new_model(struct_name, definition), - type_module_path, - )); - } - } - - None -} - -/// Find a struct by name by scanning all `.rs` files in the src directory. -/// -/// This is used as a fallback when the type path doesn't include module information -/// (e.g., just `Model` instead of `crate::models::user::Model`). -/// -/// Resolution strategy: -/// 1. If exactly one struct with the name exists -> use it -/// 2. If multiple exist and `schema_name_hint` is provided (e.g., "UserSchema"): -/// -> Prefer file whose name contains the hint prefix (e.g., "user.rs" for "`UserSchema`") -/// 3. Otherwise -> return None (ambiguous) -/// -/// The `schema_name_hint` is the custom schema name (e.g., "`UserSchema`", "`MemoSchema`") -/// which often contains a hint about the module name. -/// -/// Returns `(StructMetadata, Vec)` where the Vec is the inferred module path -/// from the file location (e.g., `["crate", "models", "user"]`). -#[allow(clippy::too_many_lines)] -pub fn find_struct_by_name_in_all_files( - src_dir: &Path, - struct_name: &str, - schema_name_hint: Option<&str>, -) -> Option<(StructMetadata, Vec)> { - // Use cached struct-candidate index: files already filtered by text search - let mut rs_files = super::file_cache::get_struct_candidates(src_dir, struct_name); - - // Pre-compute hint prefix once (used in fast path and fallback disambiguation) - let prefix_normalized = schema_name_hint.map(derive_hint_prefix); - - // FAST PATH: If schema_name_hint is provided, try matching files first. - // This avoids parsing ALL files for the common same-file pattern: - // schema_type!(Schema from Model, name = "UserSchema") in user.rs - if let Some(prefix_normalized) = &prefix_normalized { - // Partition files: candidate files (filename matches hint prefix) vs rest - let (candidates, rest): (Vec<_>, Vec<_>) = rs_files.into_iter().partition(|path| { - path.file_stem() - .and_then(|s| s.to_str()) - .is_some_and(|name| { - let norm = normalize_name(name); - norm == *prefix_normalized || norm.contains(prefix_normalized.as_str()) - }) - }); - - // Parse only candidate files first - let mut found_in_candidates: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); - for file_path in &candidates { - if let Some(definition) = - super::file_cache::get_struct_definition(file_path, struct_name) - { - found_in_candidates.push(( - file_path.clone(), - StructMetadata::new_model(struct_name.to_string(), definition), - )); - } - } - - // If exactly one match in candidates, return immediately (fast path hit!) - if found_in_candidates.len() == 1 { - let (path, metadata) = found_in_candidates.remove(0); - let module_path = file_path_to_module_path(&path, src_dir); - return Some((metadata, module_path)); - } - - // If candidates found multiple, try disambiguation by exact filename match - if found_in_candidates.len() > 1 { - let exact_match: Vec<_> = found_in_candidates - .iter() - .filter(|(path, _)| { - path.file_stem() - .and_then(|s| s.to_str()) - .is_some_and(|name| normalize_name(name) == *prefix_normalized) - }) - .collect(); - - if exact_match.len() == 1 { - let (path, metadata) = exact_match[0]; - let module_path = file_path_to_module_path(path, src_dir); - return Some((metadata.clone(), module_path)); - } - - // Still ambiguous among candidates - return None; - } - - // No match in candidates — fall through to scan remaining files - rs_files = rest; - } - - // FULL SCAN: Parse all remaining files (or all files if no hint) - let mut found_structs: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); - - for file_path in rs_files { - if let Some(definition) = super::file_cache::get_struct_definition(&file_path, struct_name) - { - found_structs.push(( - file_path.clone(), - StructMetadata::new_model(struct_name.to_string(), definition), - )); - } - } - - match found_structs.len() { - 1 => { - let (path, metadata) = found_structs.remove(0); - let module_path = file_path_to_module_path(&path, src_dir); - Some((metadata, module_path)) - } - _ => None, - } -} - -/// Derive a normalized prefix from a schema name hint for file matching. -/// -/// Strips common suffixes ("Schema", "Response", "Request") and normalizes -/// by removing underscores and lowercasing. -/// -/// # Examples -/// - "UserSchema" → "user" -/// - "MemoResponse" → "memo" -/// - "AdminUserSchema" → "adminuser" -fn derive_hint_prefix(hint: &str) -> String { - let hint_lower = hint.to_lowercase(); - let prefix = hint_lower - .strip_suffix("schema") - .or_else(|| hint_lower.strip_suffix("response")) - .or_else(|| hint_lower.strip_suffix("request")) - .unwrap_or(&hint_lower); - normalize_name(prefix) -} - -/// Normalize a name by lowercasing and removing underscores in a single pass. -/// Replaces the two-allocation `s.to_lowercase().replace('_', "")` pattern. -#[inline] -fn normalize_name(s: &str) -> String { - s.chars() - .filter(|&c| c != '_') - .map(|c| c.to_ascii_lowercase()) - .collect() -} - -/// Recursively collect all `.rs` files in a directory. -pub fn collect_rs_files_recursive(dir: &Path, files: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_rs_files_recursive(&path, files); - } else if path.extension().is_some_and(|ext| ext == "rs") { - files.push(path); - } - } -} - -/// Derive module path from a file path relative to src directory. -/// -/// Examples: -/// - `src/models/user.rs` -> `["crate", "models", "user"]` -/// - `src/models/user/mod.rs` -> `["crate", "models", "user"]` -/// - `src/lib.rs` -> `["crate"]` -pub fn file_path_to_module_path(file_path: &Path, src_dir: &Path) -> Vec { - let Ok(relative) = file_path.strip_prefix(src_dir) else { - return vec!["crate".to_string()]; - }; - - let mut segments = vec!["crate".to_string()]; - - for component in relative.components() { - if let std::path::Component::Normal(os_str) = component - && let Some(s) = os_str.to_str() - { - // Handle .rs extension - if let Some(name) = s.strip_suffix(".rs") { - // Skip mod.rs and lib.rs - they don't add a segment - if name != "mod" && name != "lib" { - segments.push(name.to_string()); - } - } else { - // Directory name - segments.push(s.to_string()); - } - } - } - - segments -} - -/// Find struct definition from a schema path string (e.g., "`crate::models::user::Schema`"). -/// -/// Similar to `find_struct_from_path` but takes a string path instead of `syn::Type`. -pub fn find_struct_from_schema_path(path_str: &str) -> Option { - // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) - let manifest_dir = super::file_cache::get_manifest_dir()?; - let src_dir = Path::new(&manifest_dir).join("src"); - - // Parse the path string into segments - let segments: Vec<&str> = path_str.split("::").filter(|s| !s.is_empty()).collect(); - - if segments.is_empty() { - return None; - } - - // The last segment is the struct name - let struct_name = segments.last()?.to_string(); - - // Build possible file paths from the module path - // e.g., crate::models::user::Schema -> src/models/user.rs - let module_segments: Vec<&str> = segments[..segments.len() - 1] - .iter() - .filter(|s| **s != "crate" && **s != "self" && **s != "super") - .copied() - .collect(); - - if module_segments.is_empty() { - return None; - } - - // Try different file path patterns - let file_paths = candidate_file_paths(&src_dir, &module_segments); - - for file_path in file_paths { - if !file_path.exists() { - continue; - } - if let Some(definition) = super::file_cache::get_struct_definition(&file_path, &struct_name) - { - return Some(StructMetadata::new_model(struct_name, definition)); - } - } - - None -} - -/// Find the FK column name from the target entity for a `HasMany` relation with `via_rel`. -/// -/// When a `HasMany` relation has `via_rel = "TargetUser"`, this function: -/// 1. Looks up the target entity file (e.g., notification.rs from schema path) -/// 2. Finds the field with matching `relation_enum = "TargetUser"` -/// 3. Extracts and returns the `from` attribute value (e.g., "`target_user_id`") -/// -/// Returns None if the target file can't be found or parsed, or if no matching relation exists. -#[allow(clippy::too_many_lines)] -pub fn find_fk_column_from_target_entity( - target_schema_path: &str, - via_rel: &str, -) -> Option { - use crate::schema_macro::seaorm::{extract_belongs_to_from_field, extract_relation_enum}; - - // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) - let manifest_dir = super::file_cache::get_manifest_dir()?; - let src_dir = Path::new(&manifest_dir).join("src"); - - // Parse the schema path to get file path - // e.g., "crate :: models :: notification :: Schema" -> src/models/notification.rs - let segments: Vec<&str> = target_schema_path - .split("::") - .map(str::trim) - .filter(|s| !s.is_empty() && *s != "Schema" && *s != "Entity") - .collect(); - - let module_segments: Vec<&str> = segments - .iter() - .filter(|s| **s != "crate" && **s != "self" && **s != "super") - .copied() - .collect(); - - if module_segments.is_empty() { - return None; - } - - // Try different file path patterns - let file_paths = candidate_file_paths(&src_dir, &module_segments); - - for file_path in file_paths { - if !file_path.exists() { - continue; - } - - let Some(model_def) = super::file_cache::get_struct_definition(&file_path, "Model") else { - continue; - }; - let Ok(model) = super::file_cache::parse_struct_cached(&model_def) else { - continue; - }; - - // Search through fields for the one with matching relation_enum - if let syn::Fields::Named(fields_named) = &model.fields { - for field in &fields_named.named { - let field_relation_enum = extract_relation_enum(&field.attrs); - if field_relation_enum.as_deref() == Some(via_rel) { - // Found the matching field, extract FK column from `from` attribute - return extract_belongs_to_from_field(&field.attrs); - } - } - } - } - - None -} - -/// Find the Model definition from a Schema path. -/// Converts "`crate::models::user::Schema`" -> finds Model in src/models/user.rs -#[allow(clippy::too_many_lines)] -pub fn find_model_from_schema_path(schema_path_str: &str) -> Option { - // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) - let manifest_dir = super::file_cache::get_manifest_dir()?; - let src_dir = Path::new(&manifest_dir).join("src"); - - // Parse the path string and convert Schema path to module path - // e.g., "crate :: models :: user :: Schema" -> ["crate", "models", "user"] - let segments: Vec<&str> = schema_path_str - .split("::") - .map(str::trim) - .filter(|s| !s.is_empty() && *s != "Schema") - .collect(); - - if segments.is_empty() { - return None; - } - - // Build possible file paths from the module path - let module_segments: Vec<&str> = segments - .iter() - .filter(|s| **s != "crate" && **s != "self" && **s != "super") - .copied() - .collect(); - - if module_segments.is_empty() { - return None; - } - - // Try different file path patterns - let file_paths = candidate_file_paths(&src_dir, &module_segments); - - for file_path in file_paths { - if !file_path.exists() { - continue; - } - if let Some(definition) = super::file_cache::get_struct_definition(&file_path, "Model") { - return Some(StructMetadata::new_model("Model".to_string(), definition)); - } - } - - None -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use serial_test::serial; - use tempfile::TempDir; - - use super::*; - - #[test] - fn test_file_path_to_module_path_simple() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - let file_path = src_dir.join("models").join("user.rs"); - let result = file_path_to_module_path(&file_path, src_dir); - assert_eq!(result, vec!["crate", "models", "user"]); - } - - #[test] - fn test_file_path_to_module_path_mod_rs() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - let file_path = src_dir.join("models").join("mod.rs"); - let result = file_path_to_module_path(&file_path, src_dir); - assert_eq!(result, vec!["crate", "models"]); - } - - #[test] - fn test_file_path_to_module_path_lib_rs() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - let file_path = src_dir.join("lib.rs"); - let result = file_path_to_module_path(&file_path, src_dir); - assert_eq!(result, vec!["crate"]); - } - - #[test] - fn test_file_path_to_module_path_not_under_src() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let file_path = temp_dir.path().join("other").join("file.rs"); - let result = file_path_to_module_path(&file_path, &src_dir); - assert_eq!(result, vec!["crate"]); - } - - #[test] - fn test_collect_rs_files_recursive_empty_dir() { - let temp_dir = TempDir::new().unwrap(); - let mut files = Vec::new(); - collect_rs_files_recursive(temp_dir.path(), &mut files); - assert!(files.is_empty()); - } - - #[test] - fn test_collect_rs_files_recursive_nonexistent_dir() { - let mut files = Vec::new(); - collect_rs_files_recursive(Path::new("/nonexistent/path"), &mut files); - assert!(files.is_empty()); - } - - #[test] - fn test_collect_rs_files_recursive_with_files() { - let temp_dir = TempDir::new().unwrap(); - - // Create some .rs files - std::fs::write(temp_dir.path().join("main.rs"), "fn main() {}").unwrap(); - std::fs::create_dir(temp_dir.path().join("models")).unwrap(); - std::fs::write( - temp_dir.path().join("models").join("user.rs"), - "struct User;", - ) - .unwrap(); - std::fs::write(temp_dir.path().join("other.txt"), "not a rust file").unwrap(); - - let mut files = Vec::new(); - collect_rs_files_recursive(temp_dir.path(), &mut files); - - assert_eq!(files.len(), 2); - assert!(files.iter().all(|f| f.extension().unwrap() == "rs")); - } - - // ============================================================ - // Coverage tests for find_struct_from_path - // ============================================================ - - #[test] - fn test_find_struct_from_path_non_path_type() { - // Tests: Type is not a Path type -> returns None - use syn::Type; - - // Create a reference type (not a path type) - let ty: Type = syn::parse_str("&str").unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - - // Set a temporary manifest dir (doesn't matter since we'll return early) - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_struct_from_path(&ty, None); - - // Restore - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Non-path type should return None"); - } - - #[test] - fn test_find_struct_from_path_empty_segments() { - // Tests: Type path with empty segments -> returns None - use syn::{Path, TypePath}; - - // Construct a TypePath with empty segments - let empty_path = Path { - leading_colon: None, - segments: syn::punctuated::Punctuated::new(), - }; - let ty = Type::Path(TypePath { - qself: None, - path: empty_path, - }); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_struct_from_path(&ty, None); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Empty segments should return None"); - } - - #[test] - #[serial] - fn test_find_struct_from_path_file_with_non_matching_items() { - // Tests: File contains items that are not the target struct - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create a file with multiple items, only one matching - let content = r" -pub enum SomeEnum { A, B } -pub fn some_function() {} -pub const SOME_CONST: i32 = 42; -pub trait SomeTrait {} -pub struct NotTarget { pub x: i32 } -pub struct Target { pub id: i32 } -"; - std::fs::write(models_dir.join("mixed.rs"), content).unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let ty: Type = syn::parse_str("crate::models::mixed::Target").unwrap(); - let result = find_struct_from_path(&ty, None); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_some(), "Should find Target struct"); - let (metadata, _) = result.unwrap(); - assert!(metadata.definition.contains("Target")); - } - - // ============================================================ - // Coverage tests for find_struct_by_name_in_all_files - // ============================================================ - - #[test] - #[serial] - fn test_find_struct_by_name_unreadable_file() { - // Tests for error continuation - // Create broken symlink that exists but can't be read - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // Valid file - std::fs::write( - src_dir.join("valid.rs"), - "pub struct Target { pub id: i32 }", - ) - .unwrap(); - - // Broken symlink -> read_to_string fails -> line 122 - let broken = src_dir.join("broken.rs"); - let nonexistent = src_dir.join("nonexistent"); - #[cfg(unix)] - let _ = std::os::unix::fs::symlink(&nonexistent, &broken); - #[cfg(windows)] - let _ = std::os::windows::fs::symlink_file(&nonexistent, &broken); - - let result = find_struct_by_name_in_all_files(src_dir, "Target", None); - - assert!( - result.is_some(), - "Should find Target, skipping broken symlink" - ); - } - - #[test] - #[serial] - fn test_find_struct_by_name_unparseable_file() { - // Tests: File cannot be parsed -> continue to next file - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // Create an unparseable file - std::fs::write(src_dir.join("broken.rs"), "this is not valid rust {{{{").unwrap(); - - // Create a valid file with the struct - std::fs::write( - src_dir.join("valid.rs"), - "pub struct Target { pub id: i32 }", - ) - .unwrap(); - - let result = find_struct_by_name_in_all_files(src_dir, "Target", None); - - assert!( - result.is_some(), - "Should find Target in valid file, skipping broken" - ); - } - - #[test] - #[serial] - fn test_find_struct_disambiguation_with_hint() { - // Tests: Multiple structs with same name, schema_name_hint disambiguates - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // Create user.rs with Model - std::fs::create_dir(src_dir.join("models")).unwrap(); - std::fs::write( - src_dir.join("models").join("user.rs"), - "pub struct Model { pub id: i32, pub name: String }", - ) - .unwrap(); - - // Create memo.rs with Model (same struct name) - std::fs::write( - src_dir.join("models").join("memo.rs"), - "pub struct Model { pub id: i32, pub title: String }", - ) - .unwrap(); - - // Without hint - should return None (ambiguous) - let result_no_hint = find_struct_by_name_in_all_files(src_dir, "Model", None); - assert!( - result_no_hint.is_none(), - "Without hint, multiple Models should be ambiguous" - ); - - // With hint "UserSchema" - should find user.rs - let result_with_hint = - find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - assert!( - result_with_hint.is_some(), - "With UserSchema hint, should find user.rs" - ); - let (metadata, module_path) = result_with_hint.unwrap(); - assert!( - metadata.definition.contains("name"), - "Should be user Model with name field" - ); - assert!( - module_path.contains(&"user".to_string()), - "Module path should contain 'user'" - ); - - // With hint "MemoSchema" - should find memo.rs - let result_memo = find_struct_by_name_in_all_files(src_dir, "Model", Some("MemoSchema")); - assert!( - result_memo.is_some(), - "With MemoSchema hint, should find memo.rs" - ); - let (metadata_memo, _) = result_memo.unwrap(); - assert!( - metadata_memo.definition.contains("title"), - "Should be memo Model with title field" - ); - } - - #[test] - #[serial] - fn test_find_struct_disambiguation_with_response_suffix() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::create_dir(src_dir.join("models")).unwrap(); - std::fs::write( - src_dir.join("models").join("user.rs"), - "pub struct Data { pub id: i32 }", - ) - .unwrap(); - std::fs::write( - src_dir.join("models").join("item.rs"), - "pub struct Data { pub name: String }", - ) - .unwrap(); - - // With hint "UserResponse" - should find user.rs - let result = find_struct_by_name_in_all_files(src_dir, "Data", Some("UserResponse")); - assert!( - result.is_some(), - "With UserResponse hint, should find user.rs" - ); - } - - #[test] - #[serial] - fn test_find_struct_disambiguation_with_request_suffix() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::create_dir(src_dir.join("models")).unwrap(); - std::fs::write( - src_dir.join("models").join("user.rs"), - "pub struct Input { pub id: i32 }", - ) - .unwrap(); - std::fs::write( - src_dir.join("models").join("item.rs"), - "pub struct Input { pub name: String }", - ) - .unwrap(); - - // With hint "UserRequest" - should find user.rs - let result = find_struct_by_name_in_all_files(src_dir, "Input", Some("UserRequest")); - assert!( - result.is_some(), - "With UserRequest hint, should find user.rs" - ); - } - - #[test] - #[serial] - fn test_find_struct_disambiguation_still_ambiguous() { - // Tests: Multiple matches even after applying hint -> returns None - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // Create two files that both match the hint - std::fs::create_dir(src_dir.join("models")).unwrap(); - std::fs::write( - src_dir.join("models").join("user_admin.rs"), - "pub struct Model { pub id: i32 }", - ) - .unwrap(); - std::fs::write( - src_dir.join("models").join("user_regular.rs"), - "pub struct Model { pub name: String }", - ) - .unwrap(); - - // With hint "UserSchema" - both user_admin.rs and user_regular.rs match - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - assert!( - result.is_none(), - "Multiple files matching hint should still be ambiguous" - ); - } - - #[test] - #[serial] - fn test_find_struct_disambiguation_snake_case_filename() { - // Tests: CamelCase schema name matches snake_case filename - // e.g., "AdminUserSchema" should match "admin_user.rs" - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::create_dir(src_dir.join("models")).unwrap(); - // Create admin_user.rs with Model - std::fs::write( - src_dir.join("models").join("admin_user.rs"), - "pub struct Model { pub id: i32, pub role: String }", - ) - .unwrap(); - // Create regular_user.rs with Model - std::fs::write( - src_dir.join("models").join("regular_user.rs"), - "pub struct Model { pub id: i32, pub name: String }", - ) - .unwrap(); - - // With hint "AdminUserSchema" - should find admin_user.rs - // "AdminUserSchema" -> prefix "adminuser" -> matches "admin_user.rs" (normalized: "adminuser") - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("AdminUserSchema")); - assert!( - result.is_some(), - "AdminUserSchema hint should match admin_user.rs" - ); - let (metadata, module_path) = result.unwrap(); - assert!( - metadata.definition.contains("role"), - "Should be admin_user Model with role field" - ); - assert!( - module_path.contains(&"admin_user".to_string()), - "Module path should contain 'admin_user'" - ); - - // With hint "RegularUserSchema" - should find regular_user.rs - let result_regular = - find_struct_by_name_in_all_files(src_dir, "Model", Some("RegularUserSchema")); - assert!( - result_regular.is_some(), - "RegularUserSchema hint should match regular_user.rs" - ); - let (metadata_regular, _) = result_regular.unwrap(); - assert!( - metadata_regular.definition.contains("name"), - "Should be regular_user Model with name field" - ); - } - - // ============================================================ - // Coverage tests for find_struct_from_schema_path - // ============================================================ - - #[test] - fn test_find_struct_from_schema_path_empty_string() { - // Tests: Empty path string -> returns None - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_struct_from_schema_path(""); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Empty path should return None"); - } - - #[test] - fn test_find_struct_from_schema_path_no_module() { - // Tests: Path with only struct name (no module) -> returns None - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Only "Schema" with no module path - after filtering crate/self/super, module_segments is empty - let result = find_struct_from_schema_path("crate::Schema"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Path with no module should return None"); - } - - #[test] - #[serial] - fn test_find_struct_from_schema_path_with_non_struct_items() { - // Tests: File contains non-struct items that get skipped - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - let content = r" -pub enum NotStruct { A, B } -pub fn not_struct() {} -pub struct Target { pub id: i32 } -pub const NOT_STRUCT: i32 = 1; -"; - std::fs::write(models_dir.join("item.rs"), content).unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_struct_from_schema_path("crate::models::item::Target"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_some(), "Should find Target struct"); - assert!(result.unwrap().definition.contains("Target")); - } - - // ============================================================ - // Coverage tests for find_model_from_schema_path - // ============================================================ - - #[test] - fn test_find_model_from_schema_path_empty_after_filter() { - // Tests: After filtering "Schema" and other keywords, segments is empty - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Only "Schema" - after filtering, empty - let result = find_model_from_schema_path("Schema"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Empty segments should return None"); - } - - #[test] - fn test_find_model_from_schema_path_no_module() { - // Tests: After filtering crate/self/super, module_segments is empty - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - let temp_dir = TempDir::new().unwrap(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // "crate::Schema" - after filtering "Schema" and "crate", module_segments is empty - let result = find_model_from_schema_path("crate::Schema"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "No module segments should return None"); - } - - #[test] - #[serial] - fn test_find_model_from_schema_path_success() { - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - let content = "pub struct Model { pub id: i32, pub name: String }"; - std::fs::write(models_dir.join("user.rs"), content).unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; +//! File system operations for finding struct definitions. + +mod fk; +mod lookup; + +#[allow(unused_imports)] +pub use fk::find_fk_column_from_target_entity; +#[allow(unused_imports)] +pub use lookup::{ + collect_rs_files_recursive, file_path_to_module_path, find_model_from_schema_path, + find_struct_from_path_detailed, find_struct_from_schema_path, +}; +#[cfg(test)] +pub use lookup::{find_struct_by_name_in_all_files, find_struct_from_path}; - let result = find_model_from_schema_path("crate::models::user::Schema"); +#[cfg(test)] +mod schema_type_lookup_tests { + use std::collections::HashMap; - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } + use quote::quote; + use serial_test::serial; - assert!(result.is_some(), "Should find Model"); - assert!(result.unwrap().definition.contains("Model")); - } + use crate::metadata::StructMetadata; + use crate::schema_macro::{SchemaTypeInput, generate_schema_type_code}; #[test] #[serial] - fn test_find_struct_disambiguation_fallback_contains() { - // Tests: No exact match, but fallback "contains" finds exactly one match - // Tests for fallback contains path - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - std::fs::create_dir(src_dir.join("models")).unwrap(); - // No file named exactly "special.rs", but "special_item.rs" contains "special" - std::fs::write( - src_dir.join("models").join("special_item.rs"), - "pub struct Model { pub special_field: i32 }", - ) - .unwrap(); - // Another file that doesn't match - std::fs::write( - src_dir.join("models").join("regular.rs"), - "pub struct Model { pub regular_field: String }", - ) - .unwrap(); - - // With hint "SpecialSchema" -> prefix "special" - // No exact match (no "special.rs"), but "special_item.rs" contains "special" - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("SpecialSchema")); - assert!( - result.is_some(), - "SpecialSchema hint should match special_item.rs via contains fallback" - ); - let (metadata, module_path) = result.unwrap(); - assert!( - metadata.definition.contains("special_field"), - "Should be special_item Model with special_field" - ); - assert!( - module_path.contains(&"special_item".to_string()), - "Module path should contain 'special_item'" - ); - } - - // ============================================================ - // Tests for find_fk_column_from_target_entity - // ============================================================ + fn test_generate_schema_type_code_qualified_path_file_lookup_success() { + // Tests: qualified path found via file lookup, module_path used when source is empty + use tempfile::TempDir; - #[test] - #[serial] - fn test_find_fk_column_from_target_entity_success() { - // Tests: Full success path - find FK column from target entity - // Full success path let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); std::fs::create_dir_all(&models_dir).unwrap(); - // Create notification.rs with a BelongsTo relation that has relation_enum matching via_rel - let notification_model = r#" + // Create user.rs with Model struct + let user_model = r" pub struct Model { pub id: i32, - pub message: String, - pub target_user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "target_user_id", to = "id", relation_enum = "TargetUser")] - pub target_user: BelongsTo, + pub name: String, + pub email: String, } -"#; - std::fs::write(models_dir.join("notification.rs"), notification_model).unwrap(); +"; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - let result = - find_fk_column_from_target_entity("crate::models::notification::Schema", "TargetUser"); + // Use qualified path - file lookup should succeed + let tokens = quote!(UserSchema from crate::models::user::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); // Empty storage - force file lookup + + let result = generate_schema_type_code(&input, &storage); + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - assert_eq!( - result, - Some("target_user_id".to_string()), - "Should find FK column 'target_user_id'" - ); + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("UserSchema")); + assert!(output.contains("id")); + assert!(output.contains("name")); + assert!(output.contains("email")); } #[test] #[serial] - fn test_find_fk_column_from_target_entity_mod_rs() { - // Tests: Find FK column from mod.rs file + fn test_generate_schema_type_code_simple_name_file_lookup_fallback() { + // Tests: simple name (not in storage) found via file lookup with schema_name hint + use tempfile::TempDir; + let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models").join("notification"); + let models_dir = src_dir.join("models"); std::fs::create_dir_all(&models_dir).unwrap(); - let notification_model = r#" + // Create user.rs with Model struct + let user_model = r" pub struct Model { pub id: i32, - pub sender_id: i32, - #[sea_orm(belongs_to = "super::super::user::Entity", from = "sender_id", to = "id", relation_enum = "Sender")] - pub sender: BelongsTo, + pub username: String, } -"#; - std::fs::write(models_dir.join("mod.rs"), notification_model).unwrap(); +"; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - let result = - find_fk_column_from_target_entity("crate::models::notification::Schema", "Sender"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert_eq!( - result, - Some("sender_id".to_string()), - "Should find FK column from mod.rs" - ); - } - - #[test] - #[serial] - fn test_find_fk_column_from_target_entity_empty_module_segments() { - // Tests: Empty module segments return None - let temp_dir = TempDir::new().unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + // Use simple name with schema_name hint - file lookup should find it via hint + // name = "UserSchema" provides hint to look in user.rs + let tokens = quote!(Schema from Model, name = "UserSchema"); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); // Empty storage - force file lookup - // After filtering "crate", "Schema", segments is empty - let result = find_fk_column_from_target_entity("crate::Schema", "SomeRelation"); + let result = generate_schema_type_code(&input, &storage); + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - assert!(result.is_none(), "Empty module segments should return None"); + assert!(result.is_ok()); + let (tokens, metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("Schema")); + assert!(output.contains("id")); + assert!(output.contains("username")); + // Metadata should be returned for custom name + assert!(metadata.is_some()); + assert_eq!(metadata.unwrap().name, "UserSchema"); } - #[test] - #[serial] - fn test_find_fk_column_from_target_entity_file_not_found() { - // Tests: File does not exist -> continue, then return None - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - std::fs::create_dir_all(&src_dir).unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Path to non-existent file - let result = - find_fk_column_from_target_entity("crate::models::nonexistent::Schema", "SomeRelation"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Non-existent file should return None"); - } + // ============================================================ + // Tests for HasMany explicit pick with inline type + // ============================================================ #[test] #[serial] - fn test_find_fk_column_from_target_entity_unparseable_file() { - // Tests: File cannot be parsed -> returns None - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create unparseable file - std::fs::write(models_dir.join("broken.rs"), "this is not valid rust {{{{").unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = - find_fk_column_from_target_entity("crate::models::broken::Schema", "SomeRelation"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_none(), "Unparseable file should return None"); - } + fn test_generate_schema_type_code_has_many_explicit_pick_inline_type() { + // Tests: HasMany is explicitly picked, inline type is generated + use tempfile::TempDir; - #[test] - #[serial] - fn test_find_fk_column_from_target_entity_no_model_struct() { - // Tests: File exists but has no Model struct let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); std::fs::create_dir_all(&models_dir).unwrap(); - // Create file without Model struct - let content = r" -pub struct SomethingElse { + // Create memo.rs with Model struct (the target of HasMany) + let memo_model = r" +pub struct Model { pub id: i32, + pub title: String, + pub content: String, } -pub enum Status { Active, Inactive } "; - std::fs::write(models_dir.join("nomodel.rs"), content).unwrap(); + std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // Create user.rs with Model struct that has HasMany relation + let user_model = r#" +#[sea_orm(table_name = "users")] +pub struct Model { + pub id: i32, + pub name: String, + pub memos: HasMany, +} +"#; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); + + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - let result = - find_fk_column_from_target_entity("crate::models::nomodel::Schema", "SomeRelation"); + // Explicitly pick HasMany field - should generate inline type + let tokens = + quote!(UserSchema from crate::models::user::Model, pick = ["id", "name", "memos"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + let result = generate_schema_type_code(&input, &storage); + + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - assert!( - result.is_none(), - "File without Model struct should return None" - ); + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should have inline type definition for memos + assert!(output.contains("UserSchema")); + assert!(output.contains("memos")); + // Inline type should be Vec + assert!(output.contains("Vec <")); } #[test] #[serial] - fn test_find_fk_column_from_target_entity_no_matching_relation_enum() { - // Tests: Model exists but no field matches the via_rel + fn test_generate_schema_type_code_has_many_explicit_pick_file_not_found() { + // Tests: HasMany is explicitly picked but target file not found - should skip field + use tempfile::TempDir; + let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); std::fs::create_dir_all(&models_dir).unwrap(); - // Create model with different relation_enum - let model = r#" + // Create user.rs with Model struct that has HasMany to nonexistent model + let user_model = r#" +#[sea_orm(table_name = "users")] pub struct Model { pub id: i32, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id", relation_enum = "Author")] - pub user: BelongsTo, + pub name: String, + pub items: HasMany, } "#; - std::fs::write(models_dir.join("comment.rs"), model).unwrap(); + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - // Search for "TargetUser" but only "Author" exists - let result = - find_fk_column_from_target_entity("crate::models::comment::Schema", "TargetUser"); + // Explicitly picked HasMany field must error when inline generation fails. + let tokens = + quote!(UserSchema from crate::models::user::Model, pick = ["id", "name", "items"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + + let result = generate_schema_type_code(&input, &storage); + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } + let err = result.expect_err("picked relation must not be silently omitted"); assert!( - result.is_none(), - "Non-matching relation_enum should return None" + err.to_string().contains("explicitly picked"), + "unexpected error: {err}" ); } - #[test] #[serial] - fn test_find_fk_column_from_target_entity_tuple_struct() { - // Tests: Model is a tuple struct (not named fields) -> skip - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create tuple struct Model - let model = "pub struct Model(i32, String);"; - std::fs::write(models_dir.join("tuple.rs"), model).unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = - find_fk_column_from_target_entity("crate::models::tuple::Schema", "SomeRelation"); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } + fn test_generate_schema_type_code_qualified_path_with_nonempty_module_path() { + // Tests: qualified path with explicit module segments that are not empty + use tempfile::TempDir; - assert!(result.is_none(), "Tuple struct Model should return None"); - } - - #[test] - #[serial] - fn test_find_fk_column_from_target_entity_field_no_from_attr() { - // Tests: Field matches relation_enum but has no `from` attribute let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); std::fs::create_dir_all(&models_dir).unwrap(); - // Create model with relation_enum but no `from` attribute - let model = r#" + // Create user.rs + let user_model = r" pub struct Model { pub id: i32, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", to = "id", relation_enum = "TargetUser")] - pub user: BelongsTo, + pub name: String, } -"#; - std::fs::write(models_dir.join("nofrom.rs"), model).unwrap(); +"; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - let result = - find_fk_column_from_target_entity("crate::models::nofrom::Schema", "TargetUser"); + // crate::models::user::Model - this is a qualified path + // extract_module_path should return ["crate", "models", "user"] + // So the if source_module_path.is_empty() check should be false + let tokens = quote!(UserSchema from crate::models::user::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + + let result = generate_schema_type_code(&input, &storage); + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - // extract_belongs_to_from_field returns None when no `from` attr - assert!( - result.is_none(), - "Field without 'from' attribute should return None" - ); - } - - // ============================================================ - // Coverage tests for find_struct_by_name_in_all_files (candidate/rest paths) - // ============================================================ - - #[test] - #[serial] - fn test_find_struct_candidate_unparseable_file() { - // Tests line 145: candidate file fails to parse -> continue to next candidate - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // user.rs matches hint prefix "user" (candidate), contains "Model" text, but won't parse - std::fs::write( - src_dir.join("user.rs"), - "pub struct Model {{{{ broken syntax", - ) - .unwrap(); - - // valid.rs contains Model and parses fine (goes to rest since filename doesn't match prefix) - std::fs::write(src_dir.join("valid.rs"), "pub struct Model { pub id: i32 }").unwrap(); - - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - - assert!( - result.is_some(), - "Should find Model in valid.rs after skipping unparseable candidate user.rs" - ); - } - - #[test] - #[serial] - fn test_find_struct_exact_filename_disambiguation() { - // Tests lines 168-170: multiple candidates found, exact filename match disambiguates - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // user.rs: exact match (normalize_name("user") == prefix "user") - std::fs::write(src_dir.join("user.rs"), "pub struct Model { pub id: i32 }").unwrap(); - - // user_extended.rs: contains-match only (normalize_name("user_extended") = "userextended" != "user") - std::fs::write( - src_dir.join("user_extended.rs"), - "pub struct Model { pub name: String }", - ) - .unwrap(); - - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - - assert!(result.is_some(), "Should resolve via exact filename match"); - let (metadata, _) = result.unwrap(); - assert!( - metadata.definition.contains("id"), - "Should return user.rs Model (with id field)" - ); - } - - #[test] - #[serial] - fn test_find_struct_no_match_in_candidates_falls_to_rest() { - // Tests line 189: candidates have no struct match -> rs_files = rest -> full scan finds it - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // user.rs is a candidate (filename matches "user" prefix) but has no struct Model - // Must contain "Model" text for get_struct_candidates to include it - std::fs::write( - src_dir.join("user.rs"), - "pub struct Other { pub x: i32 } // Model ref", - ) - .unwrap(); - - // data.rs is in rest (filename "data" doesn't contain "user"), has struct Model - std::fs::write(src_dir.join("data.rs"), "pub struct Model { pub id: i32 }").unwrap(); - - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - - assert!( - result.is_some(), - "Should find Model in data.rs after candidates had no match" - ); + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("UserSchema")); } #[test] #[serial] - fn test_find_struct_full_scan_unparseable_file() { - // Tests line 197: full-scan file fails to parse -> continue to next file - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path(); - - // user.rs is candidate but no struct Model - std::fs::write( - src_dir.join("user.rs"), - "pub struct Other { pub x: i32 } // Model", - ) - .unwrap(); + fn test_generate_schema_type_code_cross_module_json_alias_uses_public_path() { + use tempfile::TempDir; - // broken.rs is rest, contains "Model" text but won't parse - std::fs::write(src_dir.join("broken.rs"), "Model unparseable {{{{{").unwrap(); - - // valid.rs is rest, has struct Model - std::fs::write(src_dir.join("valid.rs"), "pub struct Model { pub id: i32 }").unwrap(); - - let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); - - assert!( - result.is_some(), - "Should find Model in valid.rs after skipping unparseable broken.rs in rest" - ); - } - - #[test] - #[serial] - fn test_find_struct_from_path_qualified_module_path() { - // Exercises the candidate_file_paths call (line 82) with a fully qualified path - // where the file exists at the expected module location let temp_dir = TempDir::new().unwrap(); let src_dir = temp_dir.path().join("src"); let models_dir = src_dir.join("models"); + let routes_dir = src_dir.join("routes"); std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::create_dir_all(&routes_dir).unwrap(); - // Create user.rs at the expected module path location - std::fs::write( - models_dir.join("user.rs"), - "pub struct Model { pub id: i32, pub name: String }", - ) - .unwrap(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Use a fully qualified path: crate::models::user::Model - // This ensures module_segments = ["models", "user"] (non-empty after filtering "crate") - // which reaches line 82: candidate_file_paths(&src_dir, &module_segments) - let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); - let result = find_struct_from_path(&ty, None); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!( - result.is_some(), - "Should find Model struct via qualified path" - ); - let (metadata, module_path) = result.unwrap(); - assert!( - metadata.definition.contains("Model"), - "Definition should contain Model" - ); - assert_eq!( - module_path, - vec!["crate", "models", "user"], - "Module path should be inferred from type path" - ); - } + let json_case_model = r#" +use sea_orm::entity::prelude::*; - #[test] - #[serial] - fn test_find_struct_from_path_mod_rs_variant() { - // Exercises candidate_file_paths with the mod.rs pattern - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models").join("user"); - std::fs::create_dir_all(&models_dir).unwrap(); +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "json_case")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub payload: Json, +} - // Create mod.rs instead of user.rs +impl ActiveModelBehavior for ActiveModel {} +"#; + std::fs::write(models_dir.join("json_case.rs"), json_case_model).unwrap(); std::fs::write( - models_dir.join("mod.rs"), - "pub struct Model { pub id: i32, pub email: String }", + routes_dir.join("json_case.rs"), + "vespera::schema_type!(RouteJsonCaseSchema from crate::models::json_case::Model);", ) .unwrap(); - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); - let result = find_struct_from_path(&ty, None); - - unsafe { - if let Some(dir) = original { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_some(), "Should find Model struct via mod.rs path"); - let (metadata, _) = result.unwrap(); - assert!( - metadata.definition.contains("email"), - "Should find the correct Model with email field" - ); - } - - #[test] - #[serial] - fn test_find_fk_column_parse_struct_cached_failure() { - // Exercises line 334: get_struct_definition succeeds but parse_struct_cached fails. - // We inject an invalid struct definition string into the cache so that - // parse_struct_cached returns Err, triggering the `continue` branch. - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create a real file so the file_path exists (candidate_file_paths will find it) - let model_file = models_dir.join("item.rs"); - std::fs::write(&model_file, "pub struct Model { pub id: i32 }").unwrap(); - - // Inject a CORRUPT definition for "Model" at this path — syn::parse_str will fail - crate::schema_macro::file_cache::inject_struct_definition_for_test( - &model_file, - "Model", - "not valid rust {{ struct }}", - ); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - // This should trigger: get_struct_definition -> Some(corrupt) -> parse_struct_cached -> Err -> continue - let result = - find_fk_column_from_target_entity("crate::models::item::Schema", "SomeRelation"); + let tokens = quote!(RouteJsonCaseSchema from crate::models::json_case::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + let result = generate_schema_type_code(&input, &storage); unsafe { - if let Some(dir) = original { + if let Some(dir) = original_manifest_dir { std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { std::env::remove_var("CARGO_MANIFEST_DIR"); } } - assert!( - result.is_none(), - "Should return None when struct definition fails to parse" - ); + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("pub payload : vespera :: serde_json :: Value")); + assert!(!output.contains("crate :: models :: json_case :: Json")); } } diff --git a/crates/vespera_macro/src/schema_macro/file_lookup/fk.rs b/crates/vespera_macro/src/schema_macro/file_lookup/fk.rs new file mode 100644 index 00000000..5a4ac2f7 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/file_lookup/fk.rs @@ -0,0 +1,492 @@ +//! Foreign-key lookup for SeaORM HasMany relations. + +use std::path::Path; + +use super::lookup::candidate_file_paths; + +/// Find the FK column name from the target entity for a `HasMany` relation with `via_rel`. +/// +/// When a `HasMany` relation has `via_rel = "TargetUser"`, this function: +/// 1. Looks up the target entity file (e.g., notification.rs from schema path) +/// 2. Finds the field with matching `relation_enum = "TargetUser"` +/// 3. Extracts and returns the `from` attribute value (e.g., "`target_user_id`") +/// +/// Returns None if the target file can't be found or parsed, or if no matching relation exists. +#[allow(clippy::too_many_lines)] +pub fn find_fk_column_from_target_entity( + target_schema_path: &str, + via_rel: &str, +) -> Option { + use crate::schema_macro::seaorm::{extract_belongs_to_from_field, extract_relation_enum}; + + // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) + let manifest_dir = crate::schema_macro::file_cache::get_manifest_dir()?; + let src_dir = Path::new(&manifest_dir).join("src"); + + // Parse the schema path to get file path + // e.g., "crate :: models :: notification :: Schema" -> src/models/notification.rs + let segments: Vec<&str> = target_schema_path + .split("::") + .map(str::trim) + .filter(|s| !s.is_empty() && *s != "Schema" && *s != "Entity") + .collect(); + + let module_segments: Vec<&str> = segments + .iter() + .filter(|s| **s != "crate" && **s != "self" && **s != "super") + .copied() + .collect(); + + if module_segments.is_empty() { + return None; + } + + // Try different file path patterns + let file_paths = candidate_file_paths(&src_dir, &module_segments); + + for file_path in file_paths { + // No `exists()` preflight: `get_struct_definition` returns `None` for + // a missing/unreadable file via its mtime-validated cache, so the + // stat is redundant (and TOCTOU-prone). + let Some(model_def) = + crate::schema_macro::file_cache::get_struct_definition(&file_path, "Model") + else { + continue; + }; + let Ok(model) = crate::schema_macro::file_cache::parse_struct_cached(&model_def) else { + continue; + }; + + // Search through fields for the one with matching relation_enum + if let syn::Fields::Named(fields_named) = &model.fields { + for field in &fields_named.named { + let field_relation_enum = extract_relation_enum(&field.attrs); + if field_relation_enum.as_deref() == Some(via_rel) { + // Found the matching field, extract FK column from `from` attribute + return extract_belongs_to_from_field(&field.attrs); + } + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use crate::schema_macro::file_lookup::{ + find_struct_by_name_in_all_files, find_struct_from_path, + }; + + use super::*; + use serial_test::serial; + use tempfile::TempDir; + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_success() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let notification_model = r#" +pub struct Model { + pub id: i32, + pub message: String, + pub target_user_id: i32, + #[sea_orm(belongs_to = "super::user::Entity", from = "target_user_id", to = "id", relation_enum = "TargetUser")] + pub target_user: BelongsTo, +} +"#; + std::fs::write(models_dir.join("notification.rs"), notification_model).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::notification::Schema", "TargetUser"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert_eq!( + result, + Some("target_user_id".to_string()), + "Should find FK column 'target_user_id'" + ); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_mod_rs() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models").join("notification"); + std::fs::create_dir_all(&models_dir).unwrap(); + let notification_model = r#" +pub struct Model { + pub id: i32, + pub sender_id: i32, + #[sea_orm(belongs_to = "super::super::user::Entity", from = "sender_id", to = "id", relation_enum = "Sender")] + pub sender: BelongsTo, +} +"#; + std::fs::write(models_dir.join("mod.rs"), notification_model).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::notification::Schema", "Sender"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert_eq!( + result, + Some("sender_id".to_string()), + "Should find FK column from mod.rs" + ); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_empty_module_segments() { + let temp_dir = TempDir::new().unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_fk_column_from_target_entity("crate::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Empty module segments should return None"); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_file_not_found() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + std::fs::create_dir_all(&src_dir).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::nonexistent::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Non-existent file should return None"); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_unparseable_file() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write(models_dir.join("broken.rs"), "this is not valid rust {{{{").unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::broken::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Unparseable file should return None"); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_no_model_struct() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let content = r" +pub struct SomethingElse { + pub id: i32, +} +pub enum Status { Active, Inactive } +"; + std::fs::write(models_dir.join("nomodel.rs"), content).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::nomodel::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!( + result.is_none(), + "File without Model struct should return None" + ); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_no_matching_relation_enum() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model = r#" +pub struct Model { + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id", relation_enum = "Author")] + pub user: BelongsTo, +} +"#; + std::fs::write(models_dir.join("comment.rs"), model).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::comment::Schema", "TargetUser"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!( + result.is_none(), + "Non-matching relation_enum should return None" + ); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_tuple_struct() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model = "pub struct Model(i32, String);"; + std::fs::write(models_dir.join("tuple.rs"), model).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::tuple::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Tuple struct Model should return None"); + } + #[test] + #[serial] + fn test_find_fk_column_from_target_entity_field_no_from_attr() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model = r#" +pub struct Model { + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to = "super::user::Entity", to = "id", relation_enum = "TargetUser")] + pub user: BelongsTo, +} +"#; + std::fs::write(models_dir.join("nofrom.rs"), model).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::nofrom::Schema", "TargetUser"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!( + result.is_none(), + "Field without 'from' attribute should return None" + ); + } + #[test] + #[serial] + fn test_find_struct_candidate_unparseable_file() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write( + src_dir.join("user.rs"), + "pub struct Model {{{{ broken syntax", + ) + .unwrap(); + std::fs::write(src_dir.join("valid.rs"), "pub struct Model { pub id: i32 }").unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!( + result.is_some(), + "Should find Model in valid.rs after skipping unparseable candidate user.rs" + ); + } + #[test] + #[serial] + fn test_find_struct_exact_filename_disambiguation() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write(src_dir.join("user.rs"), "pub struct Model { pub id: i32 }").unwrap(); + std::fs::write( + src_dir.join("user_extended.rs"), + "pub struct Model { pub name: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!(result.is_some(), "Should resolve via exact filename match"); + let (metadata, _) = result.unwrap(); + assert!( + metadata.definition.contains("id"), + "Should return user.rs Model (with id field)" + ); + } + #[test] + #[serial] + fn test_find_struct_no_match_in_candidates_falls_to_rest() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write( + src_dir.join("user.rs"), + "pub struct Other { pub x: i32 } // Model ref", + ) + .unwrap(); + std::fs::write(src_dir.join("data.rs"), "pub struct Model { pub id: i32 }").unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!( + result.is_some(), + "Should find Model in data.rs after candidates had no match" + ); + } + #[test] + #[serial] + fn test_find_struct_full_scan_unparseable_file() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write( + src_dir.join("user.rs"), + "pub struct Other { pub x: i32 } // Model", + ) + .unwrap(); + std::fs::write(src_dir.join("broken.rs"), "Model unparseable {{{{{").unwrap(); + std::fs::write(src_dir.join("valid.rs"), "pub struct Model { pub id: i32 }").unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!( + result.is_some(), + "Should find Model in valid.rs after skipping unparseable broken.rs in rest" + ); + } + #[test] + #[serial] + fn test_find_struct_from_path_qualified_module_path() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("user.rs"), + "pub struct Model { pub id: i32, pub name: String }", + ) + .unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); + let result = find_struct_from_path(&ty, None); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!( + result.is_some(), + "Should find Model struct via qualified path" + ); + let (metadata, module_path) = result.unwrap(); + assert!( + metadata.definition.contains("Model"), + "Definition should contain Model" + ); + assert_eq!( + module_path, + vec!["crate", "models", "user"], + "Module path should be inferred from type path" + ); + } + #[test] + #[serial] + fn test_find_struct_from_path_mod_rs_variant() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models").join("user"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("mod.rs"), + "pub struct Model { pub id: i32, pub email: String }", + ) + .unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); + let result = find_struct_from_path(&ty, None); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_some(), "Should find Model struct via mod.rs path"); + let (metadata, _) = result.unwrap(); + assert!( + metadata.definition.contains("email"), + "Should find the correct Model with email field" + ); + } + #[test] + #[serial] + fn test_find_fk_column_parse_struct_cached_failure() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let model_file = models_dir.join("item.rs"); + std::fs::write(&model_file, "pub struct Model { pub id: i32 }").unwrap(); + crate::schema_macro::file_cache::inject_struct_definition_for_test( + &model_file, + "Model", + "not valid rust {{ struct }}", + ); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = + find_fk_column_from_target_entity("crate::models::item::Schema", "SomeRelation"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!( + result.is_none(), + "Should return None when struct definition fails to parse" + ); + } +} diff --git a/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs b/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs new file mode 100644 index 00000000..3ff5995c --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs @@ -0,0 +1,520 @@ +//! Struct lookup/search helpers. + +use std::path::{Path, PathBuf}; + +use syn::Type; + +use crate::metadata::StructMetadata; + +/// Why a source struct lookup failed. +#[derive(Debug, Clone)] +pub enum LookupError { + /// The macro could not derive a usable path from the supplied type. + InvalidTypePath, + /// `CARGO_MANIFEST_DIR` was unavailable. + MissingManifestDir, + /// No matching struct definition was found. + NotFound { + struct_name: String, + searched: Vec, + }, + /// Multiple files define the requested struct and no hint disambiguated it. + Ambiguous { + struct_name: String, + candidates: Vec, + }, +} + +impl LookupError { + /// Convert a lookup failure into a user-facing macro diagnostic. + pub fn to_syn_error(&self, span: &impl quote::ToTokens) -> syn::Error { + match self { + Self::InvalidTypePath => syn::Error::new_spanned( + span, + "schema_type! source must be a type path like `Model` or `crate::models::user::Model`", + ), + Self::MissingManifestDir => syn::Error::new_spanned( + span, + "schema_type! source type not found: CARGO_MANIFEST_DIR is not set", + ), + Self::NotFound { + struct_name, + searched, + } => syn::Error::new_spanned( + span, + format!( + "schema_type! struct `{struct_name}` not found. Searched: {}", + render_paths(searched) + ), + ), + Self::Ambiguous { + struct_name, + candidates, + } => syn::Error::new_spanned( + span, + format!( + "schema_type! found multiple structs named `{struct_name}`. Add a fully-qualified path or a `name = \"...\"` hint. Candidates: {}", + render_paths(candidates) + ), + ), + } + } +} + +fn render_paths(paths: &[PathBuf]) -> String { + if paths.is_empty() { + "".to_string() + } else { + paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + } +} + +/// Build candidate file paths from module segments. +/// +/// Given a source directory and module segments (e.g., `["models", "memo"]`), +/// returns both `{src_dir}/models/memo.rs` and `{src_dir}/models/memo/mod.rs`. +#[inline] +pub(super) fn candidate_file_paths(src_dir: &Path, module_segments: &[&str]) -> [PathBuf; 2] { + let joined = module_segments.join("/"); + [ + src_dir.join(format!("{joined}.rs")), + src_dir.join(format!("{joined}/mod.rs")), + ] +} + +/// Try to find a struct definition from a module path by reading source files. +/// +/// This allows `schema_type`! to work with structs defined in other files, like: +/// ```ignore +/// // In src/routes/memos.rs +/// schema_type!(CreateMemoRequest from models::memo::Model, pick = ["title", "content"]); +/// ``` +/// +/// The function will: +/// 1. Parse the path (e.g., `models::memo::Model` or `crate::models::memo::Model`) +/// 2. Convert to file path (e.g., `src/models/memo.rs`) +/// 3. Read and parse the file to find the struct definition +/// +/// For simple names (e.g., just `Model` without module path), it will scan all `.rs` +/// files in `src/` to find the struct. This supports same-file usage like: +/// ```ignore +/// pub struct Model { ... } +/// vespera::schema_type!(Schema from Model, name = "UserSchema"); +/// ``` +/// +/// The `schema_name_hint` is used to disambiguate when multiple structs with the same +/// name exist. For example, with `name = "UserSchema"`, it will prefer `user.rs`. +/// +/// Returns `(StructMetadata, Vec)` where the Vec is the module path. +/// For qualified paths, this is extracted from the type itself. +/// For simple names, it's inferred from the file location. +#[cfg(test)] +pub fn find_struct_from_path( + ty: &Type, + schema_name_hint: Option<&str>, +) -> Option<(StructMetadata, Vec)> { + find_struct_from_path_detailed(ty, schema_name_hint).ok() +} + +/// Detailed variant of [`find_struct_from_path`] that preserves failure reasons. +pub fn find_struct_from_path_detailed( + ty: &Type, + schema_name_hint: Option<&str>, +) -> Result<(StructMetadata, Vec), LookupError> { + // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) + let manifest_dir = crate::schema_macro::file_cache::get_manifest_dir() + .ok_or(LookupError::MissingManifestDir)?; + let src_dir = Path::new(&manifest_dir).join("src"); + + // Extract path segments from the type + let Type::Path(type_path) = ty else { + return Err(LookupError::InvalidTypePath); + }; + + let segments: Vec = type_path + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect(); + + if segments.is_empty() { + return Err(LookupError::InvalidTypePath); + } + + // The last segment is the struct name + let struct_name = segments.last().ok_or(LookupError::InvalidTypePath)?.clone(); + + // Build possible file paths from the module path + // e.g., models::memo::Model -> src/models/memo.rs or src/models/memo/mod.rs + // e.g., crate::models::memo::Model -> src/models/memo.rs + let module_segments: Vec<&str> = segments[..segments.len() - 1] + .iter() + .filter(|s| *s != "crate" && *s != "self" && *s != "super") + .map(std::string::String::as_str) + .collect(); + + // If no module path (simple name like `Model`), scan all files with schema_name hint + if module_segments.is_empty() { + return find_struct_by_name_in_all_files_detailed(&src_dir, &struct_name, schema_name_hint); + } + + // For qualified paths, the module path is extracted from the type itself + // e.g., crate::models::memo::Model -> ["crate", "models", "memo"] + let type_module_path: Vec = segments[..segments.len() - 1].to_vec(); + + // Try different file path patterns + let file_paths = candidate_file_paths(&src_dir, &module_segments); + + for file_path in file_paths { + // No `exists()` preflight: `get_struct_definition` reads through the + // mtime-validated cache and returns `None` for a missing/unreadable + // file, so the extra stat (and its TOCTOU window) is pure overhead. + if let Some(definition) = + crate::schema_macro::file_cache::get_struct_definition(&file_path, &struct_name) + { + return Ok(( + StructMetadata::new_model(struct_name, definition), + type_module_path, + )); + } + } + + Err(LookupError::NotFound { + struct_name, + searched: candidate_file_paths(&src_dir, &module_segments) + .into_iter() + .collect(), + }) +} + +/// Find a struct by name by scanning all `.rs` files in the src directory. +/// +/// This is used as a fallback when the type path doesn't include module information +/// (e.g., just `Model` instead of `crate::models::user::Model`). +/// +/// Resolution strategy: +/// 1. If exactly one struct with the name exists -> use it +/// 2. If multiple exist and `schema_name_hint` is provided (e.g., "UserSchema"): +/// -> Prefer file whose name contains the hint prefix (e.g., "user.rs" for "`UserSchema`") +/// 3. Otherwise -> return None (ambiguous) +/// +/// The `schema_name_hint` is the custom schema name (e.g., "`UserSchema`", "`MemoSchema`") +/// which often contains a hint about the module name. +/// +/// Returns `(StructMetadata, Vec)` where the Vec is the inferred module path +/// from the file location (e.g., `["crate", "models", "user"]`). +#[allow(clippy::too_many_lines)] +#[cfg(test)] +pub fn find_struct_by_name_in_all_files( + src_dir: &Path, + struct_name: &str, + schema_name_hint: Option<&str>, +) -> Option<(StructMetadata, Vec)> { + find_struct_by_name_in_all_files_detailed(src_dir, struct_name, schema_name_hint).ok() +} + +/// Detailed variant of [`find_struct_by_name_in_all_files`]. +#[allow(clippy::too_many_lines)] +pub fn find_struct_by_name_in_all_files_detailed( + src_dir: &Path, + struct_name: &str, + schema_name_hint: Option<&str>, +) -> Result<(StructMetadata, Vec), LookupError> { + // Use cached struct-candidate index: files already filtered by text + // search. `Arc<[PathBuf]>` — iterate by reference; only matched + // paths are cloned. + let all_files = crate::schema_macro::file_cache::get_struct_candidates(src_dir, struct_name); + let mut rs_files: Vec<&std::path::PathBuf> = all_files.iter().collect(); + + // Pre-compute hint prefix once (used in fast path and fallback disambiguation) + let prefix_normalized = schema_name_hint.map(derive_hint_prefix); + + // FAST PATH: If schema_name_hint is provided, try matching files first. + // This avoids parsing ALL files for the common same-file pattern: + // schema_type!(Schema from Model, name = "UserSchema") in user.rs + if let Some(prefix_normalized) = &prefix_normalized { + // Partition files: candidate files (filename matches hint prefix) vs rest + let (candidates, rest): (Vec<_>, Vec<_>) = rs_files.into_iter().partition(|path| { + path.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|name| { + let norm = normalize_name(name); + norm == *prefix_normalized || norm.contains(prefix_normalized.as_str()) + }) + }); + + // Parse only candidate files first + let mut found_in_candidates: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); + for file_path in &candidates { + if let Some(definition) = + crate::schema_macro::file_cache::get_struct_definition(file_path, struct_name) + { + found_in_candidates.push(( + (*file_path).clone(), + StructMetadata::new_model(struct_name.to_string(), definition), + )); + } + } + + // If exactly one match in candidates, return immediately (fast path hit!) + if found_in_candidates.len() == 1 { + let (path, metadata) = found_in_candidates.remove(0); + let module_path = file_path_to_module_path(&path, src_dir); + return Ok((metadata, module_path)); + } + + // If candidates found multiple, try disambiguation by exact filename match + if found_in_candidates.len() > 1 { + let exact_match: Vec<_> = found_in_candidates + .iter() + .filter(|(path, _)| { + path.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|name| normalize_name(name) == *prefix_normalized) + }) + .collect(); + + if exact_match.len() == 1 { + let (path, metadata) = exact_match[0]; + let module_path = file_path_to_module_path(path, src_dir); + return Ok((metadata.clone(), module_path)); + } + + // Still ambiguous among candidates + return Err(LookupError::Ambiguous { + struct_name: struct_name.to_string(), + candidates: found_in_candidates + .into_iter() + .map(|(path, _)| path) + .collect(), + }); + } + + // No match in candidates — fall through to scan remaining files + rs_files = rest; + } + + // FULL SCAN: Parse all remaining files (or all files if no hint) + let mut found_structs: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); + + for file_path in rs_files { + if let Some(definition) = + crate::schema_macro::file_cache::get_struct_definition(file_path, struct_name) + { + found_structs.push(( + file_path.clone(), + StructMetadata::new_model(struct_name.to_string(), definition), + )); + } + } + + match found_structs.len() { + 1 => { + let (path, metadata) = found_structs.remove(0); + let module_path = file_path_to_module_path(&path, src_dir); + Ok((metadata, module_path)) + } + 0 => Err(LookupError::NotFound { + struct_name: struct_name.to_string(), + searched: all_files.iter().cloned().collect(), + }), + _ => Err(LookupError::Ambiguous { + struct_name: struct_name.to_string(), + candidates: found_structs.into_iter().map(|(path, _)| path).collect(), + }), + } +} + +/// Derive a normalized prefix from a schema name hint for file matching. +/// +/// Strips common suffixes ("Schema", "Response", "Request") and normalizes +/// by removing underscores and lowercasing. +/// +/// # Examples +/// - "UserSchema" → "user" +/// - "MemoResponse" → "memo" +/// - "AdminUserSchema" → "adminuser" +fn derive_hint_prefix(hint: &str) -> String { + let hint_lower = hint.to_lowercase(); + let prefix = hint_lower + .strip_suffix("schema") + .or_else(|| hint_lower.strip_suffix("response")) + .or_else(|| hint_lower.strip_suffix("request")) + .unwrap_or(&hint_lower); + normalize_name(prefix) +} + +/// Normalize a name by lowercasing and removing underscores in a single pass. +/// Replaces the two-allocation `s.to_lowercase().replace('_', "")` pattern. +#[inline] +fn normalize_name(s: &str) -> String { + s.chars() + .filter(|&c| c != '_') + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +/// Recursively collect all `.rs` files in a directory. +pub fn collect_rs_files_recursive(dir: &Path, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + // `entry.file_type()` reads the kind from the directory-entry data the + // OS already returned for this `read_dir` walk — no extra `metadata` + // stat per entry, unlike `path.is_dir()`. Mirrors the established + // `file_utils::collect_with_mtimes_into` pattern (symlinks, which are + // neither file nor dir here, are skipped — never present in a `src/` + // tree this indexes). + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + if file_type.is_dir() { + collect_rs_files_recursive(&path, files); + } else if file_type.is_file() && path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } +} + +/// Derive module path from a file path relative to src directory. +/// +/// Examples: +/// - `src/models/user.rs` -> `["crate", "models", "user"]` +/// - `src/models/user/mod.rs` -> `["crate", "models", "user"]` +/// - `src/lib.rs` -> `["crate"]` +pub fn file_path_to_module_path(file_path: &Path, src_dir: &Path) -> Vec { + let Ok(relative) = file_path.strip_prefix(src_dir) else { + return vec!["crate".to_string()]; + }; + + let mut segments = vec!["crate".to_string()]; + + for component in relative.components() { + if let std::path::Component::Normal(os_str) = component + && let Some(s) = os_str.to_str() + { + // Handle .rs extension + if let Some(name) = s.strip_suffix(".rs") { + // Skip mod.rs and lib.rs - they don't add a segment + if name != "mod" && name != "lib" { + segments.push(name.to_string()); + } + } else { + // Directory name + segments.push(s.to_string()); + } + } + } + + segments +} + +/// Find struct definition from a schema path string (e.g., "`crate::models::user::Schema`"). +/// +/// Similar to `find_struct_from_path` but takes a string path instead of `syn::Type`. +pub fn find_struct_from_schema_path(path_str: &str) -> Option { + // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) + let manifest_dir = crate::schema_macro::file_cache::get_manifest_dir()?; + let src_dir = Path::new(&manifest_dir).join("src"); + + // Parse the path string into segments + let segments: Vec<&str> = path_str + .split("::") + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + + if segments.is_empty() { + return None; + } + + // The last segment is the struct name + let struct_name = segments.last()?.to_string(); + + // Build possible file paths from the module path + // e.g., crate::models::user::Schema -> src/models/user.rs + let module_segments: Vec<&str> = segments[..segments.len() - 1] + .iter() + .filter(|s| **s != "crate" && **s != "self" && **s != "super") + .copied() + .collect(); + + if module_segments.is_empty() { + return None; + } + + // Try different file path patterns + let file_paths = candidate_file_paths(&src_dir, &module_segments); + + for file_path in file_paths { + // No `exists()` preflight: the mtime-validated cache read returns + // `None` for a missing/unreadable file, so the stat is redundant + // (and TOCTOU-prone). + if let Some(definition) = + crate::schema_macro::file_cache::get_struct_definition(&file_path, &struct_name) + { + return Some(StructMetadata::new_model(struct_name, definition)); + } + } + + None +} + +/// Find the Model definition from a Schema path. +/// Converts "`crate::models::user::Schema`" -> finds Model in src/models/user.rs +#[allow(clippy::too_many_lines)] +pub fn find_model_from_schema_path(schema_path_str: &str) -> Option { + // Get CARGO_MANIFEST_DIR to locate src folder (cached to avoid repeated syscalls) + let manifest_dir = crate::schema_macro::file_cache::get_manifest_dir()?; + let src_dir = Path::new(&manifest_dir).join("src"); + + // Parse the path string and convert Schema path to module path + // e.g., "crate :: models :: user :: Schema" -> ["crate", "models", "user"] + let segments: Vec<&str> = schema_path_str + .split("::") + .map(str::trim) + .filter(|s| !s.is_empty() && *s != "Schema") + .collect(); + + if segments.is_empty() { + return None; + } + + // Build possible file paths from the module path + let module_segments: Vec<&str> = segments + .iter() + .filter(|s| **s != "crate" && **s != "self" && **s != "super") + .copied() + .collect(); + + if module_segments.is_empty() { + return None; + } + + // Try different file path patterns + let file_paths = candidate_file_paths(&src_dir, &module_segments); + + for file_path in file_paths { + // No `exists()` preflight: the mtime-validated cache read returns + // `None` for a missing/unreadable file, so the stat is redundant + // (and TOCTOU-prone). + if let Some(definition) = + crate::schema_macro::file_cache::get_struct_definition(&file_path, "Model") + { + return Some(StructMetadata::new_model("Model".to_string(), definition)); + } + } + + None +} + +#[cfg(test)] +mod tests; diff --git a/crates/vespera_macro/src/schema_macro/file_lookup/lookup/tests.rs b/crates/vespera_macro/src/schema_macro/file_lookup/lookup/tests.rs new file mode 100644 index 00000000..36dcdbe5 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/file_lookup/lookup/tests.rs @@ -0,0 +1,502 @@ +use super::*; +use serial_test::serial; +use std::path::Path; +use tempfile::TempDir; +#[test] +fn test_file_path_to_module_path_simple() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + let file_path = src_dir.join("models").join("user.rs"); + let result = file_path_to_module_path(&file_path, src_dir); + assert_eq!(result, vec!["crate", "models", "user"]); +} +#[test] +fn test_file_path_to_module_path_mod_rs() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + let file_path = src_dir.join("models").join("mod.rs"); + let result = file_path_to_module_path(&file_path, src_dir); + assert_eq!(result, vec!["crate", "models"]); +} +#[test] +fn test_file_path_to_module_path_lib_rs() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + let file_path = src_dir.join("lib.rs"); + let result = file_path_to_module_path(&file_path, src_dir); + assert_eq!(result, vec!["crate"]); +} +#[test] +fn test_file_path_to_module_path_not_under_src() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let file_path = temp_dir.path().join("other").join("file.rs"); + let result = file_path_to_module_path(&file_path, &src_dir); + assert_eq!(result, vec!["crate"]); +} +#[test] +fn test_collect_rs_files_recursive_empty_dir() { + let temp_dir = TempDir::new().unwrap(); + let mut files = Vec::new(); + collect_rs_files_recursive(temp_dir.path(), &mut files); + assert!(files.is_empty()); +} +#[test] +fn test_collect_rs_files_recursive_nonexistent_dir() { + let mut files = Vec::new(); + collect_rs_files_recursive(Path::new("/nonexistent/path"), &mut files); + assert!(files.is_empty()); +} +#[test] +fn test_collect_rs_files_recursive_with_files() { + let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("main.rs"), "fn main() {}").unwrap(); + std::fs::create_dir(temp_dir.path().join("models")).unwrap(); + std::fs::write( + temp_dir.path().join("models").join("user.rs"), + "struct User;", + ) + .unwrap(); + std::fs::write(temp_dir.path().join("other.txt"), "not a rust file").unwrap(); + let mut files = Vec::new(); + collect_rs_files_recursive(temp_dir.path(), &mut files); + assert_eq!(files.len(), 2); + assert!(files.iter().all(|f| f.extension().unwrap() == "rs")); +} +#[test] +#[serial] +fn test_find_struct_from_path_non_path_type() { + use syn::Type; + let ty: Type = syn::parse_str("&str").unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_path(&ty, None); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Non-path type should return None"); +} +#[test] +#[serial] +fn test_find_struct_from_path_empty_segments() { + use syn::{Path, TypePath}; + let empty_path = Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }; + let ty = Type::Path(TypePath { + qself: None, + path: empty_path, + }); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_path(&ty, None); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Empty segments should return None"); +} +#[test] +#[serial] +fn test_find_struct_from_path_file_with_non_matching_items() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let content = r" +pub enum SomeEnum { A, B } +pub fn some_function() {} +pub const SOME_CONST: i32 = 42; +pub trait SomeTrait {} +pub struct NotTarget { pub x: i32 } +pub struct Target { pub id: i32 } +"; + std::fs::write(models_dir.join("mixed.rs"), content).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let ty: Type = syn::parse_str("crate::models::mixed::Target").unwrap(); + let result = find_struct_from_path(&ty, None); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_some(), "Should find Target struct"); + let (metadata, _) = result.unwrap(); + assert!(metadata.definition.contains("Target")); +} +#[test] +#[serial] +fn test_find_struct_by_name_unreadable_file() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write( + src_dir.join("valid.rs"), + "pub struct Target { pub id: i32 }", + ) + .unwrap(); + let broken = src_dir.join("broken.rs"); + let nonexistent = src_dir.join("nonexistent"); + #[cfg(unix)] + let _ = std::os::unix::fs::symlink(&nonexistent, &broken); + #[cfg(windows)] + let _ = std::os::windows::fs::symlink_file(&nonexistent, &broken); + let result = find_struct_by_name_in_all_files(src_dir, "Target", None); + assert!( + result.is_some(), + "Should find Target, skipping broken symlink" + ); +} +#[test] +#[serial] +fn test_find_struct_by_name_unparseable_file() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::write(src_dir.join("broken.rs"), "this is not valid rust {{{{").unwrap(); + std::fs::write( + src_dir.join("valid.rs"), + "pub struct Target { pub id: i32 }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Target", None); + assert!( + result.is_some(), + "Should find Target in valid file, skipping broken" + ); +} +#[test] +#[serial] +fn test_find_struct_disambiguation_with_hint() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("user.rs"), + "pub struct Model { pub id: i32, pub name: String }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("memo.rs"), + "pub struct Model { pub id: i32, pub title: String }", + ) + .unwrap(); + let result_no_hint = find_struct_by_name_in_all_files(src_dir, "Model", None); + assert!( + result_no_hint.is_none(), + "Without hint, multiple Models should be ambiguous" + ); + let result_with_hint = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!( + result_with_hint.is_some(), + "With UserSchema hint, should find user.rs" + ); + let (metadata, module_path) = result_with_hint.unwrap(); + assert!( + metadata.definition.contains("name"), + "Should be user Model with name field" + ); + assert!( + module_path.contains(&"user".to_string()), + "Module path should contain 'user'" + ); + let result_memo = find_struct_by_name_in_all_files(src_dir, "Model", Some("MemoSchema")); + assert!( + result_memo.is_some(), + "With MemoSchema hint, should find memo.rs" + ); + let (metadata_memo, _) = result_memo.unwrap(); + assert!( + metadata_memo.definition.contains("title"), + "Should be memo Model with title field" + ); +} +#[test] +#[serial] +fn test_find_struct_disambiguation_with_response_suffix() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("user.rs"), + "pub struct Data { pub id: i32 }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("item.rs"), + "pub struct Data { pub name: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Data", Some("UserResponse")); + assert!( + result.is_some(), + "With UserResponse hint, should find user.rs" + ); +} +#[test] +#[serial] +fn test_find_struct_disambiguation_with_request_suffix() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("user.rs"), + "pub struct Input { pub id: i32 }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("item.rs"), + "pub struct Input { pub name: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Input", Some("UserRequest")); + assert!( + result.is_some(), + "With UserRequest hint, should find user.rs" + ); +} +#[test] +#[serial] +fn test_find_struct_disambiguation_still_ambiguous() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("user_admin.rs"), + "pub struct Model { pub id: i32 }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("user_regular.rs"), + "pub struct Model { pub name: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("UserSchema")); + assert!( + result.is_none(), + "Multiple files matching hint should still be ambiguous" + ); +} +#[test] +#[serial] +fn test_find_struct_disambiguation_snake_case_filename() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("admin_user.rs"), + "pub struct Model { pub id: i32, pub role: String }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("regular_user.rs"), + "pub struct Model { pub id: i32, pub name: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("AdminUserSchema")); + assert!( + result.is_some(), + "AdminUserSchema hint should match admin_user.rs" + ); + let (metadata, module_path) = result.unwrap(); + assert!( + metadata.definition.contains("role"), + "Should be admin_user Model with role field" + ); + assert!( + module_path.contains(&"admin_user".to_string()), + "Module path should contain 'admin_user'" + ); + let result_regular = + find_struct_by_name_in_all_files(src_dir, "Model", Some("RegularUserSchema")); + assert!( + result_regular.is_some(), + "RegularUserSchema hint should match regular_user.rs" + ); + let (metadata_regular, _) = result_regular.unwrap(); + assert!( + metadata_regular.definition.contains("name"), + "Should be regular_user Model with name field" + ); +} +#[test] +#[serial] +fn test_find_struct_from_schema_path_empty_string() { + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_schema_path(""); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Empty path should return None"); +} +#[test] +#[serial] +fn test_find_struct_from_schema_path_no_module() { + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_schema_path("crate::Schema"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Path with no module should return None"); +} +#[test] +#[serial] +fn test_find_struct_from_schema_path_with_non_struct_items() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let content = r" +pub enum NotStruct { A, B } +pub fn not_struct() {} +pub struct Target { pub id: i32 } +pub const NOT_STRUCT: i32 = 1; +"; + std::fs::write(models_dir.join("item.rs"), content).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_schema_path("crate::models::item::Target"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_some(), "Should find Target struct"); + assert!(result.unwrap().definition.contains("Target")); +} + +#[test] +#[serial] +fn test_find_struct_from_schema_path_trims_segments() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("item.rs"), + "pub struct Target { pub id: i32 }", + ) + .unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_struct_from_schema_path("crate :: models :: item :: Target"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_some(), "Whitespace around :: should be ignored"); +} + +#[test] +#[serial] +fn test_find_model_from_schema_path_empty_after_filter() { + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_model_from_schema_path("Schema"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "Empty segments should return None"); +} +#[test] +#[serial] +fn test_find_model_from_schema_path_no_module() { + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + let temp_dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_model_from_schema_path("crate::Schema"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_none(), "No module segments should return None"); +} +#[test] +#[serial] +fn test_find_model_from_schema_path_success() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + let content = "pub struct Model { pub id: i32, pub name: String }"; + std::fs::write(models_dir.join("user.rs"), content).unwrap(); + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + let result = find_model_from_schema_path("crate::models::user::Schema"); + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + assert!(result.is_some(), "Should find Model"); + assert!(result.unwrap().definition.contains("Model")); +} +#[test] +#[serial] +fn test_find_struct_disambiguation_fallback_contains() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path(); + std::fs::create_dir(src_dir.join("models")).unwrap(); + std::fs::write( + src_dir.join("models").join("special_item.rs"), + "pub struct Model { pub special_field: i32 }", + ) + .unwrap(); + std::fs::write( + src_dir.join("models").join("regular.rs"), + "pub struct Model { pub regular_field: String }", + ) + .unwrap(); + let result = find_struct_by_name_in_all_files(src_dir, "Model", Some("SpecialSchema")); + assert!( + result.is_some(), + "SpecialSchema hint should match special_item.rs via contains fallback" + ); + let (metadata, module_path) = result.unwrap(); + assert!( + metadata.definition.contains("special_field"), + "Should be special_item Model with special_field" + ); + assert!( + module_path.contains(&"special_item".to_string()), + "Module path should contain 'special_item'" + ); +} diff --git a/crates/vespera_macro/src/schema_macro/from_model.rs b/crates/vespera_macro/src/schema_macro/from_model.rs index 96c104f7..fbe8d396 100644 --- a/crates/vespera_macro/src/schema_macro/from_model.rs +++ b/crates/vespera_macro/src/schema_macro/from_model.rs @@ -2,24 +2,15 @@ //! //! Generates async `from_model` implementations for `SeaORM` models with relations. -use std::collections::HashMap; - -use super::type_utils::normalize_token_str; use proc_macro2::TokenStream; use quote::quote; -use syn::Type; -use super::{ - circular::{generate_inline_struct_construction, generate_inline_type_construction}, - file_cache::{get_circular_analysis, get_fk_column, get_struct_from_schema_path}, - seaorm::RelationFieldInfo, - type_utils::snake_to_pascal_case, -}; -use crate::metadata::StructMetadata; +mod generate; + +pub use generate::generate_from_model_with_relations; /// Build Entity path from Schema path. /// e.g., `crate::models::user::Schema` -> `crate::models::user::Entity` -#[allow(clippy::too_many_lines, clippy::option_if_let_else)] pub fn build_entity_path_from_schema_path( schema_path: &TokenStream, _source_module_path: &[String], @@ -38,2474 +29,32 @@ pub fn build_entity_path_from_schema_path( quote! { #(#path_idents)::* } } -/// Generate `from_model` impl for `SeaORM` Model WITH relations (async version). -/// -/// When circular references are detected, generates inline struct construction -/// that excludes circular fields (sets them to default values). -/// -/// ```ignore -/// impl NewType { -/// pub async fn from_model( -/// model: SourceType, -/// db: &sea_orm::DatabaseConnection, -/// ) -> Result { -/// // Load related entities -/// let user = model.find_related(user::Entity).one(db).await?; -/// let tags = model.find_related(tag::Entity).all(db).await?; -/// -/// Ok(Self { -/// id: model.id, -/// // Inline construction with circular field defaulted: -/// user: user.map(|r| Box::new(user::Schema { id: r.id, memos: vec![], ... })), -/// tags: tags.into_iter().map(|r| tag::Schema { ... }).collect(), -/// }) -/// } -/// } -/// ``` -#[allow(clippy::too_many_lines, clippy::option_if_let_else)] -pub fn generate_from_model_with_relations( - new_type_name: &syn::Ident, - source_type: &Type, - field_mappings: &[(syn::Ident, syn::Ident, bool, bool)], - relation_fields: &[RelationFieldInfo], - source_module_path: &[String], - _schema_storage: &HashMap, -) -> TokenStream { - // Build relation loading statements - let relation_loads: Vec = relation_fields - .iter() - .map(|rel| { - let field_name = &rel.field_name; - let entity_path = build_entity_path_from_schema_path(&rel.schema_path, source_module_path); - - match rel.relation_type.as_str() { - "HasOne" | "BelongsTo" => { - // When relation_enum is specified, use the specific Relation variant - // This handles cases where multiple relations point to the same Entity type - if let Some(ref relation_enum_name) = rel.relation_enum { - let relation_variant = syn::Ident::new(relation_enum_name, proc_macro2::Span::call_site()); - - if rel.is_optional { - // Optional FK: load only if FK value exists - if let Some(ref fk_col) = rel.fk_column { - let fk_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site()); - quote! { - let #field_name = match &model.#fk_ident { - Some(fk_value) => #entity_path::find_by_id(fk_value.clone()).one(db).await?, - None => None, - }; - } - } else { - // Fallback: use find_related with Relation enum - quote! { - let #field_name = Entity::find_related(Relation::#relation_variant) - .filter(::PrimaryKey::eq(&model)) - .one(db) - .await?; - } - } - } else { - // Required FK: directly query by FK value - if let Some(ref fk_col) = rel.fk_column { - let fk_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site()); - quote! { - let #field_name = #entity_path::find_by_id(model.#fk_ident.clone()).one(db).await?; - } - } else { - // Fallback: use find_related with Relation enum - quote! { - let #field_name = Entity::find_related(Relation::#relation_variant) - .filter(::PrimaryKey::eq(&model)) - .one(db) - .await?; - } - } - } - } else { - // Standard case: single relation to target entity, use find_related - quote! { - let #field_name = model.find_related(#entity_path).one(db).await?; - } - } - } - "HasMany" => { - // Try via_rel first, fall back to relation_enum as FK source - let fk_rel_source = rel.via_rel.as_ref().or(rel.relation_enum.as_ref()); - if let Some(via_rel_value) = fk_rel_source { - let schema_path_str = normalize_token_str(&rel.schema_path); - if let Some(fk_col_name) = get_fk_column(&schema_path_str, via_rel_value) { - let fk_col_pascal = snake_to_pascal_case(&fk_col_name); - let fk_col_ident = syn::Ident::new(&fk_col_pascal, proc_macro2::Span::call_site()); - - let entity_path_str = normalize_token_str(&entity_path); - let column_path_str = entity_path_str.replace(":: Entity", ":: Column"); - let column_path_idents: Vec = column_path_str - .split("::") - .filter_map(|s| { - let trimmed = s.trim(); - if trimmed.is_empty() { None } else { Some(syn::Ident::new(trimmed, proc_macro2::Span::call_site())) } - }) - .collect(); - - quote! { - let #field_name = #(#column_path_idents)::*::#fk_col_ident - .into_column() - .eq(model.id.clone()) - .into_condition(); - let #field_name = #entity_path::find() - .filter(#field_name) - .all(db) - .await?; - } - } else { - quote! { - // WARNING: Could not find FK column for relation, using empty vec - let #field_name: Vec<_> = vec![]; - } - } - } else { - // Standard HasMany - use find_related - quote! { - let #field_name = model.find_related(#entity_path).all(db).await?; - } - } - } - _ => quote! {}, - } - }) - .collect(); - - // Check if we need a parent stub for HasMany relations with required circular back-refs - // This is needed when: UserSchema.memos has MemoSchema which has required user: Box - // BUT: If the relation uses an inline type (which excludes circular fields), we don't need a parent stub - let needs_parent_stub = relation_fields.iter().any(|rel| { - if rel.relation_type != "HasMany" { - return false; - } - // If using inline type, circular fields are excluded, so no parent stub needed - if rel.inline_type_info.is_some() { - return false; - } - let schema_path_str = normalize_token_str(&rel.schema_path); - let model_path_str = schema_path_str.replace("::Schema", "::Model"); - let related_model = get_struct_from_schema_path(&model_path_str); - - if let Some(ref model) = related_model { - let analysis = get_circular_analysis(source_module_path, &model.definition); - // Check if any circular field is a required relation - analysis.circular_fields.iter().any(|cf| { - analysis - .circular_field_required - .get(cf) - .copied() - .unwrap_or(false) - }) - } else { - false - } - }); - - // Generate parent stub field assignments (non-relation fields from model) - let parent_stub_fields: Vec = if needs_parent_stub { - field_mappings - .iter() - .map(|(new_ident, source_ident, _wrapped, is_relation)| { - if *is_relation { - // For relation fields in stub, use defaults - if let Some(rel) = relation_fields - .iter() - .find(|r| &r.field_name == source_ident) - { - match rel.relation_type.as_str() { - "HasMany" => quote! { #new_ident: vec![] }, - _ if rel.is_optional => quote! { #new_ident: None }, - // Required single relations in parent stub - this shouldn't happen - // as we're creating stub to break circular ref - _ => quote! { #new_ident: None }, - } - } else { - quote! { #new_ident: Default::default() } - } - } else { - // Regular field - clone from model - quote! { #new_ident: model.#source_ident.clone() } - } - }) - .collect() - } else { - vec![] - }; - - // Pre-build relation lookup for O(1) access in field assignments loop - let relation_by_name: HashMap<&syn::Ident, &RelationFieldInfo> = relation_fields - .iter() - .map(|rel| (&rel.field_name, rel)) - .collect(); - - // Build field assignments - // For relation fields, check for circular references and use inline construction if needed - let field_assignments: Vec = field_mappings - .iter() - .map(|(new_ident, source_ident, wrapped, is_relation)| { - if *is_relation { - // Find the relation info for this field - if let Some(rel) = relation_by_name.get(source_ident) { - let schema_path = &rel.schema_path; - - // Try to find the related MODEL definition to check for circular refs - // The schema_path is like "crate::models::user::Schema", but the actual - // struct is "Model" in the same module. We need to look up the Model - // to see if it has relations pointing back to us. - let schema_path_str = normalize_token_str(schema_path); - - // Convert schema path to model path: Schema -> Model - let model_path_str = schema_path_str.replace("::Schema", "::Model"); - - // Try to find the related Model definition from file - let related_model_from_file = get_struct_from_schema_path(&model_path_str); - - // Get the definition string - let related_def_str = related_model_from_file.as_ref().map_or("", |s| s.definition.as_str()); - - // Analyze circular references, FK relations, and FK optionality in ONE pass - let analysis = get_circular_analysis(source_module_path, related_def_str); - let circular_fields = &analysis.circular_fields; - let has_circular = !circular_fields.is_empty(); - - // Check if we have inline type info - if so, use the inline type - // instead of the original schema path - if let Some((ref inline_type_name, ref included_fields)) = rel.inline_type_info { - // Use inline type construction - let inline_construct = generate_inline_type_construction(inline_type_name, included_fields, related_def_str, "r"); - - match rel.relation_type.as_str() { - "HasOne" | "BelongsTo" => { - if rel.is_optional { - quote! { - #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) - } - } else { - quote! { - #new_ident: Box::new({ - let r = #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( - format!("Required relation '{}' not found", stringify!(#source_ident)) - ))?; - #inline_construct - }) - } - } - } - "HasMany" => { - quote! { - #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() - } - } - _ => quote! { #new_ident: Default::default() }, - } - } else { - // No inline type - use original behavior - match rel.relation_type.as_str() { - "HasOne" | "BelongsTo" => { - if has_circular { - // Use inline construction to break circular ref - let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, circular_fields, "r"); - if rel.is_optional { - quote! { - #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) - } - } else { - quote! { - #new_ident: Box::new({ - let r = #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( - format!("Required relation '{}' not found", stringify!(#source_ident)) - ))?; - #inline_construct - }) - } - } - } else { - // No circular ref - use has_fk_relations from the analysis - let target_has_fk = analysis.has_fk_relations; - - if target_has_fk { - // Target schema has FK relations -> use async from_model() - if rel.is_optional { - quote! { - #new_ident: match #source_ident { - Some(r) => Some(Box::new(#schema_path::from_model(r, db).await?)), - None => None, - } - } - } else { - quote! { - #new_ident: Box::new(#schema_path::from_model( - #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( - format!("Required relation '{}' not found", stringify!(#source_ident)) - ))?, - db, - ).await?) - } - } - } else { - // Target schema has no FK relations -> use sync From::from() - if rel.is_optional { - quote! { - #new_ident: #source_ident.map(|r| Box::new(<#schema_path as From<_>>::from(r))) - } - } else { - quote! { - #new_ident: Box::new(<#schema_path as From<_>>::from( - #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( - format!("Required relation '{}' not found", stringify!(#source_ident)) - ))? - )) - } - } - } - } - } - "HasMany" => { - // HasMany is excluded by default, so this branch is only hit - // when explicitly picked. Use inline construction (no relations). - if has_circular { - // Use inline construction to break circular ref - let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, circular_fields, "r"); - quote! { - #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() - } - } else { - // No circular ref - use has_fk_relations from the analysis - let target_has_fk = analysis.has_fk_relations; - - if target_has_fk { - // Target has FK relations but HasMany doesn't load nested data anyway, - // so we use inline construction (flat fields only) - let inline_construct = generate_inline_struct_construction( - schema_path, - related_def_str, - &[], // no circular fields to exclude - "r", - ); - quote! { - #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() - } - } else { - quote! { - #new_ident: #source_ident.into_iter().map(|r| <#schema_path as From<_>>::from(r)).collect() - } - } - } - } - _ => quote! { #new_ident: Default::default() }, - } - } - } else { - quote! { #new_ident: Default::default() } - } - } else if *wrapped { - quote! { #new_ident: Some(model.#source_ident) } - } else { - quote! { #new_ident: model.#source_ident } - } - }) - .collect(); - - // Circular references are now handled automatically via inline construction - // For HasMany with required circular back-refs, we create a parent stub first - - // Generate parent stub definition if needed - let parent_stub_def = if needs_parent_stub { - quote! { - let __parent_stub__ = Self { - #(#parent_stub_fields),* - }; - } - } else { - quote! {} - }; - - quote! { - impl #new_type_name { - pub async fn from_model( - model: #source_type, - db: &sea_orm::DatabaseConnection, - ) -> Result { - use sea_orm::ModelTrait; - - #(#relation_loads)* - - #parent_stub_def - - Ok(Self { - #(#field_assignments),* - }) - } - } - } -} - #[cfg(test)] mod tests { - use serial_test::serial; + use rstest::rstest; use super::*; - #[test] - fn test_build_entity_path_from_schema_path() { - let schema_path = quote! { crate::models::user::Schema }; - let result = build_entity_path_from_schema_path(&schema_path, &[]); - let output = result.to_string(); - assert!(output.contains("crate")); - assert!(output.contains("models")); - assert!(output.contains("user")); - assert!(output.contains("Entity")); - assert!(!output.contains("Schema")); - } - - #[test] - fn test_build_entity_path_simple() { - let schema_path = quote! { user::Schema }; - let result = build_entity_path_from_schema_path(&schema_path, &[]); - let output = result.to_string(); - assert!(output.contains("user")); - assert!(output.contains("Entity")); - } - - #[test] - fn test_build_entity_path_deeply_nested() { - let schema_path = quote! { crate::api::models::entities::user::Schema }; - let result = build_entity_path_from_schema_path(&schema_path, &[]); - let output = result.to_string(); - assert!(output.contains("api")); - assert!(output.contains("models")); - assert!(output.contains("entities")); - assert!(output.contains("user")); - assert!(output.contains("Entity")); - assert!(!output.contains("Schema")); - } - - #[test] - fn test_build_entity_path_single_segment() { - let schema_path = quote! { Schema }; - let result = build_entity_path_from_schema_path(&schema_path, &[]); - let output = result.to_string(); - assert!(output.contains("Entity")); - } - - // Tests for generate_from_model_with_relations - - fn create_test_relation_info( - field_name: &str, - relation_type: &str, - schema_path: TokenStream, - is_optional: bool, - ) -> RelationFieldInfo { - RelationFieldInfo { - field_name: syn::Ident::new(field_name, proc_macro2::Span::call_site()), - relation_type: relation_type.to_string(), - schema_path, - is_optional, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - } - } - - #[test] - fn test_generate_from_model_with_required_relation() { - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("user", proc_macro2::Span::call_site()), - syn::Ident::new("user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - // Required relation (is_optional = false) - let relation_fields = vec![create_test_relation_info( - "user", - "HasOne", - quote! { user::Schema }, - false, - )]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - // Required relations should have RecordNotFound error handling - assert!(output.contains("DbErr :: RecordNotFound")); - } - - #[test] - fn test_generate_from_model_with_wrapped_fields() { - let new_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - // Field with wrapped=true means it needs Some() wrapping - let field_mappings = vec![( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - true, // wrapped - false, - )]; - let relation_fields = vec![]; - let source_module_path = vec!["crate".to_string()]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("Some (model . id)")); - } - - #[test] - fn test_generate_from_model_with_has_one_optional() { - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("user", proc_macro2::Span::call_site()), - syn::Ident::new("user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - let relation_fields = vec![create_test_relation_info( - "user", - "HasOne", - quote! { user::Schema }, - true, - )]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - assert!(output.contains("pub async fn from_model")); - // quote! produces spaced output like "sea_orm :: DatabaseConnection" - assert!(output.contains("sea_orm :: DatabaseConnection")); - assert!(output.contains("Result < Self , sea_orm :: DbErr >")); - assert!(output.contains("find_related")); - assert!(output.contains(". one (db)")); - } - - #[test] - fn test_generate_from_model_with_has_many() { - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("memos", proc_macro2::Span::call_site()), - syn::Ident::new("memos", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - let relation_fields = vec![create_test_relation_info( - "memos", - "HasMany", - quote! { memo::Schema }, - false, - )]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl UserSchema")); - assert!(output.contains("pub async fn from_model")); - assert!(output.contains(". all (db)")); - } - - #[test] - fn test_generate_from_model_with_belongs_to() { - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("user", proc_macro2::Span::call_site()), - syn::Ident::new("user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - let relation_fields = vec![create_test_relation_info( - "user", - "BelongsTo", - quote! { user::Schema }, - true, - )]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - assert!(output.contains("find_related")); - assert!(output.contains(". one (db)")); - } - - #[test] - fn test_generate_from_model_no_relations() { - let new_type_name = syn::Ident::new("SimpleSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("name", proc_macro2::Span::call_site()), - syn::Ident::new("name", proc_macro2::Span::call_site()), - false, - false, - ), - ]; - let relation_fields = vec![]; - let source_module_path = vec!["crate".to_string()]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl SimpleSchema")); - assert!(output.contains("id : model . id")); - assert!(output.contains("name : model . name")); - } - - #[test] - fn test_generate_from_model_with_inline_type() { - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("user", proc_macro2::Span::call_site()), - syn::Ident::new("user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - // Relation with inline type info (for circular references) - let mut rel_info = - create_test_relation_info("user", "HasOne", quote! { user::Schema }, true); - rel_info.inline_type_info = Some(( - syn::Ident::new("MemoSchema_User", proc_macro2::Span::call_site()), - vec!["id".to_string(), "name".to_string()], - )); - let relation_fields = vec![rel_info]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - assert!(output.contains("find_related")); - } - - #[test] - fn test_generate_from_model_unknown_relation_type() { - let new_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("unknown", proc_macro2::Span::call_site()), - syn::Ident::new("unknown", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - // Unknown relation type - let relation_fields = vec![create_test_relation_info( - "unknown", - "UnknownType", - quote! { some::Schema }, - true, - )]; - let source_module_path = vec!["crate".to_string()]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - // Unknown relation type should generate empty token (no load statement) - assert!(output.contains("impl TestSchema")); - } - - #[test] - fn test_generate_from_model_relation_field_not_in_mappings() { - let new_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - // Relation field with different source_ident - ( - syn::Ident::new("owner", proc_macro2::Span::call_site()), - syn::Ident::new("different_name", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - let relation_fields = vec![create_test_relation_info( - "user", - "HasOne", - quote! { user::Schema }, - true, - )]; - let source_module_path = vec!["crate".to_string()]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - // Should still generate valid code - assert!(output.contains("impl TestSchema")); - } - - #[test] - fn test_generate_from_model_with_has_many_inline() { - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("memos", proc_macro2::Span::call_site()), - syn::Ident::new("memos", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - // HasMany with inline type - let mut rel_info = - create_test_relation_info("memos", "HasMany", quote! { memo::Schema }, false); - rel_info.inline_type_info = Some(( - syn::Ident::new("UserSchema_Memos", proc_macro2::Span::call_site()), - vec!["id".to_string(), "title".to_string()], - )); - let relation_fields = vec![rel_info]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl UserSchema")); - assert!(output.contains(". all (db)")); - assert!(output.contains("into_iter")); - assert!(output.contains("collect")); - } - - // ============================================================ - // Coverage tests for file-based lookup branches - // ============================================================ - - #[test] - #[serial] - fn test_generate_from_model_needs_parent_stub_with_required_circular() { - // Tests for from_model generation - // Tests: HasMany relation where target model has REQUIRED circular back-ref - // This triggers needs_parent_stub = true and generates parent stub fields - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create memo.rs with Model that has REQUIRED circular back-ref to user - // The memo has `user: Box` (not Option) - required - let memo_model = r#" -pub struct Model { - pub id: i32, - pub title: String, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id")] - pub user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Create user.rs - let user_model = r" -pub struct Model { - pub id: i32, - pub name: String, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - // Field mappings: id (regular), name (regular), memos (relation, HasMany) - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("name", proc_macro2::Span::call_site()), - syn::Ident::new("name", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("memos", proc_macro2::Span::call_site()), - syn::Ident::new("memos", proc_macro2::Span::call_site()), - false, - true, // is_relation - ), - ]; - - // HasMany WITHOUT inline_type_info (triggers parent stub path) - let relation_fields = vec![create_test_relation_info( - "memos", - "HasMany", - quote! { crate::models::memo::Schema }, - false, - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - assert!(output.contains("from_model")); - // Should have parent stub with __parent_stub__ - assert!( - output.contains("__parent_stub__"), - "Should have parent stub: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_circular_has_one_optional() { - // Tests for field name resolution - // Tests: HasOne with circular reference, optional - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create profile.rs with circular back-ref to user - let profile_model = r" -pub struct Model { - pub id: i32, - pub bio: String, - pub user: BelongsTo, -} -"; - std::fs::write(models_dir.join("profile.rs"), profile_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("profile", proc_macro2::Span::call_site()), - syn::Ident::new("profile", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne, optional, WITHOUT inline_type_info - let relation_fields = vec![create_test_relation_info( - "profile", - "HasOne", - quote! { crate::models::profile::Schema }, - true, // optional - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Circular optional should have .map(|r| Box::new(...)) - assert!( - output.contains(". map (| r |"), - "Should have map for optional: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_circular_has_one_required() { - // Tests for relation conversion failure - // Tests: HasOne with circular reference, required - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create profile.rs with circular back-ref to user - let profile_model = r" -pub struct Model { - pub id: i32, - pub bio: String, - pub user: BelongsTo, -} -"; - std::fs::write(models_dir.join("profile.rs"), profile_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("profile", proc_macro2::Span::call_site()), - syn::Ident::new("profile", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne, REQUIRED, WITHOUT inline_type_info - let relation_fields = vec![create_test_relation_info( - "profile", - "HasOne", - quote! { crate::models::profile::Schema }, - false, // required - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Required circular should have Box::new with error handling - assert!( - output.contains("Box :: new"), - "Should have Box::new for required: {output}" - ); - assert!( - output.contains("ok_or_else"), - "Should have ok_or_else: {output}" - ); - } - - #[test] - fn test_generate_from_model_unknown_relation_with_inline_type() { - // Tests for unknown relation type handling - // Tests: Unknown relation type WITH inline_type_info -> Default::default() - let new_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("weird", proc_macro2::Span::call_site()), - syn::Ident::new("weird", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // Unknown relation type WITH inline_type_info - let mut rel_info = create_test_relation_info( - "weird", - "UnknownRelationType", - quote! { some::Schema }, - true, - ); - rel_info.inline_type_info = Some(( - syn::Ident::new("TestSchema_Weird", proc_macro2::Span::call_site()), - vec!["id".to_string()], - )); - - let relation_fields = vec![rel_info]; - let source_module_path = vec!["crate".to_string()]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - let output = tokens.to_string(); - assert!(output.contains("impl TestSchema")); - // Unknown relation with inline type should use Default::default() - assert!( - output.contains("Default :: default ()"), - "Should have Default::default(): {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_non_circular_has_one_with_fk_optional() { - // Tests for field rename handling - // Tests: HasOne with FK relations in target, no circular, optional - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create address.rs with FK relations but NO circular back-ref to user - let address_model = r" -pub struct Model { - pub id: i32, - pub street: String, - pub city_id: i32, - pub city: BelongsTo, -} -"; - std::fs::write(models_dir.join("address.rs"), address_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("address", proc_macro2::Span::call_site()), - syn::Ident::new("address", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne, optional, no inline_type_info - let relation_fields = vec![create_test_relation_info( - "address", - "HasOne", - quote! { crate::models::address::Schema }, - true, // optional - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Non-circular with FK, optional should have match statement with async from_model - assert!( - output.contains("from_model (r , db) . await"), - "Should have async from_model: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_non_circular_has_one_with_fk_required() { - // Tests for parent stub generation - // Tests: HasOne with FK relations in target, no circular, required - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create address.rs with FK relations but NO circular back-ref to user - let address_model = r" -pub struct Model { - pub id: i32, - pub street: String, - pub city_id: i32, - pub city: BelongsTo, -} -"; - std::fs::write(models_dir.join("address.rs"), address_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("address", proc_macro2::Span::call_site()), - syn::Ident::new("address", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne, REQUIRED, no inline_type_info - let relation_fields = vec![create_test_relation_info( - "address", - "HasOne", - quote! { crate::models::address::Schema }, - false, // required - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Required with FK should have Box::new with from_model call - assert!( - output.contains("Box :: new"), - "Should have Box::new: {output}" - ); - assert!( - output.contains("from_model"), - "Should have from_model: {output}" - ); - assert!( - output.contains("ok_or_else"), - "Should have ok_or_else: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_circular() { - // Tests for quote generation - // Tests: HasMany with circular reference - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create memo.rs with circular back-ref to user - let memo_model = r" -pub struct Model { - pub id: i32, - pub title: String, - pub user: BelongsTo, -} -"; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("memos", proc_macro2::Span::call_site()), - syn::Ident::new("memos", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany WITHOUT inline_type_info - will use generate_inline_struct_construction - let relation_fields = vec![create_test_relation_info( - "memos", - "HasMany", - quote! { crate::models::memo::Schema }, - false, - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // HasMany with circular should have into_iter().map().collect() - assert!( - output.contains("into_iter ()"), - "Should have into_iter: {output}" - ); - assert!(output.contains(". map (| r |"), "Should have map: {output}"); - assert!(output.contains("collect"), "Should have collect: {output}"); - } - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_fk_no_circular() { - // Tests for multi-variant case handling - // Tests: HasMany with FK relations in target, no circular - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create tag.rs with FK relations but NO circular back-ref to user - let tag_model = r" -pub struct Model { - pub id: i32, - pub name: String, - pub category_id: i32, - pub category: BelongsTo, -} -"; - std::fs::write(models_dir.join("tag.rs"), tag_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("tags", proc_macro2::Span::call_site()), - syn::Ident::new("tags", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany, no inline_type_info - let relation_fields = vec![create_test_relation_info( - "tags", - "HasMany", - quote! { crate::models::tag::Schema }, - false, - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // HasMany with FK but no circular should use inline_struct_construction - assert!( - output.contains("into_iter ()"), - "Should have into_iter: {output}" - ); - assert!(output.contains(". map (| r |"), "Should have map: {output}"); - assert!(output.contains("collect"), "Should have collect: {output}"); - } - - #[test] - #[serial] - fn test_generate_from_model_inline_type_required() { - // Tests: inline_type_info with required BelongsTo - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - let user_model = r" -pub struct Model { - pub id: i32, - pub name: String, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::memo::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("user", proc_macro2::Span::call_site()), - syn::Ident::new("user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // BelongsTo with inline_type_info, REQUIRED - let mut rel_info = create_test_relation_info( - "user", - "BelongsTo", - quote! { crate::models::user::Schema }, - false, // required - ); - rel_info.inline_type_info = Some(( - syn::Ident::new("MemoSchema_User", proc_macro2::Span::call_site()), - vec!["id".to_string(), "name".to_string()], - )); - - let relation_fields = vec![rel_info]; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl MemoSchema")); - // Required inline type should have Box::new with ok_or_else - assert!( - output.contains("Box :: new"), - "Should have Box::new: {output}" - ); - assert!( - output.contains("ok_or_else"), - "Should have ok_or_else: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_parent_stub_all_relation_types() { - // Tests for relation type variants - // Tests: Parent stub generation with: - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create memo.rs with REQUIRED circular back-ref to user - // This triggers needs_parent_stub = true - let memo_model = r#" -pub struct Model { - pub id: i32, - pub title: String, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id")] - pub user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Create profile.rs (for optional single relation) - let profile_model = r" -pub struct Model { - pub id: i32, - pub bio: String, -} -"; - std::fs::write(models_dir.join("profile.rs"), profile_model).unwrap(); - - // Create settings.rs (for required single relation) - let settings_model = r" -pub struct Model { - pub id: i32, - pub theme: String, -} -"; - std::fs::write(models_dir.join("settings.rs"), settings_model).unwrap(); - - // Save and set CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - // Field mappings with various relation types - let field_mappings = vec![ - // Regular field - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - // HasMany - this one triggers needs_parent_stub - ( - syn::Ident::new("memos", proc_macro2::Span::call_site()), - syn::Ident::new("memos", proc_macro2::Span::call_site()), - false, - true, - ), - // Optional single relation - ( - syn::Ident::new("profile", proc_macro2::Span::call_site()), - syn::Ident::new("profile", proc_macro2::Span::call_site()), - false, - true, - ), - // Required single relation - ( - syn::Ident::new("settings", proc_macro2::Span::call_site()), - syn::Ident::new("settings", proc_macro2::Span::call_site()), - false, - true, - ), - // Relation field NOT in relation_fields - ( - syn::Ident::new("orphan_rel", proc_macro2::Span::call_site()), - syn::Ident::new("orphan_rel", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // Relation fields - note: orphan_rel is NOT included here - let relation_fields = vec![ - // HasMany without inline_type_info (triggers needs_parent_stub) - create_test_relation_info( - "memos", - "HasMany", - quote! { crate::models::memo::Schema }, - false, - ), - // Optional HasOne - create_test_relation_info( - "profile", - "HasOne", - quote! { crate::models::profile::Schema }, - true, // optional - ), - // Required BelongsTo - create_test_relation_info( - "settings", - "BelongsTo", - quote! { crate::models::settings::Schema }, - false, // required - ), - // Note: orphan_rel is NOT in relation_fields - ]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Should have parent stub - assert!( - output.contains("__parent_stub__"), - "Should have parent stub: {output}" - ); - // Parent stub should have various default values - // Line 113: memos: vec![] - assert!( - output.contains("memos : vec ! []"), - "Should have memos: vec![]: {output}" - ); - // Line 114 & 117: profile/settings: None (both optional and required single relations) - // (Both produce None in parent stub) - assert!( - output.contains("profile : None") || output.contains("settings : None"), - "Should have None for single relations: {output}" - ); - // Line 120: orphan_rel: Default::default() - assert!( - output.contains("Default :: default ()"), - "Should have Default::default() for orphan: {output}" - ); - } - - // ============================================================ - // Tests for relation_enum + fk_column branches - // ============================================================ - - fn create_test_relation_info_full( - field_name: &str, - relation_type: &str, - schema_path: TokenStream, - is_optional: bool, - relation_enum: Option, - fk_column: Option, - via_rel: Option, - ) -> RelationFieldInfo { - RelationFieldInfo { - field_name: syn::Ident::new(field_name, proc_macro2::Span::call_site()), - relation_type: relation_type.to_string(), - schema_path, - is_optional, - inline_type_info: None, - relation_enum, - fk_column, - via_rel, - } - } - - #[test] - fn test_generate_from_model_has_one_with_relation_enum_optional_with_fk() { - // Tests for field name comparison - // Tests: HasOne with relation_enum + optional + fk_column present - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("target_user", proc_macro2::Span::call_site()), - syn::Ident::new("target_user", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne with relation_enum, optional, WITH fk_column - let relation_fields = vec![create_test_relation_info_full( - "target_user", - "HasOne", - quote! { user::Schema }, - true, // optional - Some("TargetUser".to_string()), // relation_enum - Some("target_user_id".to_string()), // fk_column - None, // via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - // Should have match statement checking FK field - assert!( - output.contains("match & model . target_user_id"), - "Should match on FK field: {output}" - ); - assert!( - output.contains("Some (fk_value)"), - "Should have Some(fk_value) arm: {output}" - ); - assert!( - output.contains("find_by_id"), - "Should use find_by_id: {output}" - ); - } - - #[test] - fn test_generate_from_model_has_one_with_relation_enum_optional_no_fk() { - // Tests for None branch - // Tests: HasOne with relation_enum + optional + NO fk_column (fallback) - let new_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("author", proc_macro2::Span::call_site()), - syn::Ident::new("author", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasOne with relation_enum, optional, WITHOUT fk_column - let relation_fields = vec![create_test_relation_info_full( - "author", - "HasOne", - quote! { user::Schema }, - true, // optional - Some("Author".to_string()), // relation_enum - None, // NO fk_column - None, // via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl MemoSchema")); - // Fallback: use Entity::find_related(Relation::Variant) - assert!( - output.contains("Entity :: find_related (Relation :: Author)"), - "Should use find_related with Relation enum: {output}" - ); - } - - #[test] - fn test_generate_from_model_belongs_to_with_relation_enum_required_with_fk() { - // Tests for required relation field - // Tests: BelongsTo with relation_enum + required + fk_column present - let new_type_name = syn::Ident::new("CommentSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("post", proc_macro2::Span::call_site()), - syn::Ident::new("post", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // BelongsTo with relation_enum, required, WITH fk_column - let relation_fields = vec![create_test_relation_info_full( - "post", - "BelongsTo", - quote! { post::Schema }, - false, // required - Some("Post".to_string()), // relation_enum - Some("post_id".to_string()), // fk_column - None, // via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "comment".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl CommentSchema")); - // Should directly query by FK value - assert!( - output.contains("find_by_id (model . post_id . clone ())"), - "Should use find_by_id with FK: {output}" - ); - } - - #[test] - fn test_generate_from_model_belongs_to_with_relation_enum_required_no_fk() { - // Tests for skip condition - // Tests: BelongsTo with relation_enum + required + NO fk_column (fallback) - let new_type_name = syn::Ident::new("CommentSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("author", proc_macro2::Span::call_site()), - syn::Ident::new("author", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // BelongsTo with relation_enum, required, WITHOUT fk_column - let relation_fields = vec![create_test_relation_info_full( - "author", - "BelongsTo", - quote! { user::Schema }, - false, // required - Some("Author".to_string()), // relation_enum - None, // NO fk_column - None, // via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "comment".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - let output = tokens.to_string(); - - assert!(output.contains("impl CommentSchema")); - // Fallback: use Entity::find_related(Relation::Variant) - assert!( - output.contains("Entity :: find_related (Relation :: Author)"), - "Should use find_related with Relation enum: {output}" - ); - } - - // ============================================================ - // Tests for HasMany with via_rel/relation_enum - // ============================================================ - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_via_rel_fk_found() { - // Tests for HasMany with via_rel + FK column found - // Tests: HasMany with via_rel + FK column found in target entity - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create notification.rs with matching relation_enum - let notification_model = r#" -pub struct Model { - pub id: i32, - pub message: String, - pub target_user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "target_user_id", to = "id", relation_enum = "TargetUser")] - pub target_user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("notification.rs"), notification_model).unwrap(); - - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("target_user_notifications", proc_macro2::Span::call_site()), - syn::Ident::new("target_user_notifications", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany with via_rel - let relation_fields = vec![create_test_relation_info_full( - "target_user_notifications", - "HasMany", - quote! { crate::models::notification::Schema }, - false, - None, - None, - Some("TargetUser".to_string()), // via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Should generate FK-based query - assert!( - output.contains("TargetUserId"), - "Should have FK column identifier: {output}" - ); - assert!( - output.contains("into_column ()"), - "Should have into_column: {output}" - ); - assert!( - output.contains("eq (model . id . clone ())"), - "Should compare with model.id: {output}" - ); - assert!( - output.contains(". all (db)"), - "Should use .all(db): {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_via_rel_fk_not_found() { - // Tests for HasMany via_rel not found - // Tests: HasMany with via_rel but FK column NOT found in target entity - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create notification.rs WITHOUT matching relation_enum - let notification_model = r" -pub struct Model { - pub id: i32, - pub message: String, -} -"; - std::fs::write(models_dir.join("notification.rs"), notification_model).unwrap(); - - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("notifications", proc_macro2::Span::call_site()), - syn::Ident::new("notifications", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany with via_rel that won't find FK - let relation_fields = vec![create_test_relation_info_full( - "notifications", - "HasMany", - quote! { crate::models::notification::Schema }, - false, - None, - None, - Some("NonExistentRelation".to_string()), // via_rel that won't match - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Should fall back to empty vec (WARNING comment won't appear in TokenStream) - assert!( - output.contains("vec ! []"), - "Should fall back to empty vec: {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_relation_enum_fk_found() { - // Tests for via_rel field matching - // Tests: HasMany with relation_enum (no via_rel) + FK column found - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create comment.rs with matching relation_enum - let comment_model = r#" -pub struct Model { - pub id: i32, - pub content: String, - pub author_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "author_id", to = "id", relation_enum = "AuthorComments")] - pub author: BelongsTo, -} -"#; - std::fs::write(models_dir.join("comment.rs"), comment_model).unwrap(); - - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("author_comments", proc_macro2::Span::call_site()), - syn::Ident::new("author_comments", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany with relation_enum (no via_rel) - let relation_fields = vec![create_test_relation_info_full( - "author_comments", - "HasMany", - quote! { crate::models::comment::Schema }, - false, - Some("AuthorComments".to_string()), // relation_enum - None, - None, // NO via_rel - will use relation_enum as via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Should generate FK-based query using relation_enum as via_rel - assert!( - output.contains("AuthorId"), - "Should have FK column identifier: {output}" - ); - assert!( - output.contains("into_column ()"), - "Should have into_column: {output}" - ); - assert!( - output.contains(". all (db)"), - "Should use .all(db): {output}" - ); - } - - #[test] - #[serial] - fn test_generate_from_model_has_many_with_relation_enum_fk_not_found() { - // Tests for HasMany via_rel generation - // Tests: HasMany with relation_enum (no via_rel) + FK column NOT found - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create post.rs WITHOUT matching relation_enum - let post_model = r" -pub struct Model { - pub id: i32, - pub title: String, -} -"; - std::fs::write(models_dir.join("post.rs"), post_model).unwrap(); - - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let new_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let source_type: Type = syn::parse_str("crate::models::user::Model").unwrap(); - - let field_mappings = vec![ - ( - syn::Ident::new("id", proc_macro2::Span::call_site()), - syn::Ident::new("id", proc_macro2::Span::call_site()), - false, - false, - ), - ( - syn::Ident::new("authored_posts", proc_macro2::Span::call_site()), - syn::Ident::new("authored_posts", proc_macro2::Span::call_site()), - false, - true, - ), - ]; - - // HasMany with relation_enum that won't match (no via_rel) - let relation_fields = vec![create_test_relation_info_full( - "authored_posts", - "HasMany", - quote! { crate::models::post::Schema }, - false, - Some("NonExistentRelation".to_string()), // relation_enum that won't match - None, - None, // NO via_rel - )]; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let tokens = generate_from_model_with_relations( - &new_type_name, - &source_type, - &field_mappings, - &relation_fields, - &source_module_path, - &HashMap::new(), - ); - - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - let output = tokens.to_string(); - assert!(output.contains("impl UserSchema")); - // Should fall back to empty vec (WARNING comment won't appear in TokenStream) - assert!( - output.contains("vec ! []"), - "Should fall back to empty vec: {output}" + // Entity-path derivation: the rewritten PATH is the whole contract — + // each case snapshots the exact token output (e.g. `Schema` tail must + // become `Entity`, all module segments preserved) instead of probing + // substrings. Snapshot names are explicit because insta's + // auto-naming shuffles across parallel rstest cases. + #[rstest] + #[case::crate_qualified("entity_path_crate_qualified", quote! { crate::models::user::Schema })] + #[case::simple_module("entity_path_simple_module", quote! { user::Schema })] + #[case::deeply_nested( + "entity_path_deeply_nested", + quote! { crate::api::models::entities::user::Schema } + )] + #[case::single_segment("entity_path_single_segment", quote! { Schema })] + fn build_entity_path_from_schema_path_snapshot( + #[case] snapshot_name: &str, + #[case] schema_path: TokenStream, + ) { + insta::assert_snapshot!( + snapshot_name, + build_entity_path_from_schema_path(&schema_path, &[]).to_string() ); } } diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate.rs b/crates/vespera_macro/src/schema_macro/from_model/generate.rs new file mode 100644 index 00000000..cdcfb92b --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate.rs @@ -0,0 +1,442 @@ +//! Async `from_model` impl generation for SeaORM models with +//! relations (circular handling, FK lookups, parent stubs). + +use std::collections::HashMap; + +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned}; +use syn::Type; + +use super::super::{ + circular::{generate_inline_struct_construction, generate_inline_type_construction}, + file_cache::{get_circular_analysis, get_fk_column, get_struct_from_schema_path}, + seaorm::RelationFieldInfo, + type_utils::{normalize_token_str, snake_to_pascal_case}, +}; +use super::build_entity_path_from_schema_path; +use crate::metadata::StructMetadata; + +/// Generate `from_model` impl for `SeaORM` Model WITH relations (async version). +/// +/// When circular references are detected, generates inline struct construction +/// that excludes circular fields (sets them to default values). +/// +/// ```ignore +/// impl NewType { +/// pub async fn from_model( +/// model: SourceType, +/// db: &sea_orm::DatabaseConnection, +/// ) -> Result { +/// // Load related entities +/// let user = model.find_related(user::Entity).one(db).await?; +/// let tags = model.find_related(tag::Entity).all(db).await?; +/// +/// Ok(Self { +/// id: model.id, +/// // Inline construction with circular field defaulted: +/// user: user.map(|r| Box::new(user::Schema { id: r.id, memos: vec![], ... })), +/// tags: tags.into_iter().map(|r| tag::Schema { ... }).collect(), +/// }) +/// } +/// } +/// ``` +#[allow(clippy::too_many_lines, clippy::option_if_let_else)] +pub fn generate_from_model_with_relations( + new_type_name: &syn::Ident, + source_type: &Type, + field_mappings: &[(syn::Ident, syn::Ident, bool, bool)], + relation_fields: &[RelationFieldInfo], + source_module_path: &[String], + _schema_storage: &HashMap, +) -> TokenStream { + // Build relation loading statements + let relation_loads: Vec = relation_fields + .iter() + .map(|rel| { + let field_name = &rel.field_name; + let entity_path = build_entity_path_from_schema_path(&rel.schema_path, source_module_path); + + match rel.relation_type.as_str() { + "HasOne" | "BelongsTo" => { + // When relation_enum is specified, use the specific Relation variant + // This handles cases where multiple relations point to the same Entity type + if let Some(ref relation_enum_name) = rel.relation_enum { + let relation_variant = syn::Ident::new(relation_enum_name, proc_macro2::Span::call_site()); + + if rel.is_optional { + // Optional FK: load only if FK value exists + if let Some(ref fk_col) = rel.fk_column { + let fk_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site()); + quote! { + let #field_name = match &model.#fk_ident { + Some(fk_value) => #entity_path::find_by_id(fk_value.clone()).one(db).await?, + None => None, + }; + } + } else { + // Fallback: use find_related with Relation enum + quote! { + let #field_name = Entity::find_related(Relation::#relation_variant) + .filter(::PrimaryKey::eq(&model)) + .one(db) + .await?; + } + } + } else { + // Required FK: directly query by FK value + if let Some(ref fk_col) = rel.fk_column { + let fk_ident = syn::Ident::new(fk_col, proc_macro2::Span::call_site()); + quote! { + let #field_name = #entity_path::find_by_id(model.#fk_ident.clone()).one(db).await?; + } + } else { + // Fallback: use find_related with Relation enum + quote! { + let #field_name = Entity::find_related(Relation::#relation_variant) + .filter(::PrimaryKey::eq(&model)) + .one(db) + .await?; + } + } + } + } else { + // Standard case: single relation to target entity, use find_related + quote! { + let #field_name = model.find_related(#entity_path).one(db).await?; + } + } + } + "HasMany" => { + // Try via_rel first, fall back to relation_enum as FK source + let fk_rel_source = rel.via_rel.as_ref().or(rel.relation_enum.as_ref()); + if let Some(via_rel_value) = fk_rel_source { + let schema_path_str = normalize_token_str(&rel.schema_path); + if let Some(fk_col_name) = get_fk_column(&schema_path_str, via_rel_value) { + let fk_col_pascal = snake_to_pascal_case(&fk_col_name); + let fk_col_ident = syn::Ident::new(&fk_col_pascal, proc_macro2::Span::call_site()); + + let entity_path_str = normalize_token_str(&entity_path); + let column_path_str = entity_path_str.replace(":: Entity", ":: Column"); + let column_path_idents: Vec = column_path_str + .split("::") + .filter_map(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { None } else { Some(syn::Ident::new(trimmed, proc_macro2::Span::call_site())) } + }) + .collect(); + + quote! { + let #field_name = #(#column_path_idents)::*::#fk_col_ident + .into_column() + .eq(model.id.clone()) + .into_condition(); + let #field_name = #entity_path::find() + .filter(#field_name) + .all(db) + .await?; + } + } else { + quote! { + // WARNING: Could not find FK column for relation, using empty vec + let #field_name: Vec<_> = vec![]; + } + } + } else { + // Standard HasMany - use find_related + quote! { + let #field_name = model.find_related(#entity_path).all(db).await?; + } + } + } + _ => quote! {}, + } + }) + .collect(); + + // Check if we need a parent stub for HasMany relations with required circular back-refs + // This is needed when: UserSchema.memos has MemoSchema which has required user: Box + // BUT: If the relation uses an inline type (which excludes circular fields), we don't need a parent stub + let needs_parent_stub = relation_fields.iter().any(|rel| { + // A parent stub is needed whenever a relation's inline construction can + // emit `__parent_stub__` for a REQUIRED circular back-reference. That + // is NOT HasMany-only: a required circular HasOne/BelongsTo (with no + // inline type) also routes through `generate_inline_struct_construction` + // (see the `has_circular` arm below) and references the same stub. + // Excluding them generated code referencing an undefined + // `__parent_stub__` local for that schema shape. + if !matches!( + rel.relation_type.as_str(), + "HasMany" | "HasOne" | "BelongsTo" + ) { + return false; + } + // If using inline type, circular fields are excluded, so no parent stub needed + if rel.inline_type_info.is_some() { + return false; + } + let schema_path_str = normalize_token_str(&rel.schema_path); + let model_path_str = schema_path_str.replace("::Schema", "::Model"); + let related_model = get_struct_from_schema_path(&model_path_str); + + if let Some(ref model) = related_model { + let analysis = get_circular_analysis(source_module_path, &model.definition); + // Check if any circular field is a required relation + analysis.circular_fields.iter().any(|cf| { + analysis + .circular_field_required + .get(cf) + .copied() + .unwrap_or(false) + }) + } else { + false + } + }); + + // Generate parent stub field assignments (non-relation fields from model) + let parent_stub_fields: Vec = if needs_parent_stub { + field_mappings + .iter() + .map(|(new_ident, source_ident, _wrapped, is_relation)| { + if *is_relation { + // For relation fields in stub, use defaults + if let Some(rel) = relation_fields + .iter() + .find(|r| &r.field_name == source_ident) + { + match rel.relation_type.as_str() { + "HasMany" => quote! { #new_ident: vec![] }, + _ if rel.is_optional => quote! { #new_ident: None }, + _ => { + let message = format!( + "schema_type! cannot generate a circular parent stub for required relation field `{}`; make the relation `Option<...>` to break the cycle", + rel.field_name + ); + let error = syn::Error::new(rel.field_name.span(), message) + .to_compile_error(); + quote_spanned! { rel.field_name.span() => #new_ident: { #error } } + } + } + } else { + quote! { #new_ident: Default::default() } + } + } else { + // Regular field - clone from model + quote! { #new_ident: model.#source_ident.clone() } + } + }) + .collect() + } else { + vec![] + }; + + // Pre-build relation lookup for O(1) access in field assignments loop + let relation_by_name: HashMap<&syn::Ident, &RelationFieldInfo> = relation_fields + .iter() + .map(|rel| (&rel.field_name, rel)) + .collect(); + + // Build field assignments + // For relation fields, check for circular references and use inline construction if needed + let field_assignments: Vec = field_mappings + .iter() + .map(|(new_ident, source_ident, wrapped, is_relation)| { + if *is_relation { + // Find the relation info for this field + if let Some(rel) = relation_by_name.get(source_ident) { + let schema_path = &rel.schema_path; + + // Try to find the related MODEL definition to check for circular refs + // The schema_path is like "crate::models::user::Schema", but the actual + // struct is "Model" in the same module. We need to look up the Model + // to see if it has relations pointing back to us. + let schema_path_str = normalize_token_str(schema_path); + + // Convert schema path to model path: Schema -> Model + let model_path_str = schema_path_str.replace("::Schema", "::Model"); + + // Try to find the related Model definition from file + let related_model_from_file = get_struct_from_schema_path(&model_path_str); + + // Get the definition string + let related_def_str = related_model_from_file.as_ref().map_or("", |s| s.definition.as_str()); + + // Analyze circular references, FK relations, and FK optionality in ONE pass + let analysis = get_circular_analysis(source_module_path, related_def_str); + let circular_fields = &analysis.circular_fields; + let has_circular = !circular_fields.is_empty(); + + // Check if we have inline type info - if so, use the inline type + // instead of the original schema path + if let Some((ref inline_type_name, ref included_fields)) = rel.inline_type_info { + // Use inline type construction + let inline_construct = generate_inline_type_construction(inline_type_name, included_fields, related_def_str, "r"); + + match rel.relation_type.as_str() { + "HasOne" | "BelongsTo" => { + if rel.is_optional { + quote! { + #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) + } + } else { + quote! { + #new_ident: Box::new({ + let r = #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))?; + #inline_construct + }) + } + } + } + "HasMany" => { + quote! { + #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() + } + } + _ => quote! { #new_ident: Default::default() }, + } + } else { + // No inline type - use original behavior + match rel.relation_type.as_str() { + "HasOne" | "BelongsTo" => { + if has_circular { + // Use inline construction to break circular ref + let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, circular_fields, "r"); + if rel.is_optional { + quote! { + #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) + } + } else { + quote! { + #new_ident: Box::new({ + let r = #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))?; + #inline_construct + }) + } + } + } else { + // No circular ref - use has_fk_relations from the analysis + let target_has_fk = analysis.has_fk_relations; + + if target_has_fk { + // Target schema has FK relations -> use async from_model() + if rel.is_optional { + quote! { + #new_ident: match #source_ident { + Some(r) => Some(Box::new(#schema_path::from_model(r, db).await?)), + None => None, + } + } + } else { + quote! { + #new_ident: Box::new(#schema_path::from_model( + #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))?, + db, + ).await?) + } + } + } else { + // Target schema has no FK relations -> use sync From::from() + if rel.is_optional { + quote! { + #new_ident: #source_ident.map(|r| Box::new(<#schema_path as From<_>>::from(r))) + } + } else { + quote! { + #new_ident: Box::new(<#schema_path as From<_>>::from( + #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))? + )) + } + } + } + } + } + "HasMany" => { + // HasMany is excluded by default, so this branch is only hit + // when explicitly picked. Use inline construction (no relations). + if has_circular { + // Use inline construction to break circular ref + let inline_construct = generate_inline_struct_construction(schema_path, related_def_str, circular_fields, "r"); + quote! { + #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() + } + } else { + // No circular ref - use has_fk_relations from the analysis + let target_has_fk = analysis.has_fk_relations; + + if target_has_fk { + // Target has FK relations but HasMany doesn't load nested data anyway, + // so we use inline construction (flat fields only) + let inline_construct = generate_inline_struct_construction( + schema_path, + related_def_str, + &[], // no circular fields to exclude + "r", + ); + quote! { + #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() + } + } else { + quote! { + #new_ident: #source_ident.into_iter().map(|r| <#schema_path as From<_>>::from(r)).collect() + } + } + } + } + _ => quote! { #new_ident: Default::default() }, + } + } + } else { + quote! { #new_ident: Default::default() } + } + } else if *wrapped { + quote! { #new_ident: Some(model.#source_ident) } + } else { + quote! { #new_ident: model.#source_ident } + } + }) + .collect(); + + // Circular references are now handled automatically via inline construction + // For HasMany with required circular back-refs, we create a parent stub first + + // Generate parent stub definition if needed + let parent_stub_def = if needs_parent_stub { + quote! { + let __parent_stub__ = Self { + #(#parent_stub_fields),* + }; + } + } else { + quote! {} + }; + + quote! { + impl #new_type_name { + pub async fn from_model( + model: #source_type, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + + #(#relation_loads)* + + #parent_stub_def + + Ok(Self { + #(#field_assignments),* + }) + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__belongs_to_optional_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__belongs_to_optional_simple.snap new file mode 100644 index 00000000..6f9eadd9 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__belongs_to_optional_simple.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(user::Entity).one(db).await?; + Ok(Self { + id: model.id, + user: user.map(|r| Box::new(>::from(r))), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_optional.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_optional.snap new file mode 100644 index 00000000..ee261bce --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_optional.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let profile = model.find_related(crate::models::profile::Entity).one(db).await?; + Ok(Self { + id: model.id, + profile: profile + .map(|r| Box::new(crate::models::profile::Schema { + id: r.id, + bio: r.bio, + user: None, + })), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_required.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_required.snap new file mode 100644 index 00000000..793c45bd --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_required.snap @@ -0,0 +1,27 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let profile = model.find_related(crate::models::profile::Entity).one(db).await?; + Ok(Self { + id: model.id, + profile: Box::new({ + let r = profile + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(profile)), + ))?; + crate::models::profile::Schema { + id: r.id, + bio: r.bio, + user: None, + } + }), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_no_fk.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_no_fk.snap new file mode 100644 index 00000000..6cd4dcdc --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_no_fk.snap @@ -0,0 +1,31 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl CommentSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let author = Entity::find_related(Relation::Author) + .filter(::PrimaryKey::eq(&model)) + .one(db) + .await?; + Ok(Self { + id: model.id, + author: Box::new( + >::from( + author + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!( + "Required relation '{}' not found", stringify!(author) + ), + ))?, + ), + ), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_with_fk.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_with_fk.snap new file mode 100644 index 00000000..e2d33561 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_belongs_to_required_with_fk.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl CommentSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let post = post::Entity::find_by_id(model.post_id.clone()).one(db).await?; + Ok(Self { + id: model.id, + post: Box::new( + >::from( + post + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(post)), + ))?, + ), + ), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_no_fk.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_no_fk.snap new file mode 100644 index 00000000..54565ced --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_no_fk.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let author = Entity::find_related(Relation::Author) + .filter(::PrimaryKey::eq(&model)) + .one(db) + .await?; + Ok(Self { + id: model.id, + author: author.map(|r| Box::new(>::from(r))), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_with_fk.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_with_fk.snap new file mode 100644 index 00000000..82991293 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__enum_has_one_optional_with_fk.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let target_user = match &model.target_user_id { + Some(fk_value) => user::Entity::find_by_id(fk_value.clone()).one(db).await?, + None => None, + }; + Ok(Self { + id: model.id, + target_user: target_user + .map(|r| Box::new(>::from(r))), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_circular.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_circular.snap new file mode 100644 index 00000000..6a2b67bb --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_circular.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let memos = model.find_related(crate::models::memo::Entity).all(db).await?; + Ok(Self { + id: model.id, + memos: memos + .into_iter() + .map(|r| crate::models::memo::Schema { + id: r.id, + title: r.title, + user: None, + }) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_found.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_found.snap new file mode 100644 index 00000000..32176b0c --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_found.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let author_comments = crate::models::comment::Entity::AuthorId + .into_column() + .eq(model.id.clone()) + .into_condition(); + let author_comments = crate::models::comment::Entity::find() + .filter(author_comments) + .all(db) + .await?; + let __parent_stub__ = Self { + id: model.id.clone(), + author_comments: vec![], + }; + Ok(Self { + id: model.id, + author_comments: author_comments + .into_iter() + .map(|r| crate::models::comment::Schema { + id: r.id, + content: r.content, + author_id: r.author_id, + author: Box::new(__parent_stub__.clone()), + }) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_not_found.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_not_found.snap new file mode 100644 index 00000000..fd7a0638 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_not_found.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let authored_posts: Vec<_> = vec![]; + Ok(Self { + id: model.id, + authored_posts: authored_posts + .into_iter() + .map(|r| >::from(r)) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_fk_no_circular.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_fk_no_circular.snap new file mode 100644 index 00000000..848411ba --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_fk_no_circular.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let tags = model.find_related(crate::models::tag::Entity).all(db).await?; + Ok(Self { + id: model.id, + tags: tags + .into_iter() + .map(|r| crate::models::tag::Schema { + id: r.id, + name: r.name, + category_id: r.category_id, + category: None, + }) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_inline_type.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_inline_type.snap new file mode 100644 index 00000000..8cdc58ae --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_inline_type.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let memos = model.find_related(memo::Entity).all(db).await?; + Ok(Self { + id: model.id, + memos: memos.into_iter().map(|r| Default::default()).collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_simple.snap new file mode 100644 index 00000000..61352ee0 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_simple.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let memos = model.find_related(memo::Entity).all(db).await?; + Ok(Self { + id: model.id, + memos: memos + .into_iter() + .map(|r| >::from(r)) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_found.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_found.snap new file mode 100644 index 00000000..9f8e0e10 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_found.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let target_user_notifications = crate::models::notification::Entity::TargetUserId + .into_column() + .eq(model.id.clone()) + .into_condition(); + let target_user_notifications = crate::models::notification::Entity::find() + .filter(target_user_notifications) + .all(db) + .await?; + let __parent_stub__ = Self { + id: model.id.clone(), + target_user_notifications: vec![], + }; + Ok(Self { + id: model.id, + target_user_notifications: target_user_notifications + .into_iter() + .map(|r| crate::models::notification::Schema { + id: r.id, + message: r.message, + target_user_id: r.target_user_id, + target_user: Box::new(__parent_stub__.clone()), + }) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_not_found.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_not_found.snap new file mode 100644 index 00000000..5e9ca495 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_via_rel_fk_not_found.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let notifications: Vec<_> = vec![]; + Ok(Self { + id: model.id, + notifications: notifications + .into_iter() + .map(|r| >::from(r)) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_inline_type.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_inline_type.snap new file mode 100644 index 00000000..2679de01 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_inline_type.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(user::Entity).one(db).await?; + Ok(Self { + id: model.id, + user: user.map(|r| Box::new(Default::default())), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_simple.snap new file mode 100644 index 00000000..6f9eadd9 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_simple.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(user::Entity).one(db).await?; + Ok(Self { + id: model.id, + user: user.map(|r| Box::new(>::from(r))), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_required_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_required_simple.snap new file mode 100644 index 00000000..d3f3de04 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_required_simple.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(user::Entity).one(db).await?; + Ok(Self { + id: model.id, + user: Box::new( + >::from( + user + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(user)), + ))?, + ), + ), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__inline_type_required_belongs_to.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__inline_type_required_belongs_to.snap new file mode 100644 index 00000000..989990fe --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__inline_type_required_belongs_to.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl MemoSchema { + pub async fn from_model( + model: crate::models::memo::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(crate::models::user::Entity).one(db).await?; + Ok(Self { + id: model.id, + user: Box::new({ + let r = user + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(user)), + ))?; + MemoSchema_User { + id: r.id, + name: r.name, + } + }), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__no_relations.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__no_relations.snap new file mode 100644 index 00000000..b0438281 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__no_relations.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl SimpleSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + Ok(Self { + id: model.id, + name: model.name, + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_optional.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_optional.snap new file mode 100644 index 00000000..01af6058 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_optional.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let address = model.find_related(crate::models::address::Entity).one(db).await?; + Ok(Self { + id: model.id, + address: match address { + Some(r) => { + Some( + Box::new( + crate::models::address::Schema::from_model(r, db).await?, + ), + ) + } + None => None, + }, + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_required.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_required.snap new file mode 100644 index 00000000..3142e099 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__non_circular_has_one_fk_required.snap @@ -0,0 +1,28 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let address = model.find_related(crate::models::address::Entity).one(db).await?; + Ok(Self { + id: model.id, + address: Box::new( + crate::models::address::Schema::from_model( + address + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!( + "Required relation '{}' not found", stringify!(address) + ), + ))?, + db, + ) + .await?, + ), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_all_relation_types.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_all_relation_types.snap new file mode 100644 index 00000000..450a7dd0 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_all_relation_types.snap @@ -0,0 +1,56 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let memos = model.find_related(crate::models::memo::Entity).all(db).await?; + let profile = model.find_related(crate::models::profile::Entity).one(db).await?; + let settings = model + .find_related(crate::models::settings::Entity) + .one(db) + .await?; + let __parent_stub__ = Self { + id: model.id.clone(), + memos: vec![], + profile: None, + settings: { + ::core::compile_error! { + "schema_type! cannot generate a circular parent stub for required relation field `settings`; make the relation `Option<...>` to break the cycle" + } + }, + orphan_rel: Default::default(), + }; + Ok(Self { + id: model.id, + memos: memos + .into_iter() + .map(|r| crate::models::memo::Schema { + id: r.id, + title: r.title, + user_id: r.user_id, + user: Box::new(__parent_stub__.clone()), + }) + .collect(), + profile: profile + .map(|r| Box::new(>::from(r))), + settings: Box::new( + >::from( + settings + .ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!( + "Required relation '{}' not found", stringify!(settings) + ), + ))?, + ), + ), + orphan_rel: Default::default(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_required_circular.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_required_circular.snap new file mode 100644 index 00000000..e8c8a48a --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_required_circular.snap @@ -0,0 +1,31 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl UserSchema { + pub async fn from_model( + model: crate::models::user::Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let memos = model.find_related(crate::models::memo::Entity).all(db).await?; + let __parent_stub__ = Self { + id: model.id.clone(), + name: model.name.clone(), + memos: vec![], + }; + Ok(Self { + id: model.id, + name: model.name, + memos: memos + .into_iter() + .map(|r| crate::models::memo::Schema { + id: r.id, + title: r.title, + user_id: r.user_id, + user: Box::new(__parent_stub__.clone()), + }) + .collect(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__relation_field_not_in_mappings.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__relation_field_not_in_mappings.snap new file mode 100644 index 00000000..df67e679 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__relation_field_not_in_mappings.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl TestSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + let user = model.find_related(user::Entity).one(db).await?; + Ok(Self { + id: model.id, + owner: Default::default(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_type.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_type.snap new file mode 100644 index 00000000..b330a624 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_type.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl TestSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + Ok(Self { + id: model.id, + unknown: Default::default(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_with_inline_type.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_with_inline_type.snap new file mode 100644 index 00000000..27822281 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_with_inline_type.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl TestSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + Ok(Self { + id: model.id, + weird: Default::default(), + }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__wrapped_field.snap b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__wrapped_field.snap new file mode 100644 index 00000000..84e15956 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/snapshots/vespera_macro__schema_macro__from_model__generate__tests__wrapped_field.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/generate.rs +expression: "run_scenario(models, new_type, source_type, rows, relations, module)" +--- +impl TestSchema { + pub async fn from_model( + model: Model, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + Ok(Self { id: Some(model.id) }) + } +} diff --git a/crates/vespera_macro/src/schema_macro/from_model/generate/tests.rs b/crates/vespera_macro/src/schema_macro/from_model/generate/tests.rs new file mode 100644 index 00000000..a5f97ffd --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate/tests.rs @@ -0,0 +1,395 @@ +use std::collections::HashMap; + +use rstest::rstest; +use serial_test::serial; + +use super::*; + +// ── Test support ───────────────────────────────────────────────── +// +// Every scenario snapshots the FULL generated `impl` (pretty-printed +// Rust) under an explicit name — one reviewable artifact per code +// path instead of fragile `contains` probes. All cases run +// `#[serial]` inside a temp `CARGO_MANIFEST_DIR` so file-lookup +// branches are deterministic and isolated. + +fn pretty(tokens: &TokenStream) -> String { + let file: syn::File = + syn::parse2(tokens.clone()).expect("generated tokens must parse as Rust items"); + prettyplease::unparse(&file) +} + +/// `(source_field, target_field, wrapped, is_relation)` mapping row. +type MappingRow = (&'static str, &'static str, bool, bool); + +fn mappings(rows: &[MappingRow]) -> Vec<(syn::Ident, syn::Ident, bool, bool)> { + rows.iter() + .map(|(source, target, wrapped, is_relation)| { + ( + syn::Ident::new(source, proc_macro2::Span::call_site()), + syn::Ident::new(target, proc_macro2::Span::call_site()), + *wrapped, + *is_relation, + ) + }) + .collect() +} + +fn rel( + field_name: &str, + relation_type: &str, + schema_path: TokenStream, + is_optional: bool, +) -> RelationFieldInfo { + RelationFieldInfo { + field_name: syn::Ident::new(field_name, proc_macro2::Span::call_site()), + relation_type: relation_type.to_string(), + schema_path, + is_optional, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + } +} + +fn with_inline(mut info: RelationFieldInfo, type_name: &str, fields: &[&str]) -> RelationFieldInfo { + info.inline_type_info = Some(( + syn::Ident::new(type_name, proc_macro2::Span::call_site()), + fields.iter().map(ToString::to_string).collect(), + )); + info +} + +fn with_enum( + mut info: RelationFieldInfo, + relation_enum: Option<&str>, + fk_column: Option<&str>, + via_rel: Option<&str>, +) -> RelationFieldInfo { + info.relation_enum = relation_enum.map(ToString::to_string); + info.fk_column = fk_column.map(ToString::to_string); + info.via_rel = via_rel.map(ToString::to_string); + info +} + +/// Model fixtures written under the temp project''s `src/models/`. +const USER_PLAIN: &str = "pub struct Model {\n pub id: i32,\n pub name: String,\n}"; +const MEMO_REQUIRED_CIRCULAR: &str = "pub struct Model {\n pub id: i32,\n pub title: String,\n pub user_id: i32,\n #[sea_orm(belongs_to = \"super::user::Entity\", from = \"user_id\")]\n pub user: BelongsTo,\n}"; +const MEMO_CIRCULAR: &str = "pub struct Model {\n pub id: i32,\n pub title: String,\n pub user: BelongsTo,\n}"; +const PROFILE_CIRCULAR: &str = "pub struct Model {\n pub id: i32,\n pub bio: String,\n pub user: BelongsTo,\n}"; +const PROFILE_PLAIN: &str = "pub struct Model {\n pub id: i32,\n pub bio: String,\n}"; +const SETTINGS_PLAIN: &str = "pub struct Model {\n pub id: i32,\n pub theme: String,\n}"; +const ADDRESS_FK: &str = "pub struct Model {\n pub id: i32,\n pub street: String,\n pub city_id: i32,\n pub city: BelongsTo,\n}"; +const TAG_FK: &str = "pub struct Model {\n pub id: i32,\n pub name: String,\n pub category_id: i32,\n pub category: BelongsTo,\n}"; +const NOTIFICATION_TARGET_USER: &str = "pub struct Model {\n pub id: i32,\n pub message: String,\n pub target_user_id: i32,\n #[sea_orm(belongs_to = \"super::user::Entity\", from = \"target_user_id\", to = \"id\", relation_enum = \"TargetUser\")]\n pub target_user: BelongsTo,\n}"; +const NOTIFICATION_PLAIN: &str = + "pub struct Model {\n pub id: i32,\n pub message: String,\n}"; +const COMMENT_AUTHOR_ENUM: &str = "pub struct Model {\n pub id: i32,\n pub content: String,\n pub author_id: i32,\n #[sea_orm(belongs_to = \"super::user::Entity\", from = \"author_id\", to = \"id\", relation_enum = \"AuthorComments\")]\n pub author: BelongsTo,\n}"; +const POST_PLAIN: &str = "pub struct Model {\n pub id: i32,\n pub title: String,\n}"; + +/// Run one scenario inside a temp project and return the pretty +/// impl for snapshotting. +#[allow(clippy::too_many_arguments)] +fn run_scenario( + models: &[(&str, &str)], + new_type: &str, + source_type: &str, + rows: &[MappingRow], + relations: &[RelationFieldInfo], + module: &[&str], +) -> String { + let temp_dir = tempfile::TempDir::new().unwrap(); + let models_dir = temp_dir.path().join("src").join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + for (file, source) in models { + std::fs::write(models_dir.join(file), source).unwrap(); + } + + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: every caller is a #[serial] test. + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + let tokens = generate_from_model_with_relations( + &syn::Ident::new(new_type, proc_macro2::Span::call_site()), + &syn::parse_str::(source_type).unwrap(), + &mappings(rows), + relations, + &module.iter().map(ToString::to_string).collect::>(), + &HashMap::new(), + ); + + // SAFETY: same as above. + unsafe { + match original { + Some(dir) => std::env::set_var("CARGO_MANIFEST_DIR", dir), + None => std::env::remove_var("CARGO_MANIFEST_DIR"), + } + } + + pretty(&tokens) +} + +// ── Scenario table ─────────────────────────────────────────────── + +#[rstest] +// Plain shapes (no on-disk models needed). +#[case::no_relations( + "no_relations", &[], "SimpleSchema", "Model", + &[("id", "id", false, false), ("name", "name", false, false)], + vec![], &["crate"] + )] +#[case::wrapped_field( + "wrapped_field", &[], "TestSchema", "Model", + &[("id", "id", true, false)], + vec![], &["crate"] + )] +#[case::has_one_required_simple( + "has_one_required_simple", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("user", "user", false, true)], + vec![rel("user", "HasOne", quote! { user::Schema }, false)], + &["crate", "models", "memo"] + )] +#[case::has_one_optional_simple( + "has_one_optional_simple", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("user", "user", false, true)], + vec![rel("user", "HasOne", quote! { user::Schema }, true)], + &["crate", "models", "memo"] + )] +#[case::has_many_simple( + "has_many_simple", &[], "UserSchema", "Model", + &[("id", "id", false, false), ("memos", "memos", false, true)], + vec![rel("memos", "HasMany", quote! { memo::Schema }, false)], + &["crate", "models", "user"] + )] +#[case::belongs_to_optional_simple( + "belongs_to_optional_simple", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("user", "user", false, true)], + vec![rel("user", "BelongsTo", quote! { user::Schema }, true)], + &["crate", "models", "memo"] + )] +#[case::has_one_optional_inline_type( + "has_one_optional_inline_type", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("user", "user", false, true)], + vec![with_inline( + rel("user", "HasOne", quote! { user::Schema }, true), + "MemoSchema_User", &["id", "name"], + )], + &["crate", "models", "memo"] + )] +#[case::has_many_inline_type( + "has_many_inline_type", &[], "UserSchema", "Model", + &[("id", "id", false, false), ("memos", "memos", false, true)], + vec![with_inline( + rel("memos", "HasMany", quote! { memo::Schema }, false), + "UserSchema_Memos", &["id", "title"], + )], + &["crate", "models", "user"] + )] +#[case::unknown_relation_type( + "unknown_relation_type", &[], "TestSchema", "Model", + &[("id", "id", false, false), ("unknown", "unknown", false, true)], + vec![rel("unknown", "UnknownType", quote! { some::Schema }, true)], + &["crate"] + )] +#[case::unknown_relation_with_inline_type( + "unknown_relation_with_inline_type", &[], "TestSchema", "Model", + &[("id", "id", false, false), ("weird", "weird", false, true)], + vec![with_inline( + rel("weird", "UnknownRelationType", quote! { some::Schema }, true), + "TestSchema_Weird", &["id"], + )], + &["crate"] + )] +#[case::relation_field_not_in_mappings( + "relation_field_not_in_mappings", &[], "TestSchema", "Model", + &[("id", "id", false, false), ("owner", "different_name", false, true)], + vec![rel("user", "HasOne", quote! { user::Schema }, true)], + &["crate"] + )] +// relation_enum / fk_column branches. +#[case::enum_has_one_optional_with_fk( + "enum_has_one_optional_with_fk", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("target_user", "target_user", false, true)], + vec![with_enum( + rel("target_user", "HasOne", quote! { user::Schema }, true), + Some("TargetUser"), Some("target_user_id"), None, + )], + &["crate", "models", "memo"] + )] +#[case::enum_has_one_optional_no_fk( + "enum_has_one_optional_no_fk", &[], "MemoSchema", "Model", + &[("id", "id", false, false), ("author", "author", false, true)], + vec![with_enum( + rel("author", "HasOne", quote! { user::Schema }, true), + Some("Author"), None, None, + )], + &["crate", "models", "memo"] + )] +#[case::enum_belongs_to_required_with_fk( + "enum_belongs_to_required_with_fk", &[], "CommentSchema", "Model", + &[("id", "id", false, false), ("post", "post", false, true)], + vec![with_enum( + rel("post", "BelongsTo", quote! { post::Schema }, false), + Some("Post"), Some("post_id"), None, + )], + &["crate", "models", "comment"] + )] +#[case::enum_belongs_to_required_no_fk( + "enum_belongs_to_required_no_fk", &[], "CommentSchema", "Model", + &[("id", "id", false, false), ("author", "author", false, true)], + vec![with_enum( + rel("author", "BelongsTo", quote! { user::Schema }, false), + Some("Author"), None, None, + )], + &["crate", "models", "comment"] + )] +// File-lookup branches (models on disk). +#[case::parent_stub_required_circular( + "parent_stub_required_circular", + &[("memo.rs", MEMO_REQUIRED_CIRCULAR), ("user.rs", USER_PLAIN)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("name", "name", false, false), ("memos", "memos", false, true)], + vec![rel("memos", "HasMany", quote! { crate::models::memo::Schema }, false)], + &["crate", "models", "user"] + )] +#[case::circular_has_one_optional( + "circular_has_one_optional", + &[("profile.rs", PROFILE_CIRCULAR)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("profile", "profile", false, true)], + vec![rel("profile", "HasOne", quote! { crate::models::profile::Schema }, true)], + &["crate", "models", "user"] + )] +#[case::circular_has_one_required( + "circular_has_one_required", + &[("profile.rs", PROFILE_CIRCULAR)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("profile", "profile", false, true)], + vec![rel("profile", "HasOne", quote! { crate::models::profile::Schema }, false)], + &["crate", "models", "user"] + )] +#[case::non_circular_has_one_fk_optional( + "non_circular_has_one_fk_optional", + &[("address.rs", ADDRESS_FK)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("address", "address", false, true)], + vec![rel("address", "HasOne", quote! { crate::models::address::Schema }, true)], + &["crate", "models", "user"] + )] +#[case::non_circular_has_one_fk_required( + "non_circular_has_one_fk_required", + &[("address.rs", ADDRESS_FK)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("address", "address", false, true)], + vec![rel("address", "HasOne", quote! { crate::models::address::Schema }, false)], + &["crate", "models", "user"] + )] +#[case::has_many_circular( + "has_many_circular", + &[("memo.rs", MEMO_CIRCULAR)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("memos", "memos", false, true)], + vec![rel("memos", "HasMany", quote! { crate::models::memo::Schema }, false)], + &["crate", "models", "user"] + )] +#[case::has_many_fk_no_circular( + "has_many_fk_no_circular", + &[("tag.rs", TAG_FK)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("tags", "tags", false, true)], + vec![rel("tags", "HasMany", quote! { crate::models::tag::Schema }, false)], + &["crate", "models", "user"] + )] +#[case::inline_type_required_belongs_to( + "inline_type_required_belongs_to", + &[("user.rs", USER_PLAIN)], + "MemoSchema", "crate::models::memo::Model", + &[("id", "id", false, false), ("user", "user", false, true)], + vec![with_inline( + rel("user", "BelongsTo", quote! { crate::models::user::Schema }, false), + "MemoSchema_User", &["id", "name"], + )], + &["crate", "models", "memo"] + )] +#[case::parent_stub_all_relation_types( + "parent_stub_all_relation_types", + &[ + ("memo.rs", MEMO_REQUIRED_CIRCULAR), + ("profile.rs", PROFILE_PLAIN), + ("settings.rs", SETTINGS_PLAIN), + ], + "UserSchema", "crate::models::user::Model", + &[ + ("id", "id", false, false), + ("memos", "memos", false, true), + ("profile", "profile", false, true), + ("settings", "settings", false, true), + ("orphan_rel", "orphan_rel", false, true), + ], + vec![ + rel("memos", "HasMany", quote! { crate::models::memo::Schema }, false), + rel("profile", "HasOne", quote! { crate::models::profile::Schema }, true), + rel("settings", "BelongsTo", quote! { crate::models::settings::Schema }, false), + ], + &["crate", "models", "user"] + )] +#[case::has_many_via_rel_fk_found( + "has_many_via_rel_fk_found", + &[("notification.rs", NOTIFICATION_TARGET_USER)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("target_user_notifications", "target_user_notifications", false, true)], + vec![with_enum( + rel("target_user_notifications", "HasMany", quote! { crate::models::notification::Schema }, false), + None, None, Some("TargetUser"), + )], + &["crate", "models", "user"] + )] +#[case::has_many_via_rel_fk_not_found( + "has_many_via_rel_fk_not_found", + &[("notification.rs", NOTIFICATION_PLAIN)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("notifications", "notifications", false, true)], + vec![with_enum( + rel("notifications", "HasMany", quote! { crate::models::notification::Schema }, false), + None, None, Some("NonExistentRelation"), + )], + &["crate", "models", "user"] + )] +#[case::has_many_enum_fk_found( + "has_many_enum_fk_found", + &[("comment.rs", COMMENT_AUTHOR_ENUM)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("author_comments", "author_comments", false, true)], + vec![with_enum( + rel("author_comments", "HasMany", quote! { crate::models::comment::Schema }, false), + Some("AuthorComments"), None, None, + )], + &["crate", "models", "user"] + )] +#[case::has_many_enum_fk_not_found( + "has_many_enum_fk_not_found", + &[("post.rs", POST_PLAIN)], + "UserSchema", "crate::models::user::Model", + &[("id", "id", false, false), ("authored_posts", "authored_posts", false, true)], + vec![with_enum( + rel("authored_posts", "HasMany", quote! { crate::models::post::Schema }, false), + Some("NonExistentRelation"), None, None, + )], + &["crate", "models", "user"] + )] +#[serial] +fn generate_from_model_scenario_snapshot( + #[case] snapshot_name: &str, + #[case] models: &[(&str, &str)], + #[case] new_type: &str, + #[case] source_type: &str, + #[case] rows: &[MappingRow], + #[case] relations: Vec, + #[case] module: &[&str], +) { + insta::assert_snapshot!( + snapshot_name, + run_scenario(models, new_type, source_type, rows, &relations, module) + ); +} diff --git a/crates/vespera_macro/src/schema_macro/generate_type.rs b/crates/vespera_macro/src/schema_macro/generate_type.rs new file mode 100644 index 00000000..95b50e30 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/generate_type.rs @@ -0,0 +1,783 @@ +//! `schema_type!` code generation. +//! +//! Hosts `generate_schema_type_code` - the orchestrator that turns a +//! `SchemaTypeInput` (parsed `schema_type!` invocation) into the generated +//! struct, `From`/`from_model` impls, inline circular types, and metadata. + +use std::collections::HashMap; + +use proc_macro2::TokenStream; +use quote::quote; + +use super::defaults::generate_sea_orm_default_attrs; +use super::file_cache; +use super::file_lookup::find_struct_from_path_detailed; +use super::from_model::generate_from_model_with_relations; +use super::inline_types::{ + generate_inline_relation_type, generate_inline_relation_type_no_relations, + generate_inline_type_definition, +}; +use super::input::{PartialMode, SchemaTypeInput}; +use super::same_file_override::maybe_generate_same_file_relation_override; +use super::seaorm::{ + RelationFieldInfo, convert_relation_type_to_schema_with_info, convert_type_with_chrono, + extract_sea_orm_default_value, has_sea_orm_primary_key, +}; +use super::transformation::{ + build_omit_set, build_partial_config, build_pick_set, build_rename_map, determine_rename_all, + extract_doc_attrs, extract_field_serde_attrs, extract_form_data_attrs, + extract_serde_attrs_without_rename_all, filter_out_serde_rename, should_skip_field, + should_wrap_in_option, +}; +use super::type_utils::{ + extract_module_path, extract_type_name, is_option_type, is_seaorm_model, + is_seaorm_relation_type, +}; +use super::validation::{ + extract_source_field_names, validate_add_field_idents, validate_omit_fields, + validate_partial_fields, validate_pick_fields, validate_rename_fields, +}; +use crate::metadata::StructMetadata; +use crate::parser::{extract_field_rename, strip_raw_prefix_owned}; + +/// Generate a new struct type from an existing type with field filtering +/// +/// Returns (`TokenStream`, Option) where the metadata is returned +/// when a custom `name` is provided (for direct registration in `SCHEMA_STORAGE`). +#[allow(clippy::too_many_lines)] +pub fn generate_schema_type_code( + input: &SchemaTypeInput, + schema_storage: &HashMap, +) -> Result<(TokenStream, Option), syn::Error> { + // Extract type name from the source Type + let source_type_name = extract_type_name(&input.source_type)?; + + // Extract the module path for resolving relative paths in relation types + // This may be empty for simple names like `Model` - will be overridden below if found from file + let mut source_module_path = extract_module_path(&input.source_type); + + // Find struct definition - check SCHEMA_STORAGE first (no file I/O), + // fall back to file lookup for types not registered (e.g., SeaORM Model). + // + // The storage-then-file-lookup resolution is identical for qualified + // (`crate::models::user::Model`) and simple (`Model`) source paths, so a + // single branch serves both and `find_struct_from_path_detailed` is called + // exactly once and matched. The previous `else if let Ok(..) else match ..` + // shape re-ran the full directory candidate scan + file parse a SECOND time + // on a lookup miss purely to surface the error — wasted work that doubled + // the cost of every "struct not found" compile error. + // + // When the struct is found via the file, the module path derived from the + // actual file location overrides `source_module_path` so relative relation + // paths like `super::user::Entity` resolve correctly (crucial for simple + // names, more accurate than the parsed path for qualified ones). + let struct_def_owned: StructMetadata; + let schema_name_hint = input.schema_name.as_deref(); + let struct_def = if let Some(found) = schema_storage.get(&source_type_name) { + found + } else { + match find_struct_from_path_detailed(&input.source_type, schema_name_hint) { + Ok((found, module_path)) => { + struct_def_owned = found; + source_module_path = module_path; + &struct_def_owned + } + Err(err) => return Err(err.to_syn_error(&input.source_type)), + } + }; + + // Parse the struct definition + let parsed_struct: syn::ItemStruct = file_cache::parse_struct_cached(&struct_def.definition) + .map_err(|e| { + syn::Error::new_spanned( + &input.source_type, + format!("failed to parse struct definition for `{source_type_name}`: {e}"), + ) + })?; + + // Extract all field names from source struct for validation + // Include relation fields since they can be converted to Schema types + let source_field_names = extract_source_field_names(&parsed_struct); + + // Validate all field references exist in source struct + validate_pick_fields( + input.pick.as_ref(), + &source_field_names, + &input.source_type, + &source_type_name, + )?; + validate_omit_fields( + input.omit.as_ref(), + &source_field_names, + &input.source_type, + &source_type_name, + )?; + validate_rename_fields( + input.rename.as_ref(), + &source_field_names, + &input.source_type, + &source_type_name, + )?; + // `add` field names also become struct identifiers via `syn::Ident::new` + // downstream, so reject a non-identifier / keyword name here as a spanned + // error instead of panicking the proc-macro during expansion. + validate_add_field_idents(input.add.as_ref(), &input.source_type)?; + let partial_fields_to_validate = match &input.partial { + Some(PartialMode::Fields(fields)) => Some(fields), + _ => None, + }; + validate_partial_fields( + partial_fields_to_validate, + &source_field_names, + &input.source_type, + &source_type_name, + )?; + + // Build filter sets and rename map + let omit_set = build_omit_set(input.omit.as_ref()); + let pick_set = build_pick_set(input.pick.as_ref()); + let (partial_all, partial_set) = build_partial_config(&input.partial); + let rename_map = build_rename_map(input.rename.as_ref()); + + // Extract serde attributes from source struct, excluding rename_all (we'll handle it separately) + let serde_attrs_without_rename_all = + extract_serde_attrs_without_rename_all(&parsed_struct.attrs); + + // Extract doc comments from source struct to carry over to generated struct + let struct_doc_attrs = extract_doc_attrs(&parsed_struct.attrs); + + // Determine the effective rename_all strategy + let effective_rename_all = + determine_rename_all(input.rename_all.as_ref(), &parsed_struct.attrs); + + // Check if source is a SeaORM Model + let is_source_seaorm_model = is_seaorm_model(&parsed_struct); + + // Generate new struct with filtered fields + let new_type_name = &input.new_type; + // Pre-size the two dense per-field codegen vectors from the source field + // count so a many-field struct fills them without the Vec's early doubling + // reallocations (the previous `Vec::new()` reallocated at 1, 2, 4, 8, ...). + // The sparser relation / inline / default / override vectors below stay + // `Vec::new()`: they hold only the subset of fields that are relations or + // carry SeaORM defaults, so sizing them to the full field count would + // over-allocate for the common struct that has neither. + let field_capacity = match &parsed_struct.fields { + syn::Fields::Named(fields_named) => fields_named.named.len(), + _ => 0, + }; + let mut field_tokens = Vec::with_capacity(field_capacity); + // Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option, is_relation) + let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool, bool)> = + Vec::with_capacity(field_capacity); + // Track relation field info for from_model generation + let mut relation_fields: Vec = Vec::new(); + // Track inline types that need to be generated for circular relations + let mut inline_type_definitions: Vec = Vec::new(); + // Track default value functions generated from sea_orm(default_value) + let mut default_functions: Vec = Vec::new(); + // Track same-file relation override helpers + let mut relation_override_helpers: Vec = Vec::new(); + + if let syn::Fields::Named(fields_named) = &parsed_struct.fields { + for field in &fields_named.named { + let rust_field_name = field.ident.as_ref().map_or_else( + || "unknown".to_string(), + |i| strip_raw_prefix_owned(i.to_string()), + ); + + // Apply omit/pick filters + if should_skip_field(&rust_field_name, &omit_set, &pick_set) { + continue; + } + + // Apply omit_default: skip fields with sea_orm(default_value) or sea_orm(primary_key) + if input.omit_default + && (extract_sea_orm_default_value(&field.attrs).is_some() + || has_sea_orm_primary_key(&field.attrs)) + { + continue; + } + + // Check if this is a SeaORM relation type + let is_relation = is_seaorm_relation_type(&field.ty); + + // In multipart mode, skip ALL relation fields (multipart forms can't represent nested objects) + if input.multipart && is_relation { + continue; + } + + // Get field components, applying partial wrapping if needed + let original_ty = &field.ty; + let should_wrap_option = should_wrap_in_option( + &rust_field_name, + partial_all, + &partial_set, + is_option_type(original_ty), + is_relation, + ); + + // Determine field type: convert relation types to Schema types + let (field_ty, relation_info): (Box, Option) = + if is_relation { + // Convert HasOne/HasMany/BelongsTo to Schema type + if let Some((converted, mut rel_info)) = + convert_relation_type_to_schema_with_info( + original_ty, + &field.attrs, + &parsed_struct, + &source_module_path, + field.ident.clone().unwrap(), + ) + { + // NEW RULE: HasMany (reverse references) are excluded by default + // They can only be included via explicit `pick` + if rel_info.relation_type == "HasMany" { + // HasMany is only included if explicitly picked + if !pick_set.contains(&rust_field_name) { + continue; + } + // When HasMany IS picked, generate inline type with ALL relations stripped + if let Some(inline_type) = generate_inline_relation_type_no_relations( + new_type_name, + &rel_info, + &source_module_path, + input.schema_name.as_deref(), + ) { + let inline_type_def = generate_inline_type_definition(&inline_type); + inline_type_definitions.push(inline_type_def); + + let inline_type_name = &inline_type.type_name; + let included_fields: Vec = inline_type + .fields + .iter() + .map(|f| f.name.to_string()) + .collect(); + + rel_info.inline_type_info = + Some((inline_type.type_name.clone(), included_fields)); + + let inline_field_ty = quote! { Vec<#inline_type_name> }; + (Box::new(inline_field_ty), Some(rel_info)) + } else { + if pick_set.contains(&rust_field_name) { + return Err(syn::Error::new_spanned( + field, + format!( + "schema_type!: relation field `{rust_field_name}` was explicitly picked but its inline relation type could not be generated" + ), + )); + } + continue; + } + } else { + // BelongsTo/HasOne: Include by default + if input.add.is_some() + && let Some((override_field_ty, helper_tokens)) = + maybe_generate_same_file_relation_override( + new_type_name, + &rust_field_name, + &rel_info, + schema_storage, + )? + { + relation_override_helpers.push(helper_tokens); + (Box::new(override_field_ty), Some(rel_info)) + } else + // Check for circular references and potentially use inline type + if let Some(inline_type) = generate_inline_relation_type( + new_type_name, + &rel_info, + &source_module_path, + input.schema_name.as_deref(), + ) { + // Generate inline type definition + let inline_type_def = generate_inline_type_definition(&inline_type); + inline_type_definitions.push(inline_type_def); + + // Use inline type instead of direct schema reference + let inline_type_name = &inline_type.type_name; + let circular_fields: Vec = inline_type + .fields + .iter() + .map(|f| f.name.to_string()) + .collect(); + + // Store inline type info + rel_info.inline_type_info = + Some((inline_type.type_name.clone(), circular_fields)); + + // Generate field type using inline type + let inline_field_ty = if rel_info.is_optional { + quote! { Option> } + } else { + quote! { Box<#inline_type_name> } + }; + + (Box::new(inline_field_ty), Some(rel_info)) + } else { + // No circular refs, use original schema path + (Box::new(converted), Some(rel_info)) + } + } + } else { + if pick_set.contains(&rust_field_name) { + return Err(syn::Error::new_spanned( + field, + format!( + "schema_type!: relation field `{rust_field_name}` was explicitly picked but its SeaORM relation type could not be converted" + ), + )); + } + // Fallback: skip if conversion fails + continue; + } + } else { + // Convert SeaORM datetime types to chrono equivalents + // Also resolves local types to absolute paths + let converted_ty = convert_type_with_chrono(original_ty, &source_module_path); + if should_wrap_option { + (Box::new(quote! { Option<#converted_ty> }), None) + } else { + (Box::new(converted_ty), None) + } + }; + + // Collect relation info — `.extend(...)` keeps the push site + // out of an explicit closure so the coverage tracker + // attributes the call to this source line. + relation_fields.extend(relation_info); + let vis: &syn::Visibility = &field.vis; + let source_field_ident: syn::Ident = field.ident.clone().unwrap(); + + // Extract doc attributes to carry over comments to the generated struct + let doc_attrs = extract_doc_attrs(&field.attrs); + + if input.multipart { + // Multipart mode: emit form_data attrs, suppress serde attrs + let form_data_attrs = extract_form_data_attrs(&field.attrs); + + // Check if field should be renamed (rename still applies to Rust field names) + if let Some(new_name) = rename_map.get(&rust_field_name) { + let new_field_ident = + syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#form_data_attrs)* + #vis #new_field_ident: #field_ty + }); + + field_mappings.push(( + new_field_ident, + source_field_ident, + should_wrap_option, + is_relation, + )); + } else { + let field_ident = field.ident.clone().unwrap(); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#form_data_attrs)* + #vis #field_ident: #field_ty + }); + + field_mappings.push(( + field_ident.clone(), + field_ident, + should_wrap_option, + is_relation, + )); + } + } else { + // Normal (serde) mode: emit serde attrs + // Filter field attributes: keep serde and doc attributes, remove sea_orm and others + // This is important when using schema_type! with models from other files + // that may have ORM-specific attributes we don't want in the generated struct + let serde_field_attrs = extract_field_serde_attrs(&field.attrs); + + // Generate serde default + schema(default) from sea_orm(default_value) or primary_key + // Handles literal defaults, SQL function defaults, and implicit auto-increment + let (serde_default_attr, schema_default_attr): ( + proc_macro2::TokenStream, + proc_macro2::TokenStream, + ) = generate_sea_orm_default_attrs( + &field.attrs, + new_type_name, + &rust_field_name, + original_ty, + &field_ty, + should_wrap_option || is_option_type(original_ty), + &mut default_functions, + ); + + // Check if field should be renamed + if let Some(new_name) = rename_map.get(&rust_field_name) { + // Create new identifier for the field + let new_field_ident: syn::Ident = + syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); + + // Filter out serde(rename) attributes from the serde attrs + let filtered_attrs = filter_out_serde_rename(&serde_field_attrs); + + // Determine the JSON name: use existing serde(rename) if present, otherwise rust field name + let json_name = extract_field_rename(&field.attrs) + .unwrap_or_else(|| rust_field_name.clone()); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#filtered_attrs)* + #serde_default_attr + #schema_default_attr + #[serde(rename = #json_name)] + #vis #new_field_ident: #field_ty + }); + + // Track mapping: new field name <- source field name + field_mappings.push(( + new_field_ident, + source_field_ident, + should_wrap_option, + is_relation, + )); + } else { + // No rename, keep field with serde and doc attrs + let field_ident = field.ident.clone().unwrap(); + + field_tokens.push(quote! { + #(#doc_attrs)* + #(#serde_field_attrs)* + #serde_default_attr + #schema_default_attr + #vis #field_ident: #field_ty + }); + + // Track mapping: same name + field_mappings.push(( + field_ident.clone(), + field_ident, + should_wrap_option, + is_relation, + )); + } + } + } + } + + // Add new fields from `add` parameter + for (field_name, field_ty) in input.add.iter().flatten() { + let field_ident: syn::Ident = syn::Ident::new(field_name, proc_macro2::Span::call_site()); + field_tokens.push(quote! { + pub #field_ident: #field_ty + }); + } + + // Build derive list + // In multipart mode, force clone = false (FieldData doesn't implement Clone) + let derive_clone: bool = if input.multipart { + false + } else { + input.derive_clone + }; + let clone_derive: proc_macro2::TokenStream = if derive_clone { + quote! { Clone, } + } else { + quote! {} + }; + + // Conditionally include Schema derive based on ignore_schema flag + // Also generate #[schema(name = "...")] attribute if custom name is provided AND Schema is derived + let schema_derive: proc_macro2::TokenStream; + let schema_name_attr: proc_macro2::TokenStream; + if input.ignore_schema { + schema_derive = quote! {}; + schema_name_attr = quote! {}; + } else if let Some(ref name) = input.schema_name { + schema_derive = quote! { vespera::Schema }; + schema_name_attr = quote! { #[schema(name = #name)] }; + } else { + schema_derive = quote! { vespera::Schema }; + schema_name_attr = quote! {}; + } + + // Check if there are any relation fields + let has_relation_fields = field_mappings.iter().any(|(_, _, _, is_rel)| *is_rel); + + // In multipart mode, skip From and from_model impls entirely + let source_type: &syn::Type = &input.source_type; + let (from_impl, from_model_impl) = if input.multipart { + (quote! {}, quote! {}) + } else { + // Generate From impl only if: + // 1. `add` is not used (can't auto-populate added fields) + // 2. There are no relation fields (relation fields don't exist on source Model) + let from_impl = if input.add.is_none() && !has_relation_fields { + let field_assignments: Vec<_> = field_mappings + .iter() + .map(|(new_ident, source_ident, wrapped, _is_relation)| { + if *wrapped { + quote! { #new_ident: Some(source.#source_ident) } + } else { + quote! { #new_ident: source.#source_ident } + } + }) + .collect(); + + quote! { + impl From<#source_type> for #new_type_name { + fn from(source: #source_type) -> Self { + Self { + #(#field_assignments),* + } + } + } + } + } else { + quote! {} + }; + + // Generate from_model impl for SeaORM Models WITH relations + // - No relations: Use `From` trait (generated above) + // - Has relations: async fn from_model(model: Model, db: &DatabaseConnection) -> Result + let from_model_impl = + if is_source_seaorm_model && input.add.is_none() && has_relation_fields { + generate_from_model_with_relations( + new_type_name, + source_type, + &field_mappings, + &relation_fields, + &source_module_path, + schema_storage, + ) + } else { + quote! {} + }; + + (from_impl, from_model_impl) + }; + + // Generate the new struct (with inline types for circular relations first) + let generated_tokens: proc_macro2::TokenStream = if input.multipart { + // Multipart mode: derive Multipart instead of serde + // Emit #[serde(rename_all = ...)] so Multipart applies the rename at runtime + // AND Schema derive reads it via extract_rename_all() fallback for OpenAPI field naming + quote! { + #(#inline_type_definitions)* + + #(#struct_doc_attrs)* + #[derive(vespera::Multipart, #clone_derive #schema_derive)] + #schema_name_attr + #[serde(rename_all = #effective_rename_all)] + pub struct #new_type_name { + #(#field_tokens),* + } + } + } else { + // Normal serde mode + quote! { + // Inline types for circular relation references + #(#inline_type_definitions)* + + // Same-file relation override helpers + #(#relation_override_helpers)* + + // Default value functions for sea_orm(default_value) fields + #(#default_functions)* + + #(#struct_doc_attrs)* + #[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)] + #schema_name_attr + #[serde(rename_all = #effective_rename_all)] + #(#serde_attrs_without_rename_all)* + pub struct #new_type_name { + #(#field_tokens),* + } + + #from_impl + #from_model_impl + } + }; + + // If custom name is provided, create metadata for direct registration + // This ensures the schema appears in OpenAPI even when `ignore` is set + let metadata = input.schema_name.as_ref().map(|custom_name| { + // Build struct definition string for metadata (without derives/attrs for parsing) + let struct_def = quote! { + #[serde(rename_all = #effective_rename_all)] + #(#serde_attrs_without_rename_all)* + pub struct #new_type_name { + #(#field_tokens),* + } + }; + StructMetadata::new(custom_name.clone(), struct_def.to_string()) + }); + + Ok((generated_tokens, metadata)) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { + StructMetadata::new(name.to_string(), definition.to_string()) + } + + fn to_storage(items: Vec) -> HashMap { + items.into_iter().map(|s| (s.name.clone(), s)).collect() + } + + #[test] + fn test_generate_schema_type_code_multipart_with_add_and_custom_name() { + let storage = to_storage(vec![create_test_struct_metadata( + "Upload", + "pub struct Upload { pub id: i32, pub name: String }", + )]); + + let tokens = quote!( + UploadForm from Upload, + multipart, + name = "UploadFormSchema", + add = [("extra": String)] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("vespera :: Multipart")); + assert!(output.contains("extra")); + assert!(output.contains("UploadFormSchema")); + assert_eq!(metadata.unwrap().name, "UploadFormSchema"); + } + // ============================================================ + // Tests for multipart mode + // ============================================================ + + #[test] + fn test_generate_schema_type_code_multipart_basic() { + // Tests: multipart mode generates Multipart derive, suppresses From impl + let storage = to_storage(vec![create_test_struct_metadata( + "UploadRequest", + "pub struct UploadRequest { pub name: String, pub description: Option }", + )]); + + let tokens = quote!(PatchUpload from UploadRequest, multipart); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should derive Multipart + assert!(output.contains("Multipart")); + // Should NOT have From impl (multipart suppresses it) + assert!(!output.contains("impl From")); + // Should have the struct fields + assert!(output.contains("name")); + assert!(output.contains("description")); + } + + #[test] + fn test_generate_schema_type_code_multipart_with_rename() { + // Tests: multipart mode with field rename + let storage = to_storage(vec![create_test_struct_metadata( + "UploadRequest", + "pub struct UploadRequest { pub name: String, pub file_path: String }", + )]); + + let tokens = quote!(RenamedUpload from UploadRequest, multipart, rename = [("file_path", "document_path")]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should derive Multipart + assert!(output.contains("Multipart")); + // Should have renamed field + assert!(output.contains("document_path")); + // Original name should NOT appear as field + assert!(!output.contains("file_path")); + } + + #[test] + fn test_generate_schema_type_code_multipart_with_form_data_attrs() { + // Tests: multipart mode preserves #[form_data] attributes from source + let storage = to_storage(vec![create_test_struct_metadata( + "UploadRequest", + r#"pub struct UploadRequest { + pub name: String, + #[form_data(limit = "10MiB")] + pub file: String + }"#, + )]); + + let tokens = quote!(PatchUpload from UploadRequest, multipart); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should preserve form_data attributes + assert!(output.contains("form_data")); + assert!(output.contains("limit")); + } + + #[test] + fn test_generate_schema_type_code_multipart_skips_relations() { + // Tests: multipart mode skips relation fields + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "memos")] + pub struct Model { + pub id: i32, + pub title: String, + pub user: BelongsTo + }"#, + )]); + + let tokens = quote!(MemoUpload from Model, multipart); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Relation field should be skipped in multipart mode + assert!(!output.contains("user")); + // Regular fields should be present + assert!(output.contains("id")); + assert!(output.contains("title")); + // Should derive Multipart + assert!(output.contains("Multipart")); + } + + #[test] + fn test_generate_schema_type_code_multipart_partial() { + // Coverage for multipart + partial combination + let storage = to_storage(vec![create_test_struct_metadata( + "UploadRequest", + "pub struct UploadRequest { pub name: String, pub tags: String }", + )]); + + let tokens = quote!(PatchUpload from UploadRequest, multipart, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should derive Multipart + assert!(output.contains("Multipart")); + // Fields should be wrapped in Option (partial) + assert!(output.contains("Option")); + // Should NOT have From impl + assert!(!output.contains("impl From")); + } +} diff --git a/crates/vespera_macro/src/schema_macro/inline_types.rs b/crates/vespera_macro/src/schema_macro/inline_types.rs index cbf13496..18c275c2 100644 --- a/crates/vespera_macro/src/schema_macro/inline_types.rs +++ b/crates/vespera_macro/src/schema_macro/inline_types.rs @@ -260,939 +260,4 @@ pub fn generate_inline_type_definition(inline_type: &InlineRelationType) -> Toke } #[cfg(test)] -mod tests { - use serial_test::serial; - - use super::*; - - #[test] - fn test_generate_inline_type_definition() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("UserInline", proc_macro2::Span::call_site()), - fields: vec![ - InlineField { - name: syn::Ident::new("id", proc_macro2::Span::call_site()), - ty: quote!(i32), - attrs: vec![], - }, - InlineField { - name: syn::Ident::new("name", proc_macro2::Span::call_site()), - ty: quote!(String), - attrs: vec![], - }, - ], - rename_all: "camelCase".to_string(), - }; - - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); - - assert!(output.contains("pub struct UserInline")); - assert!(output.contains("pub id : i32")); - assert!(output.contains("pub name : String")); - assert!(output.contains("serde :: Serialize")); - assert!(output.contains("serde :: Deserialize")); - assert!(output.contains("vespera :: Schema")); - assert!(output.contains("camelCase")); - } - - #[test] - fn test_generate_inline_type_definition_with_attrs() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("TestType", proc_macro2::Span::call_site()), - fields: vec![InlineField { - name: syn::Ident::new("field", proc_macro2::Span::call_site()), - ty: quote!(String), - attrs: vec![syn::parse_quote!(#[serde(rename = "renamed")])], - }], - rename_all: "snake_case".to_string(), - }; - - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); - - assert!(output.contains("TestType")); - assert!(output.contains("snake_case")); - } - - #[test] - fn test_generate_inline_type_definition_empty_fields() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("EmptyType", proc_macro2::Span::call_site()), - fields: vec![], - rename_all: "camelCase".to_string(), - }; - - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); - - assert!(output.contains("pub struct EmptyType")); - assert!(output.contains("Clone")); - assert!(output.contains("vespera :: Schema")); - } - - #[test] - fn test_generate_inline_type_definition_multiple_attrs() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("MultiAttrType", proc_macro2::Span::call_site()), - fields: vec![InlineField { - name: syn::Ident::new("field", proc_macro2::Span::call_site()), - ty: quote!(String), - attrs: vec![ - syn::parse_quote!(#[serde(default)]), - syn::parse_quote!(#[serde(skip_serializing_if = "Option::is_none")]), - ], - }], - rename_all: "PascalCase".to_string(), - }; - - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); - - assert!(output.contains("MultiAttrType")); - assert!(output.contains("PascalCase")); - assert!(output.contains("default")); - } - - #[test] - fn test_generate_inline_type_definition_complex_type() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("ComplexType", proc_macro2::Span::call_site()), - fields: vec![ - InlineField { - name: syn::Ident::new("id", proc_macro2::Span::call_site()), - ty: quote!(i32), - attrs: vec![], - }, - InlineField { - name: syn::Ident::new("tags", proc_macro2::Span::call_site()), - ty: quote!(Vec), - attrs: vec![], - }, - InlineField { - name: syn::Ident::new("metadata", proc_macro2::Span::call_site()), - ty: quote!(Option>), - attrs: vec![], - }, - ], - rename_all: "camelCase".to_string(), - }; - - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); - - assert!(output.contains("pub struct ComplexType")); - assert!(output.contains("pub id : i32")); - assert!(output.contains("Vec < String >")); - assert!(output.contains("Option <")); - } - - #[test] - fn test_inline_field_struct() { - // Test InlineField struct construction - let field = InlineField { - name: syn::Ident::new("test_field", proc_macro2::Span::call_site()), - ty: quote!(Option), - attrs: vec![syn::parse_quote!(#[doc = "Test doc"])], - }; - - assert_eq!(field.name.to_string(), "test_field"); - assert!(!field.attrs.is_empty()); - } - - #[test] - fn test_inline_relation_type_struct() { - // Test InlineRelationType struct construction - let inline_type = InlineRelationType { - type_name: syn::Ident::new("TestRelation", proc_macro2::Span::call_site()), - fields: vec![], - rename_all: "SCREAMING_SNAKE_CASE".to_string(), - }; - - assert_eq!(inline_type.type_name.to_string(), "TestRelation"); - assert_eq!(inline_type.rename_all, "SCREAMING_SNAKE_CASE"); - assert!(inline_type.fields.is_empty()); - } - - #[test] - fn test_generate_inline_type_definition_doc_attr() { - let inline_type = InlineRelationType { - type_name: syn::Ident::new("DocType", proc_macro2::Span::call_site()), - fields: vec![InlineField { - name: syn::Ident::new("documented_field", proc_macro2::Span::call_site()), - ty: quote!(String), - attrs: vec![syn::parse_quote!(#[doc = "This is a documented field"])], - }], - rename_all: "camelCase".to_string(), - }; - - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); - - assert!(output.contains("DocType")); - assert!(output.contains("documented_field")); - assert!(output.contains("doc")); - } - - #[test] - fn test_generate_inline_relation_type_from_def_with_circular() { - // Test inline type generation when circular reference exists - let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - // UserSchema has a circular reference back to memo via HasMany - let model_def = r"pub struct Model { - pub id: i32, - pub name: String, - pub memos: HasMany - }"; - - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, - None, - model_def, - ); - // HasMany is not considered circular, so should return None - assert!(result.is_none()); - - // Test with BelongsTo instead (which IS considered circular) - let model_def_with_belongs_to = r"pub struct Model { - pub id: i32, - pub name: String, - pub memo: BelongsTo - }"; - - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, - None, - model_def_with_belongs_to, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - assert_eq!(inline_type.type_name.to_string(), "MemoSchema_User"); - // Should have id and name fields, but NOT memo (circular) - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"name".to_string())); - assert!(!field_names.contains(&"memo".to_string())); - } - - #[test] - fn test_generate_inline_relation_type_from_def_no_circular() { - // Test that None is returned when no circular reference exists - let parent_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("other", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::other::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "test".to_string(), - ]; - - // No circular reference - let model_def = r"pub struct Model { - pub id: i32, - pub name: String - }"; - - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, - None, - model_def, - ); - assert!(result.is_none()); // No circular fields means no inline type needed - } - - #[test] - fn test_generate_inline_relation_type_from_def_with_schema_name_override() { - let parent_type_name = syn::Ident::new("Schema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let model_def = r"pub struct Model { - pub id: i32, - pub memo: BelongsTo - }"; - - // With schema_name_override - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, - Some("MemoSchema"), - model_def, - ); - assert!(result.is_some()); - assert_eq!(result.unwrap().type_name.to_string(), "MemoSchema_User"); - } - - #[test] - fn test_generate_inline_relation_type_no_relations_from_def() { - let parent_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("memos", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(super::memo::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - // Model with relations that should be stripped - let model_def = r"pub struct Model { - pub id: i32, - pub title: String, - pub user: BelongsTo, - pub comments: HasMany - }"; - - let result = generate_inline_relation_type_no_relations_from_def( - &parent_type_name, - &rel_info, - &[], - None, - model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - assert_eq!(inline_type.type_name.to_string(), "UserSchema_Memos"); - - // Should have id and title, but NOT user or comments (relations) - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"title".to_string())); - assert!(!field_names.contains(&"user".to_string())); - assert!(!field_names.contains(&"comments".to_string())); - } - - #[test] - fn test_generate_inline_relation_type_no_relations_from_def_with_skip() { - let parent_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("items", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(super::item::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - // Model with serde(skip) field - let model_def = r"pub struct Model { - pub id: i32, - #[serde(skip)] - pub internal: String, - pub name: String - }"; - - let result = generate_inline_relation_type_no_relations_from_def( - &parent_type_name, - &rel_info, - &[], - None, - model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"name".to_string())); - assert!(!field_names.contains(&"internal".to_string())); // skipped - } - - #[test] - fn test_generate_inline_relation_type_from_def_invalid_model() { - let parent_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec!["crate".to_string()]; - - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, - None, - "invalid rust code", - ); - assert!(result.is_none()); - } - - #[test] - fn test_generate_inline_relation_type_from_def_skips_relation_types() { - // Test that relation types (HasOne, HasMany, BelongsTo) are skipped - let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - // Model with circular field AND other relation types that should be skipped - let model_def = r"pub struct Model { - pub id: i32, - pub name: String, - pub memo: BelongsTo, - pub posts: HasMany, - pub profile: HasOne - }"; - - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, - None, - model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - - // Should have id and name - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"name".to_string())); - // Should NOT have any relation fields (circular or otherwise) - assert!(!field_names.contains(&"memo".to_string())); // circular - assert!(!field_names.contains(&"posts".to_string())); // HasMany - relation type - assert!(!field_names.contains(&"profile".to_string())); // HasOne - relation type - } - - #[test] - fn test_generate_inline_relation_type_from_def_skips_serde_skip() { - // Test that fields with serde(skip) are skipped - let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - // Model with circular field AND serde(skip) field - let model_def = r"pub struct Model { - pub id: i32, - #[serde(skip)] - pub internal_cache: String, - pub name: String, - pub memo: BelongsTo - }"; - - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, - None, - model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - - // Should have id and name - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"name".to_string())); - // Should NOT have skipped or circular fields - assert!(!field_names.contains(&"internal_cache".to_string())); // serde(skip) - assert!(!field_names.contains(&"memo".to_string())); // circular - } - - #[test] - fn test_generate_inline_relation_type_no_relations_from_def_with_schema_name_override() { - // Test schema_name_override Some branch - let parent_type_name = syn::Ident::new("Schema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("memos", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(super::memo::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - let model_def = r"pub struct Model { - pub id: i32, - pub title: String - }"; - - // With schema_name_override - let result = generate_inline_relation_type_no_relations_from_def( - &parent_type_name, - &rel_info, - &[], - Some("UserSchema"), - model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - // Should use the override name, not the struct name - assert_eq!(inline_type.type_name.to_string(), "UserSchema_Memos"); - } - - // Tests for public functions with file lookup - // These require setting up a temp directory with model files - - #[test] - #[serial] - fn test_generate_inline_relation_type_with_file_lookup() { - use tempfile::TempDir; - - // Create temp directory structure - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create a user.rs file with Model struct that has circular reference - let user_model = r" -pub struct Model { - pub id: i32, - pub name: String, - pub memo: BelongsTo, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR and set to temp dir - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Test generate_inline_relation_type - let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(crate::models::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - let result = - generate_inline_relation_type(&parent_type_name, &rel_info, &source_module_path, None); - - // Restore original CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - // Verify result - assert!(result.is_some()); - let inline_type = result.unwrap(); - assert_eq!(inline_type.type_name.to_string(), "MemoSchema_User"); - - // Should have id and name, but not memo (circular) - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"name".to_string())); - assert!(!field_names.contains(&"memo".to_string())); - } - - #[test] - #[serial] - fn test_generate_inline_relation_type_no_relations_with_file_lookup() { - use tempfile::TempDir; - - // Create temp directory structure - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create a memo.rs file with Model struct that has relations - let memo_model = r" -pub struct Model { - pub id: i32, - pub title: String, - pub user: BelongsTo, - pub comments: HasMany, -} -"; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR and set to temp dir - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Test generate_inline_relation_type_no_relations - let parent_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("memos", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(crate::models::memo::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let result = generate_inline_relation_type_no_relations( - &parent_type_name, - &rel_info, - &source_module_path, - None, - ); - - // Restore original CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - // Verify result - assert!(result.is_some()); - let inline_type = result.unwrap(); - assert_eq!(inline_type.type_name.to_string(), "UserSchema_Memos"); - - // Should have id and title, but not user or comments (relations) - let field_names: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - assert!(field_names.contains(&"id".to_string())); - assert!(field_names.contains(&"title".to_string())); - assert!(!field_names.contains(&"user".to_string())); - assert!(!field_names.contains(&"comments".to_string())); - } - - #[test] - #[serial] - fn test_generate_inline_relation_type_file_not_found() { - use tempfile::TempDir; - - // Create temp directory structure without the model file - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - std::fs::create_dir_all(&src_dir).unwrap(); - - // Save original CARGO_MANIFEST_DIR and set to temp dir - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let parent_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(crate::models::nonexistent::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec!["crate".to_string()]; - - let result = - generate_inline_relation_type(&parent_type_name, &rel_info, &source_module_path, None); - - // Restore original CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - // Should return None when file not found - assert!(result.is_none()); - } - - #[test] - #[serial] - fn test_generate_inline_relation_type_no_relations_file_not_found() { - use tempfile::TempDir; - - // Create temp directory structure without the model file - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - std::fs::create_dir_all(&src_dir).unwrap(); - - // Save original CARGO_MANIFEST_DIR and set to temp dir - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let parent_type_name = syn::Ident::new("TestSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("items", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(crate::models::nonexistent::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - let result = - generate_inline_relation_type_no_relations(&parent_type_name, &rel_info, &[], None); - - // Restore original CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - // Should return None when file not found - assert!(result.is_none()); - } - - #[test] - fn test_generate_inline_relation_type_converts_datetime_types() { - // Test that DateTimeWithTimeZone is converted to vespera::chrono::DateTime - let parent_type_name = syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "BelongsTo".to_string(), - schema_path: quote!(super::user::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - let source_module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - - // Model with DateTimeWithTimeZone field AND circular reference - let model_def = r"pub struct Model { - pub id: i32, - pub name: String, - pub created_at: DateTimeWithTimeZone, - pub memo: BelongsTo - }"; - - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, - None, - model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - assert_eq!(inline_type.type_name.to_string(), "MemoSchema_User"); - - // Find created_at field and check its type was converted - let created_at_field = inline_type - .fields - .iter() - .find(|f| f.name == "created_at") - .expect("created_at field should exist"); - - let ty_str = created_at_field.ty.to_string(); - // Should be converted to vespera::chrono::DateTime - assert!( - ty_str.contains("vespera :: chrono :: DateTime"), - "DateTimeWithTimeZone should be converted to vespera::chrono::DateTime, got: {ty_str}" - ); - assert!( - ty_str.contains("FixedOffset"), - "Should contain FixedOffset, got: {ty_str}" - ); - } - - #[test] - fn test_generate_inline_relation_type_no_relations_converts_datetime_types() { - // Test that DateTimeWithTimeZone is converted in no_relations variant too - let parent_type_name = syn::Ident::new("UserSchema", proc_macro2::Span::call_site()); - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("memos", proc_macro2::Span::call_site()), - relation_type: "HasMany".to_string(), - schema_path: quote!(super::memo::Schema), - is_optional: false, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - // Model with DateTimeWithTimeZone field - let model_def = r"pub struct Model { - pub id: i32, - pub title: String, - pub created_at: DateTimeWithTimeZone, - pub updated_at: Option, - pub user: BelongsTo - }"; - - let result = generate_inline_relation_type_no_relations_from_def( - &parent_type_name, - &rel_info, - &[], - None, - model_def, - ); - assert!(result.is_some()); - - let inline_type = result.unwrap(); - - // Find created_at field and check its type was converted - let created_at_field = inline_type - .fields - .iter() - .find(|f| f.name == "created_at") - .expect("created_at field should exist"); - - let ty_str = created_at_field.ty.to_string(); - assert!( - ty_str.contains("vespera :: chrono :: DateTime"), - "DateTimeWithTimeZone should be converted, got: {ty_str}" - ); - - // Also check Option - let updated_at_field = inline_type - .fields - .iter() - .find(|f| f.name == "updated_at") - .expect("updated_at field should exist"); - - let updated_ty_str = updated_at_field.ty.to_string(); - assert!( - updated_ty_str.contains("Option"), - "Should be Option type, got: {updated_ty_str}" - ); - assert!( - updated_ty_str.contains("vespera :: chrono :: DateTime"), - "Option should be converted, got: {updated_ty_str}" - ); - } -} +mod tests; diff --git a/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__complex_field_types.snap b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__complex_field_types.snap new file mode 100644 index 00000000..b4afc195 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__complex_field_types.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct ComplexType { + pub id: i32, + pub tags: Vec, + pub metadata: Option>, +} diff --git a/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__doc_attribute.snap b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__doc_attribute.snap new file mode 100644 index 00000000..e1bd12fe --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__doc_attribute.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct DocType { + ///This is a documented field + pub documented_field: String, +} diff --git a/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__empty_fields.snap b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__empty_fields.snap new file mode 100644 index 00000000..ac96ae4c --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__empty_fields.snap @@ -0,0 +1,7 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct EmptyType {} diff --git a/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__field_attr_rename_snake_case.snap b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__field_attr_rename_snake_case.snap new file mode 100644 index 00000000..56cdcd6e --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__field_attr_rename_snake_case.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "snake_case")] +pub struct TestType { + #[serde(rename = "renamed")] + pub field: String, +} diff --git a/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__from_def_created_at_type.snap b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__from_def_created_at_type.snap new file mode 100644 index 00000000..50eaff15 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__from_def_created_at_type.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: created_at.ty.to_string() +--- +vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > diff --git a/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__multiple_field_attrs_pascal_case.snap b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__multiple_field_attrs_pascal_case.snap new file mode 100644 index 00000000..b24b0142 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__multiple_field_attrs_pascal_case.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "PascalCase")] +pub struct MultiAttrType { + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub field: String, +} diff --git a/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__no_relations_datetime_types.snap b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__no_relations_datetime_types.snap new file mode 100644 index 00000000..01f0c548 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__no_relations_datetime_types.snap @@ -0,0 +1,6 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: "format!(\"created_at: {}\\nupdated_at: {}\", ty_of(\"created_at\"),\nty_of(\"updated_at\"),)" +--- +created_at: vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > +updated_at: Option < vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > > diff --git a/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__two_plain_fields_camel_case.snap b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__two_plain_fields_camel_case.snap new file mode 100644 index 00000000..6c3ef5c5 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/inline_types/snapshots/vespera_macro__schema_macro__inline_types__tests__two_plain_fields_camel_case.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespera_macro/src/schema_macro/inline_types/tests.rs +expression: pretty(&generate_inline_type_definition(&inline_type)) +--- +#[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct UserInline { + pub id: i32, + pub name: String, +} diff --git a/crates/vespera_macro/src/schema_macro/inline_types/tests.rs b/crates/vespera_macro/src/schema_macro/inline_types/tests.rs new file mode 100644 index 00000000..cc22cc19 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/inline_types/tests.rs @@ -0,0 +1,548 @@ +use rstest::rstest; +use serial_test::serial; + +use super::*; + +// ── Test support ───────────────────────────────────────────────────── + +/// Render generated item tokens as formatted Rust source so snapshots +/// review like real code instead of a single token-soup line. +fn pretty(tokens: &proc_macro2::TokenStream) -> String { + let file: syn::File = + syn::parse2(tokens.clone()).expect("generated tokens must parse as Rust items"); + prettyplease::unparse(&file) +} + +/// Compact [`InlineField`] constructor for table-driven cases. +fn field(name: &str, ty: proc_macro2::TokenStream, attrs: Vec) -> InlineField { + InlineField { + name: syn::Ident::new(name, proc_macro2::Span::call_site()), + ty, + attrs, + } +} + +/// Compact [`InlineRelationType`] constructor for table-driven cases. +fn inline(name: &str, rename_all: &str, fields: Vec) -> InlineRelationType { + InlineRelationType { + type_name: syn::Ident::new(name, proc_macro2::Span::call_site()), + fields, + rename_all: rename_all.to_string(), + } +} + +/// Compact [`RelationFieldInfo`] constructor — the original tests +/// repeated this 10-line struct literal a dozen times. +fn rel( + field_name: &str, + relation_type: &str, + schema_path: proc_macro2::TokenStream, +) -> RelationFieldInfo { + RelationFieldInfo { + field_name: syn::Ident::new(field_name, proc_macro2::Span::call_site()), + relation_type: relation_type.to_string(), + schema_path, + is_optional: false, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + } +} + +/// Sorted field names of a generated inline type — list equality +/// asserts both inclusions and exclusions in one comparison. +fn field_names(inline_type: &InlineRelationType) -> Vec { + let mut names: Vec = inline_type + .fields + .iter() + .map(|f| f.name.to_string()) + .collect(); + names.sort(); + names +} + +const MEMO_MODULE: [&str; 3] = ["crate", "models", "memo"]; + +fn module_path(segments: &[&str]) -> Vec { + segments.iter().map(ToString::to_string).collect() +} + +/// Run `body` with `CARGO_MANIFEST_DIR` pointing at `dir`, restoring +/// the original value afterwards. +fn with_manifest_dir(dir: &std::path::Path, body: impl FnOnce() -> T) -> T { + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: callers are #[serial] tests — no concurrent env access. + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", dir) }; + let result = body(); + // SAFETY: same as above. + unsafe { + match original { + Some(value) => std::env::set_var("CARGO_MANIFEST_DIR", value), + None => std::env::remove_var("CARGO_MANIFEST_DIR"), + } + } + result +} + +// ── generate_inline_type_definition: snapshot the full output ─────── +// +// The generated struct IS the contract — snapshotting the whole +// pretty-printed item locks derives, serde attributes, field types, +// and rename_all in one reviewable artifact, instead of probing a +// handful of `contains` substrings around unverified output. + +#[rstest] +#[case::two_plain_fields_camel_case( + "two_plain_fields_camel_case", + inline( + "UserInline", + "camelCase", + vec![field("id", quote!(i32), vec![]), field("name", quote!(String), vec![])], + ) + )] +#[case::field_attr_rename_snake_case( + "field_attr_rename_snake_case", + inline( + "TestType", + "snake_case", + vec![field( + "field", + quote!(String), + vec![syn::parse_quote!(#[serde(rename = "renamed")])], + )], + ) + )] +#[case::empty_fields("empty_fields", inline("EmptyType", "camelCase", vec![]))] +#[case::multiple_field_attrs_pascal_case( + "multiple_field_attrs_pascal_case", + inline( + "MultiAttrType", + "PascalCase", + vec![field( + "field", + quote!(String), + vec![ + syn::parse_quote!(#[serde(default)]), + syn::parse_quote!(#[serde(skip_serializing_if = "Option::is_none")]), + ], + )], + ) + )] +#[case::complex_field_types( + "complex_field_types", + inline( + "ComplexType", + "camelCase", + vec![ + field("id", quote!(i32), vec![]), + field("tags", quote!(Vec), vec![]), + field( + "metadata", + quote!(Option>), + vec![], + ), + ], + ) + )] +#[case::doc_attribute( + "doc_attribute", + inline( + "DocType", + "camelCase", + vec![field( + "documented_field", + quote!(String), + vec![syn::parse_quote!(#[doc = "This is a documented field"])], + )], + ) + )] +fn generate_inline_type_definition_snapshot( + #[case] snapshot_name: &str, + #[case] inline_type: InlineRelationType, +) { + // Explicit snapshot name per case: insta's auto-naming counts + // duplicate assertions per *function* in execution order, which + // shuffles across parallel rstest cases. + insta::assert_snapshot!( + snapshot_name, + pretty(&generate_inline_type_definition(&inline_type)) + ); +} + +#[test] +fn inline_field_struct_holds_constructor_inputs() { + let field = field( + "test_field", + quote!(Option), + vec![syn::parse_quote!(#[doc = "Test doc"])], + ); + assert_eq!(field.name.to_string(), "test_field"); + assert!(!field.attrs.is_empty()); +} + +#[test] +fn inline_relation_type_struct_holds_constructor_inputs() { + let inline_type = inline("TestRelation", "SCREAMING_SNAKE_CASE", vec![]); + assert_eq!(inline_type.type_name.to_string(), "TestRelation"); + assert_eq!(inline_type.rename_all, "SCREAMING_SNAKE_CASE"); + assert!(inline_type.fields.is_empty()); +} + +// ── generate_inline_relation_type_from_def ────────────────────────── + +#[test] +fn from_def_has_many_is_not_circular() { + let model_def = r"pub struct Model { + pub id: i32, + pub name: String, + pub memos: HasMany + }"; + let result = generate_inline_relation_type_from_def( + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), + None, + model_def, + ); + assert!(result.is_none(), "HasMany back-references are not circular"); +} + +#[test] +fn from_def_belongs_to_is_circular_and_strips_the_relation() { + let model_def = r"pub struct Model { + pub id: i32, + pub name: String, + pub memo: BelongsTo + }"; + let result = generate_inline_relation_type_from_def( + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), + None, + model_def, + ) + .expect("BelongsTo back-reference is circular"); + + assert_eq!(result.type_name.to_string(), "MemoSchema_User"); + assert_eq!(field_names(&result), ["id", "name"]); +} + +#[test] +fn from_def_no_circular_reference_returns_none() { + let model_def = r"pub struct Model { + pub id: i32, + pub name: String + }"; + let result = generate_inline_relation_type_from_def( + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel("other", "BelongsTo", quote!(super::other::Schema)), + &module_path(&["crate", "models", "test"]), + None, + model_def, + ); + assert!(result.is_none(), "no circular fields means no inline type"); +} + +#[test] +fn from_def_schema_name_override_names_the_inline_type() { + let model_def = r"pub struct Model { + pub id: i32, + pub memo: BelongsTo + }"; + let result = generate_inline_relation_type_from_def( + &syn::Ident::new("Schema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), + Some("MemoSchema"), + model_def, + ) + .expect("circular reference present"); + assert_eq!(result.type_name.to_string(), "MemoSchema_User"); +} + +#[test] +fn from_def_invalid_model_source_returns_none() { + let result = generate_inline_relation_type_from_def( + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&["crate"]), + None, + "invalid rust code", + ); + assert!(result.is_none()); +} + +#[test] +fn from_def_skips_every_relation_typed_field() { + let model_def = r"pub struct Model { + pub id: i32, + pub name: String, + pub memo: BelongsTo, + pub posts: HasMany, + pub profile: HasOne + }"; + let result = generate_inline_relation_type_from_def( + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), + None, + model_def, + ) + .expect("circular reference present"); + assert_eq!( + field_names(&result), + ["id", "name"], + "circular AND non-circular relation fields must all be stripped" + ); +} + +#[test] +fn from_def_skips_serde_skip_fields() { + let model_def = r"pub struct Model { + pub id: i32, + #[serde(skip)] + pub internal_cache: String, + pub name: String, + pub memo: BelongsTo + }"; + let result = generate_inline_relation_type_from_def( + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), + None, + model_def, + ) + .expect("circular reference present"); + assert_eq!(field_names(&result), ["id", "name"]); +} + +#[test] +fn from_def_converts_datetime_types() { + let model_def = r"pub struct Model { + pub id: i32, + pub name: String, + pub created_at: DateTimeWithTimeZone, + pub memo: BelongsTo + }"; + let result = generate_inline_relation_type_from_def( + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), + None, + model_def, + ) + .expect("circular reference present"); + + let created_at = result + .fields + .iter() + .find(|f| f.name == "created_at") + .expect("created_at field should exist"); + insta::assert_snapshot!("from_def_created_at_type", created_at.ty.to_string()); +} + +// ── generate_inline_relation_type_no_relations_from_def ───────────── + +#[test] +fn no_relations_from_def_strips_relations() { + let model_def = r"pub struct Model { + pub id: i32, + pub title: String, + pub user: BelongsTo, + pub comments: HasMany + }"; + let result = generate_inline_relation_type_no_relations_from_def( + &syn::Ident::new("UserSchema", proc_macro2::Span::call_site()), + &rel("memos", "HasMany", quote!(super::memo::Schema)), + &[], + None, + model_def, + ) + .expect("plain fields remain"); + + assert_eq!(result.type_name.to_string(), "UserSchema_Memos"); + assert_eq!(field_names(&result), ["id", "title"]); +} + +#[test] +fn no_relations_from_def_skips_serde_skip_fields() { + let model_def = r"pub struct Model { + pub id: i32, + #[serde(skip)] + pub internal: String, + pub name: String + }"; + let result = generate_inline_relation_type_no_relations_from_def( + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel("items", "HasMany", quote!(super::item::Schema)), + &[], + None, + model_def, + ) + .expect("plain fields remain"); + assert_eq!(field_names(&result), ["id", "name"]); +} + +#[test] +fn no_relations_from_def_schema_name_override_names_the_inline_type() { + let model_def = r"pub struct Model { + pub id: i32, + pub title: String + }"; + let result = generate_inline_relation_type_no_relations_from_def( + &syn::Ident::new("Schema", proc_macro2::Span::call_site()), + &rel("memos", "HasMany", quote!(super::memo::Schema)), + &[], + Some("UserSchema"), + model_def, + ) + .expect("plain fields remain"); + assert_eq!(result.type_name.to_string(), "UserSchema_Memos"); +} + +#[test] +fn no_relations_from_def_converts_datetime_types() { + let model_def = r"pub struct Model { + pub id: i32, + pub title: String, + pub created_at: DateTimeWithTimeZone, + pub updated_at: Option, + pub user: BelongsTo + }"; + let result = generate_inline_relation_type_no_relations_from_def( + &syn::Ident::new("UserSchema", proc_macro2::Span::call_site()), + &rel("memos", "HasMany", quote!(super::memo::Schema)), + &[], + None, + model_def, + ) + .expect("plain fields remain"); + + let ty_of = |name: &str| { + result + .fields + .iter() + .find(|f| f.name == name) + .unwrap_or_else(|| panic!("{name} field should exist")) + .ty + .to_string() + }; + insta::assert_snapshot!( + "no_relations_datetime_types", + format!( + "created_at: {}\nupdated_at: {}", + ty_of("created_at"), + ty_of("updated_at"), + ) + ); +} + +// ── File-lookup variants (CARGO_MANIFEST_DIR + temp project) ──────── + +#[test] +#[serial] +fn file_lookup_generates_inline_type_for_circular_model() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let models_dir = temp_dir.path().join("src").join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("user.rs"), + r" + pub struct Model { + pub id: i32, + pub name: String, + pub memo: BelongsTo, + } + ", + ) + .unwrap(); + + let result = with_manifest_dir(temp_dir.path(), || { + generate_inline_relation_type( + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(crate::models::user::Schema)), + &module_path(&MEMO_MODULE), + None, + ) + }) + .expect("circular reference present"); + + assert_eq!(result.type_name.to_string(), "MemoSchema_User"); + assert_eq!(field_names(&result), ["id", "name"]); +} + +#[test] +#[serial] +fn file_lookup_no_relations_strips_relations() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let models_dir = temp_dir.path().join("src").join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + std::fs::write( + models_dir.join("memo.rs"), + r" + pub struct Model { + pub id: i32, + pub title: String, + pub user: BelongsTo, + pub comments: HasMany, + } + ", + ) + .unwrap(); + + let result = with_manifest_dir(temp_dir.path(), || { + generate_inline_relation_type_no_relations( + &syn::Ident::new("UserSchema", proc_macro2::Span::call_site()), + &rel("memos", "HasMany", quote!(crate::models::memo::Schema)), + &module_path(&["crate", "models", "user"]), + None, + ) + }) + .expect("plain fields remain"); + + assert_eq!(result.type_name.to_string(), "UserSchema_Memos"); + assert_eq!(field_names(&result), ["id", "title"]); +} + +#[test] +#[serial] +fn file_lookup_missing_model_file_returns_none() { + let temp_dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + + let result = with_manifest_dir(temp_dir.path(), || { + generate_inline_relation_type( + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel( + "user", + "BelongsTo", + quote!(crate::models::nonexistent::Schema), + ), + &module_path(&["crate"]), + None, + ) + }); + assert!(result.is_none()); +} + +#[test] +#[serial] +fn file_lookup_no_relations_missing_model_file_returns_none() { + let temp_dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(temp_dir.path().join("src")).unwrap(); + + let result = with_manifest_dir(temp_dir.path(), || { + generate_inline_relation_type_no_relations( + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel( + "items", + "HasMany", + quote!(crate::models::nonexistent::Schema), + ), + &[], + None, + ) + }); + assert!(result.is_none()); +} diff --git a/crates/vespera_macro/src/schema_macro/input.rs b/crates/vespera_macro/src/schema_macro/input.rs index ed1de6b0..4577dbae 100644 --- a/crates/vespera_macro/src/schema_macro/input.rs +++ b/crates/vespera_macro/src/schema_macro/input.rs @@ -45,6 +45,15 @@ impl Parse for SchemaInput { match ident_str.as_str() { "omit" => { + // Reject a second `omit` instead of silently overwriting the + // first (the prior behaviour gave surprising schemas with no + // diagnostic) — matching `schema_type!`'s stricter parser. + if omit.is_some() { + return Err(syn::Error::new( + ident.span(), + "duplicate parameter `omit` in schema! invocation", + )); + } input.parse::()?; let content; let _ = bracketed!(content in input); @@ -53,6 +62,12 @@ impl Parse for SchemaInput { omit = Some(fields.into_iter().map(|s| s.value()).collect()); } "pick" => { + if pick.is_some() { + return Err(syn::Error::new( + ident.span(), + "duplicate parameter `pick` in schema! invocation", + )); + } input.parse::()?; let content; let _ = bracketed!(content in input); @@ -209,6 +224,10 @@ impl Parse for SchemaTypeInput { let mut rename_all = None; let mut multipart = false; let mut omit_default = false; + // Reject a repeated parameter (e.g. `pick = .., pick = ..` or a bare + // `partial, partial`) with a spanned error instead of letting the + // later value silently overwrite the earlier one. + let mut seen_params = std::collections::HashSet::::new(); // Parse optional parameters while input.peek(Token![,]) { @@ -220,6 +239,12 @@ impl Parse for SchemaTypeInput { let ident: Ident = input.parse()?; let ident_str = ident.to_string(); + if !seen_params.insert(ident_str.clone()) { + return Err(syn::Error::new( + ident.span(), + format!("duplicate parameter `{ident_str}` in schema_type! macro"), + )); + } match ident_str.as_str() { "omit" => { diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 8fcdcbb0..51764279 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -6,361 +6,30 @@ mod circular; mod codegen; +mod defaults; pub mod file_cache; mod file_lookup; mod from_model; +mod generate_type; mod inline_types; mod input; +mod same_file_override; mod seaorm; mod transformation; pub mod type_utils; mod validation; pub use file_cache::print_profile_summary; +pub use generate_type::generate_schema_type_code; +pub use input::{SchemaInput, SchemaTypeInput}; -use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use codegen::generate_filtered_schema; -use file_lookup::find_struct_from_path; -use from_model::generate_from_model_with_relations; -use inline_types::{ - generate_inline_relation_type, generate_inline_relation_type_no_relations, - generate_inline_type_definition, -}; -pub use input::{PartialMode, SchemaInput, SchemaTypeInput}; use proc_macro2::TokenStream; -use quote::quote; -use seaorm::{ - RelationFieldInfo, convert_relation_type_to_schema_with_info, convert_type_with_chrono, - extract_sea_orm_default_value, has_sea_orm_primary_key, is_sql_function_default, -}; -use transformation::{ - build_omit_set, build_partial_config, build_pick_set, build_rename_map, determine_rename_all, - extract_doc_attrs, extract_field_serde_attrs, extract_form_data_attrs, - extract_serde_attrs_without_rename_all, filter_out_serde_rename, should_skip_field, - should_wrap_in_option, -}; -use type_utils::{ - capitalize_first, extract_module_path, extract_type_name, is_option_type, is_qualified_path, - is_seaorm_model, is_seaorm_relation_type, snake_to_pascal_case, -}; -use validation::{ - extract_source_field_names, validate_omit_fields, validate_partial_fields, - validate_pick_fields, validate_rename_fields, -}; - -use crate::{ - metadata::StructMetadata, - parser::{extract_default, extract_field_rename, strip_raw_prefix_owned}, -}; +use type_utils::extract_type_name; -#[cfg(test)] -struct __VesperaSameFileLookupFixture { - value: i32, -} - -fn derive_response_base_name(name: &str) -> String { - for suffix in ["Response", "Request", "Schema"] { - if let Some(stripped) = name.strip_suffix(suffix) - && !stripped.is_empty() - { - return stripped.to_string(); - } - } - name.to_string() -} - -fn find_same_file_struct_metadata<'a>( - struct_name: &str, - schema_storage: &'a HashMap, -) -> Option> { - // Cache hit: hand back a borrow so the (potentially large) struct - // definition string is not cloned per lookup. The fallback path - // produces an owned `StructMetadata` from disk, so the unified return - // type is `Cow<'_, StructMetadata>`. - if let Some(metadata) = schema_storage.get(struct_name) { - return Some(Cow::Borrowed(metadata)); - } - - let file_path = proc_macro2::Span::call_site().local_file(); - #[cfg(test)] - let file_path = file_path.or_else(|| { - Some( - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("src") - .join("schema_macro") - .join("mod.rs"), - ) - }); - let file_path = file_path?; - let definition = file_cache::get_struct_definition(&file_path, struct_name)?; - Some(Cow::Owned(StructMetadata::new( - struct_name.to_string(), - definition, - ))) -} - -fn related_model_type_from_schema_path(schema_path: &TokenStream) -> Option { - let schema_path_str = schema_path.to_string().replace("Schema", "Model"); - syn::parse_str(&schema_path_str).ok() -} - -fn schema_component_name_from_path(schema_path: &TokenStream) -> String { - // Keep the stringified path alive in this scope so the `&str` - // segments borrow from it. The previous implementation collected - // owned `String`s — one allocation per path segment — even though - // each segment is only ever inspected as `&str`. - let path_str = schema_path.to_string(); - let segments: Vec<&str> = path_str.split("::").map(str::trim).collect(); - - if segments.last().is_some_and(|s| *s == "Schema") && segments.len() > 1 { - format!("{}Schema", capitalize_first(segments[segments.len() - 2])) - } else { - segments - .last() - .map_or_else(|| "Schema".to_string(), |s| (*s).to_string()) - } -} - -fn has_derive(struct_item: &syn::ItemStruct, derive_name: &str) -> bool { - struct_item.attrs.iter().any(|attr| { - if !attr.path().is_ident("derive") { - return false; - } - - let mut found = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident(derive_name) { - found = true; - } - Ok(()) - }); - found - }) -} - -fn build_named_struct_field_assignments( - struct_item: &syn::ItemStruct, - source_expr: &TokenStream, -) -> syn::Result> { - let syn::Fields::Named(fields_named) = &struct_item.fields else { - return Err(syn::Error::new_spanned( - struct_item, - "same-file relation override DTO must be a named-field struct", - )); - }; - - let assignments = fields_named - .named - .iter() - .filter_map(|field| { - field.ident.as_ref().map(|ident| { - quote! { #ident: #source_expr . #ident.clone() } - }) - }) - .collect(); - - Ok(assignments) -} - -fn build_proxy_fields(struct_item: &syn::ItemStruct) -> syn::Result> { - let syn::Fields::Named(fields_named) = &struct_item.fields else { - return Err(syn::Error::new_spanned( - struct_item, - "same-file relation override DTO must be a named-field struct", - )); - }; - - let fields = fields_named - .named - .iter() - .filter_map(|field| { - field.ident.as_ref().map(|ident| { - let ty = &field.ty; - let attrs: Vec<_> = field - .attrs - .iter() - .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("doc")) - .collect(); - quote! { - #(#attrs)* - #ident: #ty - } - }) - }) - .collect(); - - Ok(fields) -} - -fn build_proxy_to_dto_assignments(struct_item: &syn::ItemStruct) -> syn::Result> { - let syn::Fields::Named(fields_named) = &struct_item.fields else { - return Err(syn::Error::new_spanned( - struct_item, - "same-file relation override DTO must be a named-field struct", - )); - }; - - let assignments = fields_named - .named - .iter() - .filter_map(|field| { - field - .ident - .as_ref() - .map(|ident| quote! { #ident: proxy.#ident }) - }) - .collect(); - - Ok(assignments) -} - -fn build_clone_assignments(struct_item: &syn::ItemStruct) -> syn::Result> { - let syn::Fields::Named(fields_named) = &struct_item.fields else { - return Err(syn::Error::new_spanned( - struct_item, - "same-file relation override DTO must be a named-field struct", - )); - }; - - let assignments = fields_named - .named - .iter() - .filter_map(|field| { - field.ident.as_ref().map(|ident| { - quote! { #ident: self.#ident.clone() } - }) - }) - .collect(); - - Ok(assignments) -} - -fn maybe_generate_same_file_relation_override( - new_type_name: &syn::Ident, - field_name: &str, - rel_info: &RelationFieldInfo, - schema_storage: &HashMap, -) -> syn::Result> { - let response_base = derive_response_base_name(&new_type_name.to_string()); - let dto_name = format!("{}In{}", snake_to_pascal_case(field_name), response_base); - let Some(dto_meta) = find_same_file_struct_metadata(&dto_name, schema_storage) else { - return Ok(None); - }; - - let dto_struct: syn::ItemStruct = file_cache::parse_struct_cached(&dto_meta.definition) - .map_err(|e| syn::Error::new(proc_macro2::Span::call_site(), e.to_string()))?; - let dto_ident = syn::Ident::new(&dto_name, proc_macro2::Span::call_site()); - let wrapper_ident = syn::Ident::new( - &format!( - "__Vespera{}{}Relation", - new_type_name, - snake_to_pascal_case(field_name) - ), - proc_macro2::Span::call_site(), - ); - let proxy_ident = syn::Ident::new( - &format!( - "__Vespera{}{}Proxy", - new_type_name, - snake_to_pascal_case(field_name) - ), - proc_macro2::Span::call_site(), - ); - let schema_ref_name = schema_component_name_from_path(&rel_info.schema_path); - - let dto_serde_attrs: Vec<_> = dto_struct - .attrs - .iter() - .filter(|attr| attr.path().is_ident("serde")) - .collect(); - let dto_doc_attrs: Vec<_> = dto_struct - .attrs - .iter() - .filter(|attr| attr.path().is_ident("doc")) - .collect(); - - let proxy_fields = build_proxy_fields(&dto_struct)?; - let proxy_to_dto = build_proxy_to_dto_assignments(&dto_struct)?; - let clone_assignments = build_clone_assignments(&dto_struct)?; - let Some(model_ty) = related_model_type_from_schema_path(&rel_info.schema_path) else { - return Ok(None); - }; - let source_expr = quote! { source }; - let from_model_assignments = build_named_struct_field_assignments(&dto_struct, &source_expr)?; - - // Coalesced helpers: previously three separate `quote!` invocations - // and a `Vec` accumulator were stitched together with - // `#(#helper_tokens)*`. We instead build the conditional Clone / - // Deserialize sub-blocks as their own `TokenStream`s and splice - // them into a single `quote!`, producing the same emitted Rust code - // with one accumulator allocation removed. - let clone_impl = if has_derive(&dto_struct, "Clone") { - quote! {} - } else { - quote! { - impl Clone for #dto_ident { - fn clone(&self) -> Self { - Self { - #(#clone_assignments),* - } - } - } - } - }; - - let deserialize_impl = if has_derive(&dto_struct, "Deserialize") { - quote! {} - } else { - quote! { - #[derive(serde::Deserialize)] - #(#dto_serde_attrs)* - struct #proxy_ident { - #(#proxy_fields),* - } - - impl<'de> serde::Deserialize<'de> for #dto_ident { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let proxy = #proxy_ident::deserialize(deserializer)?; - Ok(Self { - #(#proxy_to_dto),* - }) - } - } - } - }; - - let helpers = quote! { - #clone_impl - #deserialize_impl - - impl From<#model_ty> for #dto_ident { - fn from(source: #model_ty) -> Self { - Self { - #(#from_model_assignments),* - } - } - } - - #(#dto_doc_attrs)* - #[derive(serde::Serialize, serde::Deserialize, Clone, vespera::Schema)] - #[serde(transparent)] - #[schema(ref = #schema_ref_name, nullable)] - struct #wrapper_ident(pub Option<#dto_ident>); - - impl From> for #wrapper_ident { - fn from(source: Option<#model_ty>) -> Self { - Self(source.map(Into::into)) - } - } - }; - - Ok(Some((quote! { #wrapper_ident }, helpers))) -} +use crate::metadata::StructMetadata; /// Generate schema code from a struct with optional field filtering pub fn generate_schema_code( @@ -395,739 +64,423 @@ pub fn generate_schema_code( Ok(schema_tokens) } -/// Generate a new struct type from an existing type with field filtering -/// -/// Returns (`TokenStream`, Option) where the metadata is returned -/// when a custom `name` is provided (for direct registration in `SCHEMA_STORAGE`). -#[allow(clippy::too_many_lines)] -pub fn generate_schema_type_code( - input: &SchemaTypeInput, - schema_storage: &HashMap, -) -> Result<(TokenStream, Option), syn::Error> { - // Extract type name from the source Type - let source_type_name = extract_type_name(&input.source_type)?; - - // Extract the module path for resolving relative paths in relation types - // This may be empty for simple names like `Model` - will be overridden below if found from file - let mut source_module_path = extract_module_path(&input.source_type); - - // Find struct definition - check SCHEMA_STORAGE first (no file I/O), - // fall back to file lookup for types not registered (e.g., SeaORM Model). - let struct_def_owned: StructMetadata; - let schema_name_hint = input.schema_name.as_deref(); - let struct_def = if is_qualified_path(&input.source_type) { - // Qualified path: try storage first (avoids parse_file for Schema-derived types), - // then file lookup for non-Schema types (e.g., SeaORM Model) - if let Some(found) = schema_storage.get(&source_type_name) { - found - } else if let Some((found, module_path)) = - find_struct_from_path(&input.source_type, schema_name_hint) - { - struct_def_owned = found; - // Use the module path from file lookup for qualified paths - // The file lookup derives module path from actual file location, which is more accurate - // for resolving relative paths like `super::user::Entity` - source_module_path = module_path; - &struct_def_owned - } else { - return Err(syn::Error::new_spanned( - &input.source_type, - format!( - "type `{source_type_name}` not found. Either:\n\ - 1. Use #[derive(Schema)] in the same file\n\ - 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file" - ), - )); - } - } else { - // Simple name: try storage first (for same-file structs), then file lookup with schema name hint - if let Some(found) = schema_storage.get(&source_type_name) { - found - } else if let Some((found, module_path)) = - find_struct_from_path(&input.source_type, schema_name_hint) - { - struct_def_owned = found; - // For simple names, we MUST use the inferred module path from the file location - // This is crucial for resolving relative paths like `super::user::Entity` - source_module_path = module_path; - &struct_def_owned - } else { - return Err(syn::Error::new_spanned( - &input.source_type, - format!( - "type `{source_type_name}` not found. Either:\n\ - 1. Use #[derive(Schema)] in the same file\n\ - 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file\n\ - 3. If using `name = \"XxxSchema\"`, ensure the file name matches (e.g., xxx.rs)" - ), - )); - } - }; +#[cfg(test)] +mod tests { + use std::collections::HashMap; - // Parse the struct definition - let parsed_struct: syn::ItemStruct = file_cache::parse_struct_cached(&struct_def.definition) - .map_err(|e| { - syn::Error::new_spanned( - &input.source_type, - format!("failed to parse struct definition for `{source_type_name}`: {e}"), - ) - })?; + use quote::quote; - // Extract all field names from source struct for validation - // Include relation fields since they can be converted to Schema types - let source_field_names = extract_source_field_names(&parsed_struct); - - // Validate all field references exist in source struct - validate_pick_fields( - input.pick.as_ref(), - &source_field_names, - &input.source_type, - &source_type_name, - )?; - validate_omit_fields( - input.omit.as_ref(), - &source_field_names, - &input.source_type, - &source_type_name, - )?; - validate_rename_fields( - input.rename.as_ref(), - &source_field_names, - &input.source_type, - &source_type_name, - )?; - let partial_fields_to_validate = match &input.partial { - Some(PartialMode::Fields(fields)) => Some(fields), - _ => None, - }; - validate_partial_fields( - partial_fields_to_validate, - &source_field_names, - &input.source_type, - &source_type_name, - )?; - - // Build filter sets and rename map - let omit_set = build_omit_set(input.omit.as_ref()); - let pick_set = build_pick_set(input.pick.as_ref()); - let (partial_all, partial_set) = build_partial_config(&input.partial); - let rename_map = build_rename_map(input.rename.as_ref()); - - // Extract serde attributes from source struct, excluding rename_all (we'll handle it separately) - let serde_attrs_without_rename_all = - extract_serde_attrs_without_rename_all(&parsed_struct.attrs); - - // Extract doc comments from source struct to carry over to generated struct - let struct_doc_attrs = extract_doc_attrs(&parsed_struct.attrs); - - // Determine the effective rename_all strategy - let effective_rename_all = - determine_rename_all(input.rename_all.as_ref(), &parsed_struct.attrs); - - // Check if source is a SeaORM Model - let is_source_seaorm_model = is_seaorm_model(&parsed_struct); - - // Generate new struct with filtered fields - let new_type_name = &input.new_type; - let mut field_tokens = Vec::new(); - // Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option, is_relation) - let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool, bool)> = Vec::new(); - // Track relation field info for from_model generation - let mut relation_fields: Vec = Vec::new(); - // Track inline types that need to be generated for circular relations - let mut inline_type_definitions: Vec = Vec::new(); - // Track default value functions generated from sea_orm(default_value) - let mut default_functions: Vec = Vec::new(); - // Track same-file relation override helpers - let mut relation_override_helpers: Vec = Vec::new(); - - if let syn::Fields::Named(fields_named) = &parsed_struct.fields { - for field in &fields_named.named { - let rust_field_name = field.ident.as_ref().map_or_else( - || "unknown".to_string(), - |i| strip_raw_prefix_owned(i.to_string()), - ); - - // Apply omit/pick filters - if should_skip_field(&rust_field_name, &omit_set, &pick_set) { - continue; - } - - // Apply omit_default: skip fields with sea_orm(default_value) or sea_orm(primary_key) - if input.omit_default - && (extract_sea_orm_default_value(&field.attrs).is_some() - || has_sea_orm_primary_key(&field.attrs)) - { - continue; - } - - // Check if this is a SeaORM relation type - let is_relation = is_seaorm_relation_type(&field.ty); - - // In multipart mode, skip ALL relation fields (multipart forms can't represent nested objects) - if input.multipart && is_relation { - continue; - } - - // Get field components, applying partial wrapping if needed - let original_ty = &field.ty; - let should_wrap_option = should_wrap_in_option( - &rust_field_name, - partial_all, - &partial_set, - is_option_type(original_ty), - is_relation, - ); - - // Determine field type: convert relation types to Schema types - let (field_ty, relation_info): (Box, Option) = - if is_relation { - // Convert HasOne/HasMany/BelongsTo to Schema type - if let Some((converted, mut rel_info)) = - convert_relation_type_to_schema_with_info( - original_ty, - &field.attrs, - &parsed_struct, - &source_module_path, - field.ident.clone().unwrap(), - ) - { - // NEW RULE: HasMany (reverse references) are excluded by default - // They can only be included via explicit `pick` - if rel_info.relation_type == "HasMany" { - // HasMany is only included if explicitly picked - if !pick_set.contains(&rust_field_name) { - continue; - } - // When HasMany IS picked, generate inline type with ALL relations stripped - if let Some(inline_type) = generate_inline_relation_type_no_relations( - new_type_name, - &rel_info, - &source_module_path, - input.schema_name.as_deref(), - ) { - let inline_type_def = generate_inline_type_definition(&inline_type); - inline_type_definitions.push(inline_type_def); - - let inline_type_name = &inline_type.type_name; - let included_fields: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - - rel_info.inline_type_info = - Some((inline_type.type_name.clone(), included_fields)); - - let inline_field_ty = quote! { Vec<#inline_type_name> }; - (Box::new(inline_field_ty), Some(rel_info)) - } else { - continue; - } - } else { - // BelongsTo/HasOne: Include by default - if input.add.is_some() - && let Some((override_field_ty, helper_tokens)) = - maybe_generate_same_file_relation_override( - new_type_name, - &rust_field_name, - &rel_info, - schema_storage, - )? - { - relation_override_helpers.push(helper_tokens); - (Box::new(override_field_ty), Some(rel_info)) - } else - // Check for circular references and potentially use inline type - if let Some(inline_type) = generate_inline_relation_type( - new_type_name, - &rel_info, - &source_module_path, - input.schema_name.as_deref(), - ) { - // Generate inline type definition - let inline_type_def = generate_inline_type_definition(&inline_type); - inline_type_definitions.push(inline_type_def); - - // Use inline type instead of direct schema reference - let inline_type_name = &inline_type.type_name; - let circular_fields: Vec = inline_type - .fields - .iter() - .map(|f| f.name.to_string()) - .collect(); - - // Store inline type info - rel_info.inline_type_info = - Some((inline_type.type_name.clone(), circular_fields)); - - // Generate field type using inline type - let inline_field_ty = if rel_info.is_optional { - quote! { Option> } - } else { - quote! { Box<#inline_type_name> } - }; - - (Box::new(inline_field_ty), Some(rel_info)) - } else { - // No circular refs, use original schema path - (Box::new(converted), Some(rel_info)) - } - } - } else { - // Fallback: skip if conversion fails - continue; - } - } else { - // Convert SeaORM datetime types to chrono equivalents - // Also resolves local types to absolute paths - let converted_ty = convert_type_with_chrono(original_ty, &source_module_path); - if should_wrap_option { - (Box::new(quote! { Option<#converted_ty> }), None) - } else { - (Box::new(converted_ty), None) - } - }; - - // Collect relation info — `.extend(...)` keeps the push site - // out of an explicit closure so the coverage tracker - // attributes the call to this source line. - relation_fields.extend(relation_info); - let vis: &syn::Visibility = &field.vis; - let source_field_ident: syn::Ident = field.ident.clone().unwrap(); - - // Extract doc attributes to carry over comments to the generated struct - let doc_attrs = extract_doc_attrs(&field.attrs); - - if input.multipart { - // Multipart mode: emit form_data attrs, suppress serde attrs - let form_data_attrs = extract_form_data_attrs(&field.attrs); - - // Check if field should be renamed (rename still applies to Rust field names) - if let Some(new_name) = rename_map.get(&rust_field_name) { - let new_field_ident = - syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); - - field_tokens.push(quote! { - #(#doc_attrs)* - #(#form_data_attrs)* - #vis #new_field_ident: #field_ty - }); - - field_mappings.push(( - new_field_ident, - source_field_ident, - should_wrap_option, - is_relation, - )); - } else { - let field_ident = field.ident.clone().unwrap(); - - field_tokens.push(quote! { - #(#doc_attrs)* - #(#form_data_attrs)* - #vis #field_ident: #field_ty - }); - - field_mappings.push(( - field_ident.clone(), - field_ident, - should_wrap_option, - is_relation, - )); - } - } else { - // Normal (serde) mode: emit serde attrs - // Filter field attributes: keep serde and doc attributes, remove sea_orm and others - // This is important when using schema_type! with models from other files - // that may have ORM-specific attributes we don't want in the generated struct - let serde_field_attrs = extract_field_serde_attrs(&field.attrs); - - // Generate serde default + schema(default) from sea_orm(default_value) or primary_key - // Handles literal defaults, SQL function defaults, and implicit auto-increment - let (serde_default_attr, schema_default_attr): ( - proc_macro2::TokenStream, - proc_macro2::TokenStream, - ) = generate_sea_orm_default_attrs( - &field.attrs, - new_type_name, - &rust_field_name, - original_ty, - &field_ty, - should_wrap_option || is_option_type(original_ty), - &mut default_functions, - ); - - // Check if field should be renamed - if let Some(new_name) = rename_map.get(&rust_field_name) { - // Create new identifier for the field - let new_field_ident: syn::Ident = - syn::Ident::new(new_name, field.ident.as_ref().unwrap().span()); - - // Filter out serde(rename) attributes from the serde attrs - let filtered_attrs = filter_out_serde_rename(&serde_field_attrs); - - // Determine the JSON name: use existing serde(rename) if present, otherwise rust field name - let json_name = extract_field_rename(&field.attrs) - .unwrap_or_else(|| rust_field_name.clone()); - - field_tokens.push(quote! { - #(#doc_attrs)* - #(#filtered_attrs)* - #serde_default_attr - #schema_default_attr - #[serde(rename = #json_name)] - #vis #new_field_ident: #field_ty - }); - - // Track mapping: new field name <- source field name - field_mappings.push(( - new_field_ident, - source_field_ident, - should_wrap_option, - is_relation, - )); - } else { - // No rename, keep field with serde and doc attrs - let field_ident = field.ident.clone().unwrap(); - - field_tokens.push(quote! { - #(#doc_attrs)* - #(#serde_field_attrs)* - #serde_default_attr - #schema_default_attr - #vis #field_ident: #field_ty - }); - - // Track mapping: same name - field_mappings.push(( - field_ident.clone(), - field_ident, - should_wrap_option, - is_relation, - )); - } - } - } + use super::defaults::is_parseable_type; + use super::same_file_override::maybe_generate_same_file_relation_override; + use super::seaorm::RelationFieldInfo; + use super::*; + + fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { + StructMetadata::new(name.to_string(), definition.to_string()) } - // Add new fields from `add` parameter - for (field_name, field_ty) in input.add.iter().flatten() { - let field_ident: syn::Ident = syn::Ident::new(field_name, proc_macro2::Span::call_site()); - field_tokens.push(quote! { - pub #field_ident: #field_ty - }); + fn to_storage(items: Vec) -> HashMap { + items.into_iter().map(|s| (s.name.clone(), s)).collect() } - // Build derive list - // In multipart mode, force clone = false (FieldData doesn't implement Clone) - let derive_clone: bool = if input.multipart { - false - } else { - input.derive_clone - }; - let clone_derive: proc_macro2::TokenStream = if derive_clone { - quote! { Clone, } - } else { - quote! {} - }; - - // Conditionally include Schema derive based on ignore_schema flag - // Also generate #[schema(name = "...")] attribute if custom name is provided AND Schema is derived - let schema_derive: proc_macro2::TokenStream; - let schema_name_attr: proc_macro2::TokenStream; - if input.ignore_schema { - schema_derive = quote! {}; - schema_name_attr = quote! {}; - } else if let Some(ref name) = input.schema_name { - schema_derive = quote! { vespera::Schema }; - schema_name_attr = quote! { #[schema(name = #name)] }; - } else { - schema_derive = quote! { vespera::Schema }; - schema_name_attr = quote! {}; + #[test] + fn test_generate_schema_code_simple_struct() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(User); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); + + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + assert!(output.contains("properties")); + assert!(output.contains("Schema")); } - // Check if there are any relation fields - let has_relation_fields = field_mappings.iter().any(|(_, _, _, is_rel)| *is_rel); - - // In multipart mode, skip From and from_model impls entirely - let source_type: &syn::Type = &input.source_type; - let (from_impl, from_model_impl) = if input.multipart { - (quote! {}, quote! {}) - } else { - // Generate From impl only if: - // 1. `add` is not used (can't auto-populate added fields) - // 2. There are no relation fields (relation fields don't exist on source Model) - let from_impl = if input.add.is_none() && !has_relation_fields { - let field_assignments: Vec<_> = field_mappings - .iter() - .map(|(new_ident, source_ident, wrapped, _is_relation)| { - if *wrapped { - quote! { #new_ident: Some(source.#source_ident) } - } else { - quote! { #new_ident: source.#source_ident } - } - }) - .collect(); - - quote! { - impl From<#source_type> for #new_type_name { - fn from(source: #source_type) -> Self { - Self { - #(#field_assignments),* - } - } - } - } - } else { - quote! {} - }; + #[test] + fn test_generate_schema_code_with_omit() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub password: String }", + )]); - // Generate from_model impl for SeaORM Models WITH relations - // - No relations: Use `From` trait (generated above) - // - Has relations: async fn from_model(model: Model, db: &DatabaseConnection) -> Result - let from_model_impl = - if is_source_seaorm_model && input.add.is_none() && has_relation_fields { - generate_from_model_with_relations( - new_type_name, - source_type, - &field_mappings, - &relation_fields, - &source_module_path, - schema_storage, - ) - } else { - quote! {} - }; - - (from_impl, from_model_impl) - }; - - // Generate the new struct (with inline types for circular relations first) - let generated_tokens: proc_macro2::TokenStream = if input.multipart { - // Multipart mode: derive Multipart instead of serde - // Emit #[serde(rename_all = ...)] so Multipart applies the rename at runtime - // AND Schema derive reads it via extract_rename_all() fallback for OpenAPI field naming - quote! { - #(#inline_type_definitions)* - - #(#struct_doc_attrs)* - #[derive(vespera::Multipart, #clone_derive #schema_derive)] - #schema_name_attr - #[serde(rename_all = #effective_rename_all)] - pub struct #new_type_name { - #(#field_tokens),* - } - } - } else { - // Normal serde mode - quote! { - // Inline types for circular relation references - #(#inline_type_definitions)* - - // Same-file relation override helpers - #(#relation_override_helpers)* - - // Default value functions for sea_orm(default_value) fields - #(#default_functions)* - - #(#struct_doc_attrs)* - #[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)] - #schema_name_attr - #[serde(rename_all = #effective_rename_all)] - #(#serde_attrs_without_rename_all)* - pub struct #new_type_name { - #(#field_tokens),* - } - - #from_impl - #from_model_impl - } - }; - - // If custom name is provided, create metadata for direct registration - // This ensures the schema appears in OpenAPI even when `ignore` is set - let metadata = input.schema_name.as_ref().map(|custom_name| { - // Build struct definition string for metadata (without derives/attrs for parsing) - let struct_def = quote! { - #[serde(rename_all = #effective_rename_all)] - #(#serde_attrs_without_rename_all)* - pub struct #new_type_name { - #(#field_tokens),* - } - }; - StructMetadata::new(custom_name.clone(), struct_def.to_string()) - }); + let tokens = quote!(User, omit = ["password"]); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); - Ok((generated_tokens, metadata)) -} + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + assert!(output.contains("properties")); + } + + #[test] + fn test_generate_schema_code_with_pick() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub email: String }", + )]); -/// Generate `#[serde(default = "...")]` and `#[schema(default = "...")]` attributes -/// from `#[sea_orm(default_value = ...)]` or `#[sea_orm(primary_key)]` on source fields. -/// -/// Returns `(serde_default_attr, schema_default_attr)` as `TokenStream`s. -/// - `serde_default_attr`: `#[serde(default = "default_structname_field")]` for deserialization -/// - `schema_default_attr`: `#[schema(default = "value")]` for OpenAPI default value -/// -/// Also generates a companion default function and appends it to `default_functions`. -/// -/// Handles three categories of defaults: -/// 1. **Literal defaults** (`default_value = "42"`, `"draft"`, `0.7`): -/// Generates parse-based default function + schema default. -/// 2. **SQL function defaults** (`default_value = "NOW()"`, `"gen_random_uuid()"`): -/// Generates type-specific default function + schema default with type's zero value. -/// 3. **Primary key** (implicit auto-increment): -/// Treated as having an implicit default — generates type-specific default. -/// -/// Skips serde default generation when: -/// - The field is wrapped in `Option` (partial mode or already optional) -/// - The field already has `#[serde(default)]` -/// - For literal defaults: the field type doesn't implement `FromStr` -fn generate_sea_orm_default_attrs( - original_attrs: &[syn::Attribute], - struct_name: &syn::Ident, - field_name: &str, - original_ty: &syn::Type, - field_ty: &dyn quote::ToTokens, - is_optional_or_partial: bool, - default_functions: &mut Vec, -) -> (TokenStream, TokenStream) { - // Don't generate defaults for optional/partial fields - if is_optional_or_partial { - return (quote! {}, quote! {}); + let tokens = quote!(User, pick = ["id", "name"]); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); + + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + assert!(output.contains("properties")); } - // Check for sea_orm(default_value) and sea_orm(primary_key) - let default_value = extract_sea_orm_default_value(original_attrs); - let has_pk = has_sea_orm_primary_key(original_attrs); + #[test] + fn test_generate_schema_code_type_not_found() { + let storage: HashMap = HashMap::new(); + + let tokens = quote!(NonExistent); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); - // No default source found - if default_value.is_none() && !has_pk { - return (quote! {}, quote! {}); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("not found")); } - let has_existing_serde_default = extract_default(original_attrs).is_some(); + #[test] + fn test_generate_schema_code_malformed_definition() { + let storage = to_storage(vec![create_test_struct_metadata( + "BadStruct", + "this is not valid rust code {{{", + )]); + + let tokens = quote!(BadStruct); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); - match &default_value { - // Literal default (e.g., "42", "draft", "0.7") - Some(value) if !is_sql_function_default(value) => { - let schema_default_attr = quote! { #[schema(default = #value)] }; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("failed to parse")); + } - if has_existing_serde_default { - return (quote! {}, schema_default_attr); - } + #[test] + fn test_generate_schema_type_code_pick_nonexistent_field() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(NewUser from User, pick = ["nonexistent"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not exist")); + assert!(err.contains("nonexistent")); + } - if !is_parseable_type(original_ty) { - return (quote! {}, schema_default_attr); - } + #[test] + fn test_generate_schema_type_code_omit_nonexistent_field() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(NewUser from User, omit = ["nonexistent"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not exist")); + assert!(err.contains("nonexistent")); + } - let fn_name = format!("default_{struct_name}_{field_name}"); - let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); + #[test] + fn test_generate_schema_type_code_rename_nonexistent_field() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(NewUser from User, rename = [("nonexistent", "new_name")]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not exist")); + assert!(err.contains("nonexistent")); + } - default_functions.push(quote! { - #[allow(non_snake_case)] - fn #fn_ident() -> #field_ty { - #value.parse().unwrap() - } - }); + #[test] + fn test_generate_schema_type_code_type_not_found() { + let storage: HashMap = HashMap::new(); - let serde_default_attr = quote! { #[serde(default = #fn_name)] }; - (serde_default_attr, schema_default_attr) - } - // SQL function default (NOW(), gen_random_uuid(), etc.) or primary_key auto-increment - _ => { - let Some((default_expr, schema_default_str)) = - sql_function_default_for_type(original_ty) - else { - return (quote! {}, quote! {}); - }; - - let schema_default_attr = quote! { #[schema(default = #schema_default_str)] }; - - if has_existing_serde_default { - return (quote! {}, schema_default_attr); - } - - let fn_name = format!("default_{struct_name}_{field_name}"); - let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); - - default_functions.push(quote! { - #[allow(non_snake_case)] - fn #fn_ident() -> #field_ty { - #default_expr - } - }); - - let serde_default_attr = quote! { #[serde(default = #fn_name)] }; - (serde_default_attr, schema_default_attr) - } + let tokens = quote!(NewUser from NonExistent); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("not found")); } -} -/// Return a type-appropriate (Rust default expression, OpenAPI default string) pair -/// for fields with SQL function defaults or implicit auto-increment. -/// -/// The Rust expression is used in the generated `#[serde(default = "fn")]` function body. -/// The OpenAPI string is used in `#[schema(default = "value")]`. -fn sql_function_default_for_type(original_ty: &syn::Type) -> Option<(TokenStream, String)> { - let syn::Type::Path(type_path) = original_ty else { - return None; - }; - let segment = type_path.path.segments.last()?; - let type_name = segment.ident.to_string(); - - match type_name.as_str() { - "DateTimeWithTimeZone" | "DateTimeUtc" | "DateTime" => { - let expr = quote! { - vespera::chrono::DateTime::::UNIX_EPOCH.fixed_offset() - }; - Some((expr, "1970-01-01T00:00:00+00:00".to_string())) - } - "NaiveDateTime" => { - let expr = quote! { - vespera::chrono::NaiveDateTime::UNIX_EPOCH - }; - Some((expr, "1970-01-01T00:00:00".to_string())) - } - "NaiveDate" => { - let expr = quote! { - vespera::chrono::NaiveDate::default() - }; - Some((expr, "1970-01-01".to_string())) - } - "NaiveTime" | "Time" => { - let expr = quote! { - vespera::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap() - }; - Some((expr, "00:00:00".to_string())) - } - "Uuid" => Some(( - quote! { Default::default() }, - "00000000-0000-0000-0000-000000000000".to_string(), - )), - "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128" - | "usize" | "f32" | "f64" | "Decimal" => { - Some((quote! { Default::default() }, "0".to_string())) + #[test] + fn test_generate_schema_type_code_success() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(CreateUser from User, pick = ["name"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("CreateUser")); + assert!(output.contains("name")); + } + + #[test] + fn test_generate_schema_type_code_with_omit() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String, pub password: String }", + )]); + + let tokens = quote!(SafeUser from User, omit = ["password"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("SafeUser")); + assert!(!output.contains("password")); + } + + #[test] + fn test_generate_schema_type_code_with_add() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserWithExtra from User, add = [("extra": String)]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("UserWithExtra")); + assert!(output.contains("extra")); + } + + #[test] + fn test_generate_schema_type_code_relation_fields_can_be_omitted_and_readded_with_custom_types() + { + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "article")] + pub struct Model { + pub id: i64, + pub title: String, + pub user: HasOne, + pub category: HasOne, + pub article_review_users: HasMany + }"#, + )]); + + let tokens = quote!( + ArticleResponse from Model, + omit = ["user", "category", "article_review_users"], + add = [ + ("user": Option), + ("category": Option), + ("article_review_users": Vec) + ] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("pub user : Option < UserInArticle >")); + assert!(output.contains("pub category : Option < CategoryInArticle >")); + assert!(output.contains("pub article_review_users : Vec < ArticleReviewUserInArticle >")); + assert!(!output.contains("Box < Schema >")); + assert!(!output.contains("impl From")); + } + + #[test] + fn test_generate_schema_type_code_same_file_relation_adapters_for_add_mode() { + let storage = to_storage(vec![ + create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "article")] + pub struct Model { + pub id: i64, + pub title: String, + pub user: HasOne, + pub category: HasOne, + pub article_review_users: HasMany + }"#, + ), + create_test_struct_metadata( + "UserInArticle", + "struct UserInArticle { id: i32, name: String }", + ), + create_test_struct_metadata( + "CategoryInArticle", + "struct CategoryInArticle { id: i64, name: String }", + ), + ]); + + let tokens = quote!( + ArticleResponse from Model, + add = [("article_review_users": Vec)] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("pub user : __VesperaArticleResponseUserRelation")); + assert!(output.contains("pub category : __VesperaArticleResponseCategoryRelation")); + assert!(output.contains("impl From < Option <")); + assert!(output.contains("for __VesperaArticleResponseUserRelation")); + assert!(output.contains("for __VesperaArticleResponseCategoryRelation")); + assert!(output.contains("impl Clone for UserInArticle")); + assert!(output.contains("impl Clone for CategoryInArticle")); + } + + #[test] + fn test_maybe_generate_same_file_relation_override_skips_redundant_clone_and_deserialize_impls() + { + // Same-file relation override DTOs that ALREADY carry `Clone` and + // `Deserialize` derives must NOT have the macro re-emit those + // impls — otherwise the generated code would conflict with the + // user-provided derive. Hits the "DTO already has derive" empty- + // quote branches inside `maybe_generate_same_file_relation_override`. + let rel_info = RelationFieldInfo { + field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), + relation_type: "HasOne".to_string(), + schema_path: quote!(crate::models::user::Schema), + is_optional: true, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + }; + // Bare `Clone` and `Deserialize` idents — has_derive matches the + // single-segment path, hitting the empty-quote branches at lines + // 208 (clone_impl) and 222 (deserialize_impl). + let storage = to_storage(vec![create_test_struct_metadata( + "UserInArticle", + r"#[derive(Clone, Deserialize)] + struct UserInArticle { id: i32, name: String }", + )]); + let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); + + let (override_field_ty, helper_tokens) = + maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) + .expect("override generation should succeed") + .expect("DTO is present in storage → override should be generated"); + + let output = helper_tokens.to_string(); + let field_ty = override_field_ty.to_string(); + assert!( + field_ty.contains("__VesperaArticleResponseUserRelation"), + "expected override field type to reference relation adapter, got: {field_ty}" + ); + // No `impl Clone for UserInArticle` — DTO already derives Clone. + assert!( + !output.contains("impl Clone for UserInArticle"), + "macro should skip Clone impl when DTO already derives Clone, got: {output}" + ); + // No proxy `Deserialize` derive struct — DTO already derives Deserialize. + assert!( + !output.contains("__VesperaArticleResponseUserProxy"), + "macro should skip Deserialize proxy when DTO already derives Deserialize, got: {output}" + ); + // Relation wrapper struct still emitted regardless of derives. + assert!( + output.contains("__VesperaArticleResponseUserRelation"), + "relation wrapper missing: {output}" + ); + } + + #[test] + fn test_generate_schema_type_code_generates_from_impl() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserResponse from User, pick = ["id", "name"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("impl From")); + assert!(output.contains("for UserResponse")); + } + + #[test] + fn test_generate_schema_type_code_no_from_impl_with_add() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserWithExtra from User, add = [("extra": String)]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!( + output.contains("UserWithExtra"), + "expected struct UserWithExtra in output: {output}" + ); + assert!( + !output.contains("impl From"), + "expected no From impl when `add` is used: {output}" + ); + } + + // ======================== + // is_parseable_type tests + // ======================== + + #[test] + fn test_is_parseable_type_primitives() { + for ty_str in &[ + "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize", + "f32", "f64", "bool", "String", "Decimal", + ] { + let ty: syn::Type = syn::parse_str(ty_str).unwrap(); + assert!(is_parseable_type(&ty), "{ty_str} should be parseable"); } - "bool" => Some((quote! { Default::default() }, "false".to_string())), - "String" => Some((quote! { Default::default() }, String::new())), - _ => None, } -} -/// Check if a type is known to implement `FromStr` and can use `.parse().unwrap()`. -/// -/// Returns true for primitive types, String, and Decimal. -/// Returns false for enums and unknown custom types. -fn is_parseable_type(ty: &syn::Type) -> bool { - let syn::Type::Path(type_path) = ty else { - return false; - }; - let Some(segment) = type_path.path.segments.last() else { - return false; - }; - type_utils::PRIMITIVE_TYPE_NAMES.contains(&segment.ident.to_string().as_str()) -} + #[test] + fn test_is_parseable_type_non_parseable() { + let ty: syn::Type = syn::parse_str("MyEnum").unwrap(); + assert!(!is_parseable_type(&ty)); + } -#[cfg(test)] -mod tests; + #[test] + fn test_is_parseable_type_non_path() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + assert!(!is_parseable_type(&ty)); + } +} diff --git a/crates/vespera_macro/src/schema_macro/same_file_override.rs b/crates/vespera_macro/src/schema_macro/same_file_override.rs new file mode 100644 index 00000000..d8e9bcc1 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/same_file_override.rs @@ -0,0 +1,556 @@ +//! Same-file relation override: route-local DTOs named +//! `{RelationPascal}In{ResponseBase}` replace single-value relation +//! schemas without changing handler construction code (see README +//! "Same-File Relation Adapters"). + +use std::borrow::Cow; +use std::collections::HashMap; + +use proc_macro2::TokenStream; +use quote::quote; + +use super::file_cache; +use super::seaorm::RelationFieldInfo; +use super::type_utils::snake_to_pascal_case; +use crate::metadata::StructMetadata; +#[cfg(test)] +pub(super) struct __VesperaSameFileLookupFixture { + value: i32, +} + +pub(super) fn derive_response_base_name(name: &str) -> String { + for suffix in ["Response", "Request", "Schema"] { + if let Some(stripped) = name.strip_suffix(suffix) + && !stripped.is_empty() + { + return stripped.to_string(); + } + } + name.to_string() +} + +pub(super) fn find_same_file_struct_metadata<'a>( + struct_name: &str, + schema_storage: &'a HashMap, +) -> Option> { + // Cache hit: hand back a borrow so the (potentially large) struct + // definition string is not cloned per lookup. The fallback path + // produces an owned `StructMetadata` from disk, so the unified return + // type is `Cow<'_, StructMetadata>`. + if let Some(metadata) = schema_storage.get(struct_name) { + return Some(Cow::Borrowed(metadata)); + } + + let file_path = proc_macro2::Span::call_site().local_file(); + #[cfg(test)] + let file_path = file_path.or_else(|| { + Some( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("schema_macro") + .join("same_file_override.rs"), + ) + }); + let file_path = file_path?; + let definition = file_cache::get_struct_definition(&file_path, struct_name)?; + Some(Cow::Owned(StructMetadata::new( + struct_name.to_string(), + definition, + ))) +} + +pub(super) fn related_model_type_from_schema_path(schema_path: &TokenStream) -> Option { + // Map a schema path (`…::UserSchema`) to its model path (`…::UserModel`) + // by renaming ONLY the final path segment's identifier. The previous + // `to_string().replace("Schema", "Model")` rewrote EVERY "Schema" + // substring in the whole path string, so a *module* segment that itself + // contained "Schema" (e.g. `crate::SchemaStore::UserSchema`) was silently + // corrupted into `crate::ModelStore::UserModel`, producing a dangling / + // wrong `From` target. Parsing to a `TypePath` and editing only + // the last segment keeps every module segment verbatim and preserves any + // generic arguments on the segment. + let mut type_path: syn::TypePath = syn::parse2(schema_path.clone()).ok()?; + let last = type_path.path.segments.last_mut()?; + let renamed = last.ident.to_string().replace("Schema", "Model"); + last.ident = syn::Ident::new(&renamed, last.ident.span()); + Some(syn::Type::Path(type_path)) +} + +pub(super) fn has_derive(struct_item: &syn::ItemStruct, derive_name: &str) -> bool { + struct_item.attrs.iter().any(|attr| { + if !attr.path().is_ident("derive") { + return false; + } + + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident(derive_name) { + found = true; + } + Ok(()) + }); + found + }) +} + +pub(super) fn build_named_struct_field_assignments( + struct_item: &syn::ItemStruct, + source_expr: &TokenStream, +) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let assignments = fields_named + .named + .iter() + .filter_map(|field| { + field.ident.as_ref().map(|ident| { + quote! { #ident: #source_expr . #ident.clone() } + }) + }) + .collect(); + + Ok(assignments) +} + +pub(super) fn build_proxy_fields(struct_item: &syn::ItemStruct) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let fields = fields_named + .named + .iter() + .filter_map(|field| { + field.ident.as_ref().map(|ident| { + let ty = &field.ty; + let attrs: Vec<_> = field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("doc")) + .collect(); + quote! { + #(#attrs)* + #ident: #ty + } + }) + }) + .collect(); + + Ok(fields) +} + +pub(super) fn build_proxy_to_dto_assignments( + struct_item: &syn::ItemStruct, +) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let assignments = fields_named + .named + .iter() + .filter_map(|field| { + field + .ident + .as_ref() + .map(|ident| quote! { #ident: proxy.#ident }) + }) + .collect(); + + Ok(assignments) +} + +pub(super) fn build_clone_assignments( + struct_item: &syn::ItemStruct, +) -> syn::Result> { + let syn::Fields::Named(fields_named) = &struct_item.fields else { + return Err(syn::Error::new_spanned( + struct_item, + "same-file relation override DTO must be a named-field struct", + )); + }; + + let assignments = fields_named + .named + .iter() + .filter_map(|field| { + field.ident.as_ref().map(|ident| { + quote! { #ident: self.#ident.clone() } + }) + }) + .collect(); + + Ok(assignments) +} + +/// The OpenAPI component name the adapter DTO is emitted under — its +/// `#[schema(name = "...")]` override when present, else the struct name. +fn dto_schema_ref_name(dto_struct: &syn::ItemStruct, dto_name: &str) -> String { + crate::schema_impl::extract_schema_name_attr(&dto_struct.attrs) + .unwrap_or_else(|| dto_name.to_string()) +} + +pub(super) fn maybe_generate_same_file_relation_override( + new_type_name: &syn::Ident, + field_name: &str, + rel_info: &RelationFieldInfo, + schema_storage: &HashMap, +) -> syn::Result> { + let response_base = derive_response_base_name(&new_type_name.to_string()); + let dto_name = format!("{}In{}", snake_to_pascal_case(field_name), response_base); + let Some(dto_meta) = find_same_file_struct_metadata(&dto_name, schema_storage) else { + return Ok(None); + }; + + let dto_struct: syn::ItemStruct = file_cache::parse_struct_cached(&dto_meta.definition) + .map_err(|e| syn::Error::new(proc_macro2::Span::call_site(), e.to_string()))?; + let dto_ident = syn::Ident::new(&dto_name, proc_macro2::Span::call_site()); + let wrapper_ident = syn::Ident::new( + &format!( + "__Vespera{}{}Relation", + new_type_name, + snake_to_pascal_case(field_name) + ), + proc_macro2::Span::call_site(), + ); + let proxy_ident = syn::Ident::new( + &format!( + "__Vespera{}{}Proxy", + new_type_name, + snake_to_pascal_case(field_name) + ), + proc_macro2::Span::call_site(), + ); + // B6: $ref the adapter DTO's own schema component (honoring its + // `#[schema(name = ...)]` override), not the base relation schema. + let schema_ref_name = dto_schema_ref_name(&dto_struct, &dto_name); + + let dto_serde_attrs: Vec<_> = dto_struct + .attrs + .iter() + .filter(|attr| attr.path().is_ident("serde")) + .collect(); + let dto_doc_attrs: Vec<_> = dto_struct + .attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .collect(); + + let proxy_fields = build_proxy_fields(&dto_struct)?; + let proxy_to_dto = build_proxy_to_dto_assignments(&dto_struct)?; + let clone_assignments = build_clone_assignments(&dto_struct)?; + let Some(model_ty) = related_model_type_from_schema_path(&rel_info.schema_path) else { + return Ok(None); + }; + let source_expr = quote! { source }; + let from_model_assignments = build_named_struct_field_assignments(&dto_struct, &source_expr)?; + + // Coalesced helpers: previously three separate `quote!` invocations + // and a `Vec` accumulator were stitched together with + // `#(#helper_tokens)*`. We instead build the conditional Clone / + // Deserialize sub-blocks as their own `TokenStream`s and splice + // them into a single `quote!`, producing the same emitted Rust code + // with one accumulator allocation removed. + let clone_impl = if has_derive(&dto_struct, "Clone") { + quote! {} + } else { + quote! { + impl Clone for #dto_ident { + fn clone(&self) -> Self { + Self { + #(#clone_assignments),* + } + } + } + } + }; + + let deserialize_impl = if has_derive(&dto_struct, "Deserialize") { + quote! {} + } else { + quote! { + #[derive(serde::Deserialize)] + #(#dto_serde_attrs)* + struct #proxy_ident { + #(#proxy_fields),* + } + + impl<'de> serde::Deserialize<'de> for #dto_ident { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let proxy = #proxy_ident::deserialize(deserializer)?; + Ok(Self { + #(#proxy_to_dto),* + }) + } + } + } + }; + + let helpers = quote! { + #clone_impl + #deserialize_impl + + impl From<#model_ty> for #dto_ident { + fn from(source: #model_ty) -> Self { + Self { + #(#from_model_assignments),* + } + } + } + + #(#dto_doc_attrs)* + #[derive(serde::Serialize, serde::Deserialize, Clone, vespera::Schema)] + #[serde(transparent)] + #[schema(ref = #schema_ref_name, nullable)] + struct #wrapper_ident(pub Option<#dto_ident>); + + impl From> for #wrapper_ident { + fn from(source: Option<#model_ty>) -> Self { + Self(source.map(Into::into)) + } + } + }; + + Ok(Some((quote! { #wrapper_ident }, helpers))) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use quote::quote; + + use super::*; + use crate::metadata::StructMetadata; + use crate::schema_macro::seaorm::RelationFieldInfo; + use crate::schema_macro::{SchemaTypeInput, generate_schema_type_code}; + + fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { + StructMetadata::new(name.to_string(), definition.to_string()) + } + + fn to_storage(items: Vec) -> HashMap { + items.into_iter().map(|s| (s.name.clone(), s)).collect() + } + + #[test] + fn test_derive_response_base_name_handles_known_suffixes_and_fallback() { + assert_eq!(derive_response_base_name("UserResponse"), "User"); + assert_eq!(derive_response_base_name("UserRequest"), "User"); + assert_eq!(derive_response_base_name("UserSchema"), "User"); + assert_eq!(derive_response_base_name("User"), "User"); + } + + #[test] + fn test_find_same_file_struct_metadata_reads_test_fixture_from_current_module() { + let storage: HashMap = HashMap::new(); + let metadata = find_same_file_struct_metadata("__VesperaSameFileLookupFixture", &storage) + .expect("fixture should be found in schema_macro/same_file_override.rs"); + + assert_eq!(metadata.name, "__VesperaSameFileLookupFixture"); + assert!( + metadata + .definition + .contains("__VesperaSameFileLookupFixture") + ); + assert!(metadata.definition.contains("value")); + } + + #[test] + fn test_has_derive_ignores_non_derive_attrs_and_detects_requested_derive() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[serde(rename_all = "camelCase")] + #[derive(Clone, Debug)] + struct Sample { + value: i32, + } + "#, + ) + .unwrap(); + + assert!(has_derive(&struct_item, "Clone")); + assert!(!has_derive(&struct_item, "Deserialize")); + } + + #[test] + fn test_build_named_struct_field_assignments_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let source_expr = quote!(source); + let error = build_named_struct_field_assignments(&struct_item, &source_expr).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); + } + + #[test] + fn test_build_proxy_fields_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let error = build_proxy_fields(&struct_item).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); + } + + #[test] + fn test_build_proxy_to_dto_assignments_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let error = build_proxy_to_dto_assignments(&struct_item).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); + } + + #[test] + fn test_build_clone_assignments_rejects_tuple_structs() { + let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); + let error = build_clone_assignments(&struct_item).unwrap_err(); + assert!(error.to_string().contains("named-field struct")); + } + + #[test] + fn test_maybe_generate_same_file_relation_override_returns_none_when_dto_is_missing() { + let rel_info = RelationFieldInfo { + field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), + relation_type: "HasOne".to_string(), + schema_path: quote!(crate::models::user::Schema), + is_optional: true, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + }; + + let storage: HashMap = HashMap::new(); + let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); + + let result = + maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) + .expect("missing dto should not error"); + assert!(result.is_none()); + } + + #[test] + fn test_maybe_generate_same_file_relation_override_returns_none_for_invalid_model_type() { + let rel_info = RelationFieldInfo { + field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), + relation_type: "HasOne".to_string(), + schema_path: quote!(?), + is_optional: true, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + }; + + let storage = to_storage(vec![create_test_struct_metadata( + "UserInArticle", + "struct UserInArticle { id: i32 }", + )]); + let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); + + let result = + maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) + .expect("invalid model type should not error"); + assert!(result.is_none()); + } + + #[test] + fn related_model_type_renames_only_final_segment() { + // A module segment that itself contains "Schema" (capital S) must be + // left verbatim — only the trailing `…Schema` ident becomes `…Model`. + // The previous `to_string().replace("Schema","Model")` corrupted + // `SchemaStore` into `ModelStore`; this locks the regression. + let ty = related_model_type_from_schema_path("e!(crate::SchemaStore::user::UserSchema)) + .expect("valid schema path resolves to a model type"); + assert_eq!( + quote!(#ty).to_string(), + quote!(crate::SchemaStore::user::UserModel).to_string(), + ); + // A bare trailing `Schema` ident maps to `Model`. + let ty2 = related_model_type_from_schema_path("e!(crate::models::user::Schema)) + .expect("valid schema path"); + assert_eq!( + quote!(#ty2).to_string(), + quote!(crate::models::user::Model).to_string(), + ); + // A non-path token stream (e.g. a stray `?`) yields None, not a panic. + assert!(related_model_type_from_schema_path("e!(?)).is_none()); + } + + #[test] + fn test_generate_schema_type_code_normal_mode_relation_rename_and_custom_name() { + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "articles")] + pub struct Model { + pub id: i32, + pub name: String, + pub owner: HasOne + }"#, + )]); + + let tokens = quote!( + ArticleResponse from Model, + name = "CustomArticleSchema", + rename = [("name", "display_name")] + ); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("display_name")); + assert!(output.contains("owner")); + assert!(output.contains("Clone")); + assert!(output.contains("CustomArticleSchema")); + assert_eq!(metadata.unwrap().name, "CustomArticleSchema"); + } + + #[test] + fn override_ref_honors_dto_schema_name_attribute() { + // When the adapter DTO overrides its OpenAPI component name via + // `#[schema(name = "...")]`, the generated wrapper's `#[schema(ref = ...)]` + // must use that name (not the Rust struct name) so the emitted `$ref` + // resolves instead of dangling. + let rel_info = RelationFieldInfo { + field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), + relation_type: "HasOne".to_string(), + schema_path: quote!(crate::models::user::Schema), + is_optional: true, + inline_type_info: None, + relation_enum: None, + fk_column: None, + via_rel: None, + }; + let storage = to_storage(vec![create_test_struct_metadata( + "UserInArticle", + r#"#[schema(name = "ArticleUser")] struct UserInArticle { id: i32, name: String }"#, + )]); + let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); + + let (_field_ty, helpers) = + maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) + .expect("override generation should succeed") + .expect("DTO present → override generated"); + + let output = helpers.to_string(); + assert!( + output.contains("ref = \"ArticleUser\""), + "wrapper $ref must use the DTO's #[schema(name=...)] override, got: {output}" + ); + assert!( + !output.contains("ref = \"UserInArticle\""), + "must not fall back to the struct name when a name override exists, got: {output}" + ); + } +} diff --git a/crates/vespera_macro/src/schema_macro/seaorm.rs b/crates/vespera_macro/src/schema_macro/seaorm.rs index 97ba9f50..fa52df46 100644 --- a/crates/vespera_macro/src/schema_macro/seaorm.rs +++ b/crates/vespera_macro/src/schema_macro/seaorm.rs @@ -1,1259 +1,347 @@ -//! `SeaORM` and Chrono type conversions -//! -//! Handles conversion of `SeaORM` relation types and datetime types to their -//! schema equivalents. - -use proc_macro2::TokenStream; -use quote::quote; -use syn::Type; +//! `SeaORM` and Chrono type conversions. + +mod attrs; +mod conversion; +mod relations; + +#[allow(unused_imports)] +pub use attrs::{ + extract_belongs_to_from_field, extract_relation_enum, extract_sea_orm_default_value, + extract_via_rel, has_sea_orm_primary_key, is_sql_function_default, +}; +#[allow(unused_imports)] +pub use conversion::{convert_seaorm_type_to_chrono, convert_type_with_chrono}; +#[allow(unused_imports)] +pub use relations::{ + RelationFieldInfo, convert_relation_type_to_schema_with_info, is_field_optional_in_struct, +}; + +// Circular-relation integration tests live here because relation +// conversion (`convert_relation_type_to_schema_with_info`) is the +// seaorm-owned behavior they exercise end-to-end. +#[cfg(test)] +mod circular_relation_tests { + use std::collections::HashMap; -use super::type_utils::{is_option_type, resolve_type_to_absolute_path}; + use quote::quote; + use serial_test::serial; -/// Relation field info for generating `from_model` code -#[derive(Clone)] -pub struct RelationFieldInfo { - /// Field name in the generated struct - pub field_name: syn::Ident, - /// Relation type: "`HasOne`", "`HasMany`", or "`BelongsTo`" - pub relation_type: String, - /// Target Schema path (e.g., `crate::models::user::Schema`) - pub schema_path: TokenStream, - /// Whether the relation is optional - pub is_optional: bool, - /// If Some, this relation has circular refs and uses an inline type - /// Contains: (`inline_type_name`, `circular_fields_to_exclude`) - pub inline_type_info: Option<(syn::Ident, Vec)>, - /// The `relation_enum` attribute value (e.g., "`TargetUser`", "`CreatedByUser`") - /// When present, indicates multiple relations to the same Entity type exist - pub relation_enum: Option, - /// The FK column name from `from` attribute (e.g., "`user_id`", "`target_user_id`") - pub fk_column: Option, - /// The `via_rel` attribute value for `HasMany` relations (e.g., "`TargetUser`") - /// This specifies which Relation variant on the TARGET entity to use - pub via_rel: Option, -} + use crate::metadata::StructMetadata; + use crate::schema_macro::{SchemaTypeInput, generate_schema_type_code}; -/// Convert `SeaORM` datetime types to chrono equivalents. -/// -/// This allows generated schemas to use standard chrono types instead of -/// requiring `use sea_orm::entity::prelude::DateTimeWithTimeZone`. -/// -/// Conversions: -/// - `DateTimeWithTimeZone` -> `chrono::DateTime` -/// - `DateTimeUtc` -> `chrono::DateTime` -/// - `DateTimeLocal` -> `chrono::DateTime` -/// - `DateTime` (`SeaORM`) -> `chrono::NaiveDateTime` -/// - `Date` (`SeaORM`) -> `chrono::NaiveDate` -/// - `Time` (`SeaORM`) -> `chrono::NaiveTime` -/// -/// Returns the original type as `TokenStream` if not a `SeaORM` datetime type. -pub fn convert_seaorm_type_to_chrono(ty: &Type, source_module_path: &[String]) -> TokenStream { - let Type::Path(type_path) = ty else { - return quote! { #ty }; - }; + // ============================================================ + // Tests for BelongsTo/HasOne circular reference inline types + // ============================================================ - let Some(segment) = type_path.path.segments.last() else { - return quote! { #ty }; - }; + #[test] + #[serial] + fn test_generate_schema_type_code_belongs_to_circular_inline_optional() { + // Tests: BelongsTo with circular reference, optional field (is_optional = true) + use tempfile::TempDir; - let ident_str = segment.ident.to_string(); + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); - match ident_str.as_str() { - // Use vespera::chrono to avoid requiring users to add chrono dependency - "DateTimeWithTimeZone" => { - quote! { vespera::chrono::DateTime } - } - "DateTimeUtc" => quote! { vespera::chrono::DateTime }, - "DateTimeLocal" => quote! { vespera::chrono::DateTime }, - // Multipart types - resolve via vespera::multipart - "FieldData" => { - // Preserve inner generic: FieldData → vespera::multipart::FieldData - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - let inner_args: Vec<_> = args - .args - .iter() - .map(|arg| { - if let syn::GenericArgument::Type(inner_ty) = arg { - let converted = - convert_seaorm_type_to_chrono(inner_ty, source_module_path); - quote! { #converted } - } else { - quote! { #arg } - } - }) - .collect(); - quote! { vespera::multipart::FieldData<#(#inner_args),*> } + // Create user.rs with Model that references memo (circular) + let user_model = r#" +#[sea_orm(table_name = "users")] +pub struct Model { + pub id: i32, + pub name: String, + pub memo: BelongsTo, +} +"#; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); + + // Create memo.rs with Model that references user (completing the circle) + let memo_model = r#" +#[sea_orm(table_name = "memos")] +pub struct Model { + pub id: i32, + pub title: String, + pub user_id: i32, + pub user: BelongsTo, +} +"#; + std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); + + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + // Generate schema from memo - has BelongsTo user which has circular ref back + let tokens = quote!(MemoSchema from crate::models::memo::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + + let result = generate_schema_type_code(&input, &storage); + + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded + unsafe { + if let Some(dir) = original_manifest_dir { + std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { - quote! { vespera::multipart::FieldData } + std::env::remove_var("CARGO_MANIFEST_DIR"); } } - "NamedTempFile" => quote! { vespera::tempfile::NamedTempFile }, - // Not a SeaORM datetime type - resolve to absolute path if needed - _ => resolve_type_to_absolute_path(ty, source_module_path), - } -} - -/// Convert a type to chrono equivalent, handling Option wrapper. -/// -/// If the type is `Option`, converts to `Option`. -/// If the type is just `SeaOrmType`, converts to `ChronoType`. -/// -/// Also resolves local types (like `MemoStatus`) to absolute paths -/// (like `crate::models::memo::MemoStatus`) using `source_module_path`. -pub fn convert_type_with_chrono(ty: &Type, source_module_path: &[String]) -> TokenStream { - // Check if it's Option - if let Type::Path(type_path) = ty - && let Some(segment) = type_path.path.segments.first() - && segment.ident == "Option" - { - // Extract the inner type from Option - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - { - let converted_inner = convert_seaorm_type_to_chrono(inner_ty, source_module_path); - return quote! { Option<#converted_inner> }; - } - } - // Check if it's Vec - if let Type::Path(type_path) = ty - && let Some(segment) = type_path.path.segments.first() - && segment.ident == "Vec" - { - // Extract the inner type from Vec - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() - { - let converted_inner = convert_seaorm_type_to_chrono(inner_ty, source_module_path); - return quote! { Vec<#converted_inner> }; - } + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should have inline type definition for circular relation + assert!(output.contains("MemoSchema")); + assert!(output.contains("user")); + // BelongsTo is optional by default, so should have Option> + assert!(output.contains("Option < Box <")); } - // Not Option or Vec, convert directly - convert_seaorm_type_to_chrono(ty, source_module_path) -} - -/// Extract a named string value from a `sea_orm` attribute. -/// Shared helper for `extract_belongs_to_from_field`, `extract_relation_enum`, and `extract_via_rel`. -fn extract_sea_orm_attr_value(attrs: &[syn::Attribute], attr_name: &str) -> Option { - attrs.iter().find_map(|attr| { - if !attr.path().is_ident("sea_orm") { - return None; - } - - let mut found_value = None; - // Ignore parse errors — we just won't find the field if parsing fails - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident(attr_name) { - found_value = meta - .value() - .ok() - .and_then(|v| v.parse::().ok()) - .map(|lit| lit.value()); - } else if meta.input.peek(syn::Token![=]) { - // Consume value for other key=value pairs - // Required to allow parsing to continue to next item - drop( - meta.value() - .and_then(syn::parse::ParseBuffer::parse::), - ); - } - Ok(()) - }); - found_value - }) -} + #[test] + #[serial] + fn test_generate_schema_type_code_has_one_circular_inline_required() { + // Tests: HasOne with circular reference, required field (is_optional = false) + use tempfile::TempDir; -/// Extract the "from" field name from a `sea_orm` `belongs_to` attribute. -/// e.g., `#[sea_orm(belongs_to, from = "user_id", to = "id")]` -> `Some("user_id")` -/// Also handles: `#[sea_orm(belongs_to = "Entity", from = "user_id", to = "id")]` -pub fn extract_belongs_to_from_field(attrs: &[syn::Attribute]) -> Option { - extract_sea_orm_attr_value(attrs, "from") -} + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); -/// Extract the "`relation_enum`" value from a `sea_orm` attribute. -/// e.g., `#[sea_orm(belongs_to, relation_enum = "TargetUser", from = "target_user_id")]` -> Some("TargetUser") -/// -/// When `relation_enum` is present, it indicates that multiple relations to the same -/// Entity type exist, and we need to use the specific Relation enum variant for queries. -pub fn extract_relation_enum(attrs: &[syn::Attribute]) -> Option { - extract_sea_orm_attr_value(attrs, "relation_enum") + // Create profile.rs with Model that references user (circular) + let profile_model = r#" +#[sea_orm(table_name = "profiles")] +pub struct Model { + pub id: i32, + pub bio: String, + pub user: BelongsTo, } - -/// Extract the "`via_rel`" value from a `sea_orm` attribute. -/// e.g., `#[sea_orm(has_many, relation_enum = "TargetUser", via_rel = "TargetUser")]` -> Some("TargetUser") -/// -/// For `HasMany` relations with `relation_enum`, `via_rel` specifies which Relation variant -/// on the TARGET entity corresponds to this relation. This allows us to find the FK column. -pub fn extract_via_rel(attrs: &[syn::Attribute]) -> Option { - extract_sea_orm_attr_value(attrs, "via_rel") +"#; + std::fs::write(models_dir.join("profile.rs"), profile_model).unwrap(); + + // Create user.rs with Model that has HasOne profile + // HasOne with required FK becomes required (non-optional) + let user_model = r#" +#[sea_orm(table_name = "users")] +pub struct Model { + pub id: i32, + pub name: String, + pub profile_id: i32, + #[sea_orm(has_one = "super::profile::Entity", from = "profile_id")] + pub profile: HasOne, } - -/// Extract `default_value` from a `sea_orm` attribute. -/// e.g., `#[sea_orm(default_value = 0.7)]` -> `Some("0.7")` -/// e.g., `#[sea_orm(default_value = "active")]` -> `Some("active")` -pub fn extract_sea_orm_default_value(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - if !attr.path().is_ident("sea_orm") { - continue; - } - - // Use raw token string parsing to handle all literal types - // (parse_nested_meta can't easily parse non-string literals after `=`) - let syn::Meta::List(meta_list) = &attr.meta else { - continue; - }; - let tokens = meta_list.tokens.to_string(); - - if let Some(start) = tokens.find("default_value") { - let remaining = &tokens[start + "default_value".len()..]; - let remaining = remaining.trim_start(); - if let Some(after_eq) = remaining.strip_prefix('=') { - let value_str = after_eq.trim_start(); - // Extract value until comma or end of tokens - let end = value_str.find(',').unwrap_or(value_str.len()); - let raw_value = value_str[..end].trim(); - - if raw_value.is_empty() { - continue; - } - - // If quoted string, strip quotes and return inner value - if let Some(inner) = raw_value - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - { - return Some(inner.to_string()); - } - // Numeric, bool, or other literal — return as-is - return Some(raw_value.to_string()); +"#; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); + + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + // Generate schema from user - has HasOne profile which has circular ref back + let tokens = quote!(UserSchema from crate::models::user::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + + let result = generate_schema_type_code(&input, &storage); + + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded + unsafe { + if let Some(dir) = original_manifest_dir { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); } } - } - None -} - -/// Check if a `sea_orm(default_value)` is a SQL function (e.g., `"NOW()"`, `"CURRENT_TIMESTAMP()"`, `"UUID()"`) -/// that cannot be converted to a Rust default value. -/// -/// Detection: any value containing parentheses is treated as a SQL function call. -pub fn is_sql_function_default(value: &str) -> bool { - value.contains('(') -} -/// Check if a field has `#[sea_orm(primary_key)]`. -/// -/// Primary keys in SeaORM imply auto-increment by default, -/// meaning the database provides a value even when the client omits it. -pub fn has_sea_orm_primary_key(attrs: &[syn::Attribute]) -> bool { - for attr in attrs { - if !attr.path().is_ident("sea_orm") { - continue; - } - let syn::Meta::List(meta_list) = &attr.meta else { - continue; - }; - let tokens = meta_list.tokens.to_string(); - if tokens.contains("primary_key") { - return true; - } - } - false + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should have inline type definition for circular relation + assert!(output.contains("UserSchema")); + assert!(output.contains("profile")); + // HasOne with required FK should have Box<...> (not Option>) + assert!(output.contains("Box <")); + } + + #[test] + #[serial] + fn test_generate_schema_type_code_belongs_to_circular_inline_required_file() { + // Tests: BelongsTo with circular reference AND required FK (is_optional = false) + // This requires file-based lookup with: + // 1. #[sea_orm(from = "required_fk")] where required_fk is NOT Option + // 2. Circular reference between two models + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().join("src"); + let models_dir = src_dir.join("models"); + std::fs::create_dir_all(&models_dir).unwrap(); + + // Create user.rs with Model that references memo (circular) + let user_model = r#" +#[sea_orm(table_name = "users")] +pub struct Model { + pub id: i32, + pub name: String, + pub memo_id: i32, + #[sea_orm(belongs_to, from = "memo_id", to = "id")] + pub memo: BelongsTo, } - -/// Check if a field in the struct is optional (Option). -pub fn is_field_optional_in_struct(struct_item: &syn::ItemStruct, field_name: &str) -> bool { - if let syn::Fields::Named(fields_named) = &struct_item.fields { - for field in &fields_named.named { - if let Some(ident) = &field.ident - && ident == field_name - { - return is_option_type(&field.ty); - } - } - } - false +"#; + std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); + + // Create memo.rs with Model that references user (completing the circle) + // Note: using flag-style `belongs_to` with `from = "user_id"` + let memo_model = r#" +#[sea_orm(table_name = "memos")] +pub struct Model { + pub id: i32, + pub title: String, + pub user_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: BelongsTo, } - -/// Convert a `SeaORM` relation type to a Schema type AND return relation info. -/// -/// - `#[sea_orm(has_one)]` -> Always `Option>` -/// - `#[sea_orm(has_many)]` -> Always `Vec` -/// - `#[sea_orm(belongs_to, from = "field")]`: -/// - If `from` field is `Option` -> `Option>` -/// - If `from` field is required -> `Box` -/// -/// The `source_module_path` is used to resolve relative paths like `super::`. -/// e.g., if source is `crate::models::memo::Model`, module path is `crate::models::memo` -/// -/// Returns None if the type is not a relation type or conversion fails. -/// Returns (`TokenStream`, `RelationFieldInfo`) on success for use in `from_model` generation. -#[allow(clippy::too_many_lines)] -pub fn convert_relation_type_to_schema_with_info( - ty: &Type, - field_attrs: &[syn::Attribute], - parsed_struct: &syn::ItemStruct, - source_module_path: &[String], - field_name: syn::Ident, -) -> Option<(TokenStream, RelationFieldInfo)> { - let Type::Path(type_path) = ty else { - return None; - }; - - let segment = type_path.path.segments.last()?; - let ident_str = segment.ident.to_string(); - - // Check if this is a relation type with generic argument - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - return None; - }; - - // Get the inner Entity type - let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() else { - return None; - }; - - // Extract the path and convert to absolute Schema path - let Type::Path(inner_path) = inner_ty else { - return None; - }; - - // Collect segments as strings - let segments: Vec = inner_path - .path - .segments - .iter() - .map(|s| s.ident.to_string()) - .collect(); - - // Convert path to absolute, resolving `super::` relative to source module - let absolute_segments: Vec = if !segments.is_empty() && segments[0] == "super" { - let super_count = segments.iter().take_while(|s| *s == "super").count(); - let parent_path_len = source_module_path.len().saturating_sub(super_count); - let mut abs = Vec::with_capacity(parent_path_len + segments.len() - super_count); - abs.extend_from_slice(&source_module_path[..parent_path_len]); - for seg in segments.iter().skip(super_count) { - if seg == "Entity" { - abs.push("Schema".to_string()); - } else { - abs.push(seg.clone()); - } - } - abs - } else if !segments.is_empty() && segments[0] == "crate" { - segments - .iter() - .map(|s| { - if s == "Entity" { - "Schema".to_string() - } else { - s.clone() - } - }) - .collect() - } else { - let parent_path_len = source_module_path.len().saturating_sub(1); - let mut abs = Vec::with_capacity(parent_path_len + segments.len()); - abs.extend_from_slice(&source_module_path[..parent_path_len]); - for seg in &segments { - if seg == "Entity" { - abs.push("Schema".to_string()); +"#; + std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); + + // Save original CARGO_MANIFEST_DIR + let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This is a test that runs single-threaded + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + // Generate schema from memo - has BelongsTo user which has circular ref back + // The user_id field is required (not Option), so is_optional = false + // This should generate Box<...> instead of Option> + let tokens = quote!(MemoSchema from crate::models::memo::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let storage: HashMap = HashMap::new(); + + let result = generate_schema_type_code(&input, &storage); + + // Restore CARGO_MANIFEST_DIR + // SAFETY: This is a test that runs single-threaded + unsafe { + if let Some(dir) = original_manifest_dir { + std::env::set_var("CARGO_MANIFEST_DIR", dir); } else { - abs.push(seg.clone()); + std::env::remove_var("CARGO_MANIFEST_DIR"); } } - abs - }; - - // Build the absolute path as tokens - let path_idents: Vec = absolute_segments - .iter() - .map(|s| syn::Ident::new(s, proc_macro2::Span::call_site())) - .collect(); - let schema_path = quote! { #(#path_idents)::* }; - - // Convert based on relation type - match ident_str.as_str() { - "HasOne" => { - // HasOne -> Check FK field to determine optionality - // If FK is Option -> relation is optional: Option> - // If FK is required -> relation is required: Box - let fk_field = extract_belongs_to_from_field(field_attrs); - let relation_enum = extract_relation_enum(field_attrs); - let is_optional = fk_field - .as_ref() - .is_none_or(|f| is_field_optional_in_struct(parsed_struct, f)); // Default to optional if we can't determine - - let converted = if is_optional { - quote! { Option> } - } else { - quote! { Box<#schema_path> } - }; - let info = RelationFieldInfo { - field_name, - relation_type: "HasOne".to_string(), - schema_path, - is_optional, - inline_type_info: None, // Will be populated later if circular - relation_enum, - fk_column: fk_field, - via_rel: None, // Not used for HasOne - }; - Some((converted, info)) - } - "HasMany" => { - let relation_enum = extract_relation_enum(field_attrs); - let via_rel = extract_via_rel(field_attrs); - let converted = quote! { Vec<#schema_path> }; - let info = RelationFieldInfo { - field_name, - relation_type: "HasMany".to_string(), - schema_path, - is_optional: false, - inline_type_info: None, // Will be populated later if circular - relation_enum, - fk_column: None, // HasMany doesn't have FK on this side - via_rel, // Used to find FK on target entity - }; - Some((converted, info)) - } - "BelongsTo" => { - // BelongsTo -> Check FK field to determine optionality - // If FK is Option -> relation is optional: Option> - // If FK is required -> relation is required: Box - let fk_field = extract_belongs_to_from_field(field_attrs); - let relation_enum = extract_relation_enum(field_attrs); - let is_optional = fk_field - .as_ref() - .is_none_or(|f| is_field_optional_in_struct(parsed_struct, f)); // Default to optional if we can't determine - - let converted = if is_optional { - quote! { Option> } - } else { - quote! { Box<#schema_path> } - }; - let info = RelationFieldInfo { - field_name, - relation_type: "BelongsTo".to_string(), - schema_path, - is_optional, - inline_type_info: None, // Will be populated later if circular - relation_enum, - fk_column: fk_field, - via_rel: None, // Not used for BelongsTo - }; - Some((converted, info)) - } - _ => None, - } -} - -/// Convert a SeaORM relation type to a Schema type. -/// -/// - `#[sea_orm(has_one)]` -> Always `Option>` -/// - `#[sea_orm(has_many)]` -> Always `Vec` -/// - `#[sea_orm(belongs_to, from = "field")]`: -/// - If `from` field is `Option` -> `Option>` -/// - If `from` field is required -> `Box` -/// -#[cfg(test)] -mod tests { - use rstest::rstest; - - use super::*; - #[rstest] - #[case( - "DateTimeWithTimeZone", - "vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset >" - )] - #[case( - "DateTimeUtc", - "vespera :: chrono :: DateTime < vespera :: chrono :: Utc >" - )] - #[case( - "DateTimeLocal", - "vespera :: chrono :: DateTime < vespera :: chrono :: Local >" - )] - fn test_convert_seaorm_type_to_chrono(#[case] input: &str, #[case] expected_contains: &str) { - let ty: syn::Type = syn::parse_str(input).unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); + assert!(result.is_ok(), "Should generate schema: {:?}", result.err()); + let (tokens, _metadata) = result.unwrap(); let output = tokens.to_string(); - assert!(output.contains(expected_contains)); - } - - #[test] - fn test_convert_seaorm_type_to_chrono_non_path_type() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); - assert!(output.contains("& str")); - } - - #[test] - fn test_convert_seaorm_type_to_chrono_regular_type() { - let ty: syn::Type = syn::parse_str("String").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); - assert_eq!(output.trim(), "String"); - } - - #[test] - fn test_convert_type_with_chrono_option_datetime() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - let tokens = convert_type_with_chrono(&ty, &[]); - let output = tokens.to_string(); - assert!(output.contains("Option <")); - assert!(output.contains("vespera :: chrono :: DateTime")); - } - - #[test] - fn test_convert_type_with_chrono_vec_datetime() { - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - let tokens = convert_type_with_chrono(&ty, &[]); - let output = tokens.to_string(); - assert!(output.contains("Vec <")); - assert!(output.contains("vespera :: chrono :: DateTime")); - } - - #[test] - fn test_convert_type_with_chrono_plain_type() { - let ty: syn::Type = syn::parse_str("i32").unwrap(); - let tokens = convert_type_with_chrono(&ty, &[]); - let output = tokens.to_string(); - assert_eq!(output.trim(), "i32"); - } - - #[test] - fn test_extract_belongs_to_from_field_with_from() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to, from = "user_id", to = "id")] - )]; - let result = extract_belongs_to_from_field(&attrs); - assert_eq!(result, Some("user_id".to_string())); - } - - #[test] - fn test_extract_belongs_to_from_field_without_from() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to, to = "id")] - )]; - let result = extract_belongs_to_from_field(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_belongs_to_from_field_no_sea_orm_attr() { - let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; - let result = extract_belongs_to_from_field(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_belongs_to_from_field_empty_attrs() { - let result = extract_belongs_to_from_field(&[]); - assert_eq!(result, None); - } - - #[test] - fn test_extract_relation_enum_with_value() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to, relation_enum = "TargetUser", from = "target_user_id", to = "id")] - )]; - let result = extract_relation_enum(&attrs); - assert_eq!(result, Some("TargetUser".to_string())); - } - - #[test] - fn test_extract_relation_enum_without_relation_enum() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to, from = "user_id", to = "id")] - )]; - let result = extract_relation_enum(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_relation_enum_no_sea_orm_attr() { - let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; - let result = extract_relation_enum(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_relation_enum_empty_attrs() { - let result = extract_relation_enum(&[]); - assert_eq!(result, None); - } - - #[test] - fn test_is_field_optional_in_struct_optional() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct Model { - id: i32, - user_id: Option, - } - ", - ) - .unwrap(); - assert!(is_field_optional_in_struct(&struct_item, "user_id")); - } - - #[test] - fn test_is_field_optional_in_struct_required() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct Model { - id: i32, - user_id: i32, - } - ", - ) - .unwrap(); - assert!(!is_field_optional_in_struct(&struct_item, "user_id")); - } - - #[test] - fn test_is_field_optional_in_struct_field_not_found() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - struct Model { - id: i32, - } - ", - ) - .unwrap(); - assert!(!is_field_optional_in_struct(&struct_item, "nonexistent")); - } - - #[test] - fn test_is_field_optional_in_struct_tuple_struct() { - let struct_item: syn::ItemStruct = - syn::parse_str("struct TupleStruct(i32, Option);").unwrap(); - assert!(!is_field_optional_in_struct(&struct_item, "0")); - } - - // ========================================================================= - // Tests for convert_seaorm_type_to_chrono edge cases - // ========================================================================= - - #[test] - fn test_convert_seaorm_type_to_chrono_empty_path() { - let ty = syn::Type::Path(syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: syn::punctuated::Punctuated::new(), - }, - }); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - // Should return the original type unchanged - assert!(tokens.to_string().is_empty() || tokens.to_string().trim().is_empty()); - } - - // ========================================================================= - // Tests for FieldData/NamedTempFile type conversion - // ========================================================================= - - #[test] - fn test_convert_seaorm_type_field_data_with_generic() { - // FieldData → vespera::multipart::FieldData - let ty: syn::Type = syn::parse_str("FieldData").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); - assert!( - output.contains("vespera :: multipart :: FieldData"), - "Should resolve FieldData via vespera::multipart: {output}" - ); + // Should have inline type definition for circular relation assert!( - output.contains("vespera :: tempfile :: NamedTempFile"), - "Should resolve inner NamedTempFile via vespera re-export: {output}" + output.contains("MemoSchema"), + "Should contain MemoSchema: {output}" ); - } - - #[test] - fn test_convert_seaorm_type_field_data_without_generic() { - // FieldData (no generics) → vespera::multipart::FieldData - let ty: syn::Type = syn::parse_str("FieldData").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); assert!( - output.contains("vespera :: multipart :: FieldData"), - "Should resolve bare FieldData: {output}" + output.contains("user"), + "Should contain user field: {output}" ); - // Should NOT contain nested generic + // BelongsTo with required FK (user_id: i32) should generate Box<...> not Option> assert!( - !output.contains("NamedTempFile"), - "Bare FieldData should not have NamedTempFile: {output}" - ); - } - - #[test] - fn test_convert_seaorm_type_field_data_with_non_type_generic() { - // FieldData with a non-Type generic arg (e.g., lifetime) should use fallback quote - let ty: syn::Type = syn::parse_str("FieldData<'a>").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); - assert!( - output.contains("vespera :: multipart :: FieldData"), - "Should still resolve FieldData: {output}" - ); - } - - #[test] - fn test_convert_seaorm_type_named_temp_file() { - // NamedTempFile → vespera::tempfile::NamedTempFile - let ty: syn::Type = syn::parse_str("NamedTempFile").unwrap(); - let tokens = convert_seaorm_type_to_chrono(&ty, &[]); - let output = tokens.to_string(); - assert_eq!(output.trim(), "vespera :: tempfile :: NamedTempFile"); - } - - #[test] - fn test_convert_type_with_chrono_json_alias_uses_public_value_path() { - let ty: syn::Type = syn::parse_str("Json").unwrap(); - let tokens = convert_type_with_chrono( - &ty, - &[ - "crate".to_string(), - "models".to_string(), - "json_case".to_string(), - ], - ); - let output = tokens.to_string(); - assert_eq!(output.trim(), "vespera :: serde_json :: Value"); - } - - // ========================================================================= - // Tests for convert_relation_type_to_schema_with_info - // ========================================================================= - - fn make_test_struct(def: &str) -> syn::ItemStruct { - syn::parse_str(def).unwrap() - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_non_path_type() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_empty_segments() { - let ty = syn::Type::Path(syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: syn::punctuated::Punctuated::new(), - }, - }); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_no_angle_brackets() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_non_type_generic() { - // Test with lifetime generic instead of type - let ty: syn::Type = syn::parse_str("HasOne<'a>").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_non_path_inner() { - // Inner type is a reference, not a path - let ty: syn::Type = syn::parse_str("HasOne<&str>").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_has_one_optional() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32, user_id: Option }"); - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &attrs, - &struct_item, - &module_path, - field_name, - ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert_eq!(info.relation_type, "HasOne"); - assert!(info.is_optional); - assert!(tokens.to_string().contains("Option")); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_has_one_required() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32, user_id: i32 }"); - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &attrs, - &struct_item, - &module_path, - field_name, + output.contains("pub user : Box <"), + "BelongsTo with required FK should generate Box<>, not Option>. Output: {output}" ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert_eq!(info.relation_type, "HasOne"); - assert!(!info.is_optional); - assert!(tokens.to_string().contains("Box")); - assert!(!tokens.to_string().contains("Option")); } #[test] - fn test_convert_relation_type_to_schema_with_info_has_one_no_fk() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - // No attributes, so defaults to optional - let result = convert_relation_type_to_schema_with_info( - &ty, - &[], - &struct_item, - &module_path, - field_name, - ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert!(info.is_optional); // Default when FK not determinable - assert!(tokens.to_string().contains("Option")); - } + fn test_seaorm_relation_required_fk_directly() { + // Test the convert_relation_type_to_schema_with_info function directly + // to verify is_optional = false when FK is required + use crate::schema_macro::seaorm::{ + convert_relation_type_to_schema_with_info, extract_belongs_to_from_field, + is_field_optional_in_struct, + }; - #[test] - fn test_convert_relation_type_to_schema_with_info_has_many() { - let ty: syn::Type = syn::parse_str("HasMany").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("memos", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &[], - &struct_item, - &module_path, - field_name, - ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert_eq!(info.relation_type, "HasMany"); - assert!(!info.is_optional); - assert!(tokens.to_string().contains("Vec")); - } + // Use the same attribute format that works in seaorm tests: belongs_to (flag), not belongs_to = "..." + let struct_def = r#" +#[sea_orm(table_name = "memos")] +pub struct Model { + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: BelongsTo, +} +"#; + let parsed_struct: syn::ItemStruct = syn::parse_str(struct_def).unwrap(); - #[test] - fn test_convert_relation_type_to_schema_with_info_belongs_to_optional() { - let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32, user_id: Option }"); - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &attrs, - &struct_item, - &module_path, - field_name, - ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert_eq!(info.relation_type, "BelongsTo"); - assert!(info.is_optional); - assert!(tokens.to_string().contains("Option")); - } + // Get the user field + let syn::Fields::Named(fields_named) = &parsed_struct.fields else { + panic!("Expected named fields") + }; - #[test] - fn test_convert_relation_type_to_schema_with_info_belongs_to_required() { - let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32, user_id: i32 }"); - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &attrs, - &struct_item, - &module_path, - field_name, + let user_field = fields_named + .named + .iter() + .find(|f| f.ident.as_ref().is_some_and(|i| i == "user")) + .expect("user field not found"); + + // Debug: Check if extract_belongs_to_from_field works + let fk_field = extract_belongs_to_from_field(&user_field.attrs); + assert_eq!( + fk_field, + Some("user_id".to_string()), + "Should extract FK field from attribute" ); - assert!(result.is_some()); - let (tokens, info) = result.unwrap(); - assert_eq!(info.relation_type, "BelongsTo"); - assert!(!info.is_optional); - assert!(!tokens.to_string().contains("Option")); - } - #[test] - fn test_convert_relation_type_to_schema_with_info_unknown_relation() { - let ty: syn::Type = syn::parse_str("SomeOtherType").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let result = - convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], field_name); - assert!(result.is_none()); - } - - #[test] - fn test_convert_relation_type_to_schema_with_info_super_path() { - let ty: syn::Type = syn::parse_str("HasMany").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("memos", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &[], - &struct_item, - &module_path, - field_name, - ); - assert!(result.is_some()); - let (tokens, _info) = result.unwrap(); - let output = tokens.to_string(); - // super:: should resolve: crate::models::user -> crate::models::memo - assert!(output.contains("crate")); - assert!(output.contains("models")); - assert!(output.contains("memo")); - assert!(output.contains("Schema")); - } + // Debug: Check if is_field_optional_in_struct works + let is_fk_optional = is_field_optional_in_struct(&parsed_struct, "user_id"); + assert!(!is_fk_optional, "user_id: i32 should not be optional"); - #[test] - fn test_convert_relation_type_to_schema_with_info_crate_path() { - let ty: syn::Type = syn::parse_str("HasMany").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("memos", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "user".to_string(), - ]; let result = convert_relation_type_to_schema_with_info( - &ty, - &[], - &struct_item, - &module_path, - field_name, + &user_field.ty, + &user_field.attrs, + &parsed_struct, + &[ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ], + user_field.ident.clone().unwrap(), ); - assert!(result.is_some()); - let (tokens, _info) = result.unwrap(); - let output = tokens.to_string(); - // crate:: path should preserve and replace Entity with Schema - assert!(output.contains("crate")); - assert!(output.contains("models")); - assert!(output.contains("memo")); - assert!(output.contains("Schema")); - assert!(!output.contains("Entity")); - } - #[test] - fn test_convert_relation_type_to_schema_with_info_relative_path() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - let struct_item = make_test_struct("struct Model { id: i32 }"); - let field_name = syn::Ident::new("user", proc_macro2::Span::call_site()); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let result = convert_relation_type_to_schema_with_info( - &ty, - &[], - &struct_item, - &module_path, - field_name, + assert!(result.is_some(), "Should convert BelongsTo relation"); + let (_, rel_info) = result.unwrap(); + assert_eq!(rel_info.relation_type, "BelongsTo"); + // The FK field user_id is i32 (not Option), so is_optional should be false + assert!( + !rel_info.is_optional, + "BelongsTo with required FK (user_id: i32) should have is_optional = false" ); - assert!(result.is_some()); - let (tokens, _info) = result.unwrap(); - let output = tokens.to_string(); - // Relative path should be resolved relative to parent - assert!(output.contains("crate")); - assert!(output.contains("models")); - assert!(output.contains("user")); - assert!(output.contains("Schema")); } - // ========================================================================= - // Tests for extract_via_rel - // ========================================================================= - #[test] - fn test_extract_via_rel_with_value() { - // Tests: via_rel = "..." found - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(has_many, via_rel = "TargetUser")] - )]; - let result = extract_via_rel(&attrs); - assert_eq!(result, Some("TargetUser".to_string())); - } + fn test_extract_belongs_to_from_field_with_equals_value() { + // Test that extract_belongs_to_from_field works with belongs_to = "..." format + use crate::schema_macro::seaorm::extract_belongs_to_from_field; - #[test] - fn test_extract_via_rel_with_relation_enum() { - // Tests: via_rel alongside other attributes - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(has_many, relation_enum = "TargetUserNotifications", via_rel = "TargetUser")] - )]; - let result = extract_via_rel(&attrs); - assert_eq!(result, Some("TargetUser".to_string())); - } - - #[test] - fn test_extract_via_rel_without_via_rel() { - // Tests: No via_rel attribute present - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(has_many, relation_enum = "Memos")] - )]; - let result = extract_via_rel(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_via_rel_non_sea_orm_attr() { - // Tests: Non-sea_orm attribute returns None - let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; - let result = extract_via_rel(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_via_rel_empty_attrs() { - // Tests: Empty attributes - let result = extract_via_rel(&[]); - assert_eq!(result, None); - } - - #[test] - fn test_extract_via_rel_with_other_key_value_pairs() { - // Tests: Other key=value pairs are consumed without error - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id", via_rel = "Author")] - )]; - let result = extract_via_rel(&attrs); - assert_eq!(result, Some("Author".to_string())); - } - - #[test] - fn test_extract_via_rel_multiple_sea_orm_attrs() { - // Tests: Multiple sea_orm attributes, via_rel in second one - let attrs: Vec = vec![ - syn::parse_quote!(#[sea_orm(has_many)]), - syn::parse_quote!(#[sea_orm(via_rel = "Comments")]), - ]; - let result = extract_via_rel(&attrs); - assert_eq!(result, Some("Comments".to_string())); - } - - #[test] - fn test_extract_sea_orm_default_value_float() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(default_value = 0.7)] - )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, Some("0.7".to_string())); - } - - #[test] - fn test_extract_sea_orm_default_value_int() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(default_value = 42)] - )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, Some("42".to_string())); - } - - #[test] - fn test_extract_sea_orm_default_value_string() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(default_value = "active")] - )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, Some("active".to_string())); - } - - #[test] - fn test_extract_sea_orm_default_value_bool() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(default_value = true)] + // Format 1: belongs_to (flag style) - known to work + let attrs1: Vec = vec![syn::parse_quote!( + #[sea_orm(belongs_to, from = "user_id", to = "id")] )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, Some("true".to_string())); - } + let result1 = extract_belongs_to_from_field(&attrs1); + assert_eq!( + result1, + Some("user_id".to_string()), + "Flag style should work" + ); - #[test] - fn test_extract_sea_orm_default_value_with_other_attrs() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(column_type = "Decimal(Some((10, 2)))", default_value = 0.7)] + // Format 2: belongs_to = "..." (value style) - testing this + let attrs2: Vec = vec![syn::parse_quote!( + #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id")] )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, Some("0.7".to_string())); - } - - #[test] - fn test_extract_sea_orm_default_value_none() { - let attrs: Vec = vec![syn::parse_quote!( - #[sea_orm(column_type = "Text")] - )]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_sea_orm_default_value_non_sea_orm_attr() { - let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_sea_orm_default_value_empty_attrs() { - let result = extract_sea_orm_default_value(&[]); - assert_eq!(result, None); - } - - #[test] - fn test_extract_sea_orm_default_value_non_list_meta() { - // #[sea_orm] as a path attribute (non-Meta::List) — line 222 branch - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm])]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_sea_orm_default_value_empty_value_after_equals() { - // default_value = , (empty value) — line 236 branch - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = )])]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, None); - } - - #[test] - fn test_extract_sea_orm_default_value_no_default_value_key() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(primary_key, auto_increment)])]; - let result = extract_sea_orm_default_value(&attrs); - assert_eq!(result, None); - } - - // ========================================================================= - // Tests for is_sql_function_default - // ========================================================================= - - #[rstest] - #[case("NOW()", true)] - #[case("CURRENT_TIMESTAMP()", true)] - #[case("UUID()", true)] - #[case("gen_random_uuid()", true)] - #[case("0.7", false)] - #[case("42", false)] - #[case("true", false)] - #[case("draft", false)] - #[case("active", false)] - fn test_is_sql_function_default(#[case] value: &str, #[case] expected: bool) { - assert_eq!(is_sql_function_default(value), expected); - } - - // ========================================================================= - // Tests for has_sea_orm_primary_key - // ========================================================================= - - #[test] - fn test_has_sea_orm_primary_key_true() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; - assert!(has_sea_orm_primary_key(&attrs)); - } - - #[test] - fn test_has_sea_orm_primary_key_with_other_attrs() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(primary_key, default_value = "gen_random_uuid()")])]; - assert!(has_sea_orm_primary_key(&attrs)); - } - - #[test] - fn test_has_sea_orm_primary_key_false() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - assert!(!has_sea_orm_primary_key(&attrs)); - } - - #[test] - fn test_has_sea_orm_primary_key_no_sea_orm_attr() { - let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; - assert!(!has_sea_orm_primary_key(&attrs)); - } - - #[test] - fn test_has_sea_orm_primary_key_empty_attrs() { - assert!(!has_sea_orm_primary_key(&[])); - } - - #[test] - fn test_has_sea_orm_primary_key_non_list_meta() { - // #[sea_orm = "value"] is a NameValue meta, not a List — should be skipped - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm = "something"])]; - assert!(!has_sea_orm_primary_key(&attrs)); + let result2 = extract_belongs_to_from_field(&attrs2); + assert_eq!( + result2, + Some("user_id".to_string()), + "Value style should also work" + ); } } diff --git a/crates/vespera_macro/src/schema_macro/seaorm/attrs.rs b/crates/vespera_macro/src/schema_macro/seaorm/attrs.rs new file mode 100644 index 00000000..b4264599 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/attrs.rs @@ -0,0 +1,347 @@ +/// Extract a named string value from a `sea_orm` attribute. +fn extract_sea_orm_attr_value(attrs: &[syn::Attribute], attr_name: &str) -> Option { + attrs.iter().find_map(|attr| { + if !attr.path().is_ident("sea_orm") { + return None; + } + + let mut found_value = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident(attr_name) { + found_value = meta + .value() + .ok() + .and_then(|v| v.parse::().ok()) + .map(|lit| lit.value()); + } else if meta.input.peek(syn::Token![=]) { + drop( + meta.value() + .and_then(syn::parse::ParseBuffer::parse::), + ); + } + Ok(()) + }); + found_value + }) +} + +/// Extract the `from` field name from a `sea_orm` relation attribute. +pub fn extract_belongs_to_from_field(attrs: &[syn::Attribute]) -> Option { + extract_sea_orm_attr_value(attrs, "from") +} + +/// Extract the `relation_enum` value from a `sea_orm` attribute. +pub fn extract_relation_enum(attrs: &[syn::Attribute]) -> Option { + extract_sea_orm_attr_value(attrs, "relation_enum") +} + +/// Extract the `via_rel` value from a `sea_orm` attribute. +pub fn extract_via_rel(attrs: &[syn::Attribute]) -> Option { + extract_sea_orm_attr_value(attrs, "via_rel") +} + +/// Extract `default_value` from a `sea_orm` attribute. +pub fn extract_sea_orm_default_value(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + + let syn::Meta::List(meta_list) = &attr.meta else { + continue; + }; + let tokens = meta_list.tokens.to_string(); + + if let Some(start) = tokens.find("default_value") { + let remaining = &tokens[start + "default_value".len()..]; + let remaining = remaining.trim_start(); + if let Some(after_eq) = remaining.strip_prefix('=') { + let value_str = after_eq.trim_start(); + let end = value_str.find(',').unwrap_or(value_str.len()); + let raw_value = value_str[..end].trim(); + + if raw_value.is_empty() { + continue; + } + + if let Some(inner) = raw_value + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + { + return Some(inner.to_string()); + } + return Some(raw_value.to_string()); + } + } + } + None +} + +/// Check if a `sea_orm(default_value)` is a SQL function. +pub fn is_sql_function_default(value: &str) -> bool { + value.contains('(') +} + +/// Check if a field has `#[sea_orm(primary_key)]`. +pub fn has_sea_orm_primary_key(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + let syn::Meta::List(meta_list) = &attr.meta else { + continue; + }; + if meta_list.tokens.to_string().contains("primary_key") { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[test] + fn test_extract_belongs_to_from_field_with_from() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id", to = "id")])]; + assert_eq!( + extract_belongs_to_from_field(&attrs), + Some("user_id".to_string()) + ); + } + + #[test] + fn test_extract_belongs_to_from_field_without_from() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(belongs_to, to = "id")])]; + assert_eq!(extract_belongs_to_from_field(&attrs), None); + } + + #[test] + fn test_extract_belongs_to_from_field_no_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; + assert_eq!(extract_belongs_to_from_field(&attrs), None); + } + + #[test] + fn test_extract_belongs_to_from_field_empty_attrs() { + assert_eq!(extract_belongs_to_from_field(&[]), None); + } + + #[test] + fn test_extract_relation_enum_with_value() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(belongs_to, relation_enum = "TargetUser", from = "target_user_id", to = "id")]), + ]; + assert_eq!( + extract_relation_enum(&attrs), + Some("TargetUser".to_string()) + ); + } + + #[test] + fn test_extract_relation_enum_without_relation_enum() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id", to = "id")])]; + assert_eq!(extract_relation_enum(&attrs), None); + } + + #[test] + fn test_extract_relation_enum_no_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; + assert_eq!(extract_relation_enum(&attrs), None); + } + + #[test] + fn test_extract_relation_enum_empty_attrs() { + assert_eq!(extract_relation_enum(&[]), None); + } + + #[test] + fn test_extract_via_rel_with_value() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(has_many, via_rel = "TargetUser")])]; + assert_eq!(extract_via_rel(&attrs), Some("TargetUser".to_string())); + } + + #[test] + fn test_extract_via_rel_with_relation_enum() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(has_many, relation_enum = "TargetUserNotifications", via_rel = "TargetUser")]), + ]; + assert_eq!(extract_via_rel(&attrs), Some("TargetUser".to_string())); + } + + #[test] + fn test_extract_via_rel_without_via_rel() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(has_many, relation_enum = "Memos")])]; + assert_eq!(extract_via_rel(&attrs), None); + } + + #[test] + fn test_extract_via_rel_non_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(skip)])]; + assert_eq!(extract_via_rel(&attrs), None); + } + + #[test] + fn test_extract_via_rel_empty_attrs() { + assert_eq!(extract_via_rel(&[]), None); + } + + #[test] + fn test_extract_via_rel_with_other_key_value_pairs() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id", via_rel = "Author")]), + ]; + assert_eq!(extract_via_rel(&attrs), Some("Author".to_string())); + } + + #[test] + fn test_extract_via_rel_multiple_sea_orm_attrs() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(has_many)]), + syn::parse_quote!(#[sea_orm(via_rel = "Comments")]), + ]; + assert_eq!(extract_via_rel(&attrs), Some("Comments".to_string())); + } + + #[test] + fn test_extract_sea_orm_default_value_float() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = 0.7)])]; + assert_eq!( + extract_sea_orm_default_value(&attrs), + Some("0.7".to_string()) + ); + } + + #[test] + fn test_extract_sea_orm_default_value_int() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = 42)])]; + assert_eq!( + extract_sea_orm_default_value(&attrs), + Some("42".to_string()) + ); + } + + #[test] + fn test_extract_sea_orm_default_value_string() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "active")])]; + assert_eq!( + extract_sea_orm_default_value(&attrs), + Some("active".to_string()) + ); + } + + #[test] + fn test_extract_sea_orm_default_value_bool() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = true)])]; + assert_eq!( + extract_sea_orm_default_value(&attrs), + Some("true".to_string()) + ); + } + + #[test] + fn test_extract_sea_orm_default_value_with_other_attrs() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(column_type = "Decimal(Some((10, 2)))", default_value = 0.7)]), + ]; + assert_eq!( + extract_sea_orm_default_value(&attrs), + Some("0.7".to_string()) + ); + } + + #[test] + fn test_extract_sea_orm_default_value_none() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(column_type = "Text")])]; + assert_eq!(extract_sea_orm_default_value(&attrs), None); + } + + #[test] + fn test_extract_sea_orm_default_value_non_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; + assert_eq!(extract_sea_orm_default_value(&attrs), None); + } + + #[test] + fn test_extract_sea_orm_default_value_empty_attrs() { + assert_eq!(extract_sea_orm_default_value(&[]), None); + } + + #[test] + fn test_extract_sea_orm_default_value_non_list_meta() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm])]; + assert_eq!(extract_sea_orm_default_value(&attrs), None); + } + + #[test] + fn test_extract_sea_orm_default_value_empty_value_after_equals() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = )])]; + assert_eq!(extract_sea_orm_default_value(&attrs), None); + } + + #[test] + fn test_extract_sea_orm_default_value_no_default_value_key() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(primary_key, auto_increment)])]; + assert_eq!(extract_sea_orm_default_value(&attrs), None); + } + + #[rstest] + #[case("NOW()", true)] + #[case("CURRENT_TIMESTAMP()", true)] + #[case("UUID()", true)] + #[case("gen_random_uuid()", true)] + #[case("0.7", false)] + #[case("42", false)] + #[case("true", false)] + #[case("draft", false)] + #[case("active", false)] + fn test_is_sql_function_default(#[case] value: &str, #[case] expected: bool) { + assert_eq!(is_sql_function_default(value), expected); + } + + #[test] + fn test_has_sea_orm_primary_key_true() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; + assert!(has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_with_other_attrs() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(primary_key, default_value = "gen_random_uuid()")])]; + assert!(has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_false() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + assert!(!has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_no_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; + assert!(!has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_empty_attrs() { + assert!(!has_sea_orm_primary_key(&[])); + } + + #[test] + fn test_has_sea_orm_primary_key_non_list_meta() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm = "something"] )]; + assert!(!has_sea_orm_primary_key(&attrs)); + } +} diff --git a/crates/vespera_macro/src/schema_macro/seaorm/conversion.rs b/crates/vespera_macro/src/schema_macro/seaorm/conversion.rs new file mode 100644 index 00000000..7dd2f9b4 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/conversion.rs @@ -0,0 +1,170 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Type; + +use crate::schema_macro::type_utils::resolve_type_to_absolute_path; + +/// Convert `SeaORM` datetime types to chrono equivalents. +pub fn convert_seaorm_type_to_chrono(ty: &Type, source_module_path: &[String]) -> TokenStream { + let Type::Path(type_path) = ty else { + return quote! { #ty }; + }; + + let Some(segment) = type_path.path.segments.last() else { + return quote! { #ty }; + }; + + match segment.ident.to_string().as_str() { + "DateTimeWithTimeZone" => { + quote! { vespera::chrono::DateTime } + } + "DateTimeUtc" => quote! { vespera::chrono::DateTime }, + "DateTimeLocal" => quote! { vespera::chrono::DateTime }, + "FieldData" => convert_field_data(segment, source_module_path), + "NamedTempFile" => quote! { vespera::tempfile::NamedTempFile }, + _ => resolve_type_to_absolute_path(ty, source_module_path), + } +} + +fn convert_field_data(segment: &syn::PathSegment, source_module_path: &[String]) -> TokenStream { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + let inner_args: Vec<_> = args + .args + .iter() + .map(|arg| { + if let syn::GenericArgument::Type(inner_ty) = arg { + let converted = convert_seaorm_type_to_chrono(inner_ty, source_module_path); + quote! { #converted } + } else { + quote! { #arg } + } + }) + .collect(); + quote! { vespera::multipart::FieldData<#(#inner_args),*> } + } else { + quote! { vespera::multipart::FieldData } + } +} + +/// Convert a type to chrono equivalent, handling `Option` and `Vec` wrappers. +pub fn convert_type_with_chrono(ty: &Type, source_module_path: &[String]) -> TokenStream { + if let Some((wrapper, inner_ty)) = option_or_vec_inner(ty) { + let converted_inner = convert_seaorm_type_to_chrono(inner_ty, source_module_path); + return match wrapper { + "Option" => quote! { Option<#converted_inner> }, + "Vec" => quote! { Vec<#converted_inner> }, + _ => unreachable!(), + }; + } + + convert_seaorm_type_to_chrono(ty, source_module_path) +} + +fn option_or_vec_inner(ty: &Type) -> Option<(&'static str, &Type)> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.first()?; + let wrapper = match segment.ident.to_string().as_str() { + "Option" => "Option", + "Vec" => "Vec", + _ => return None, + }; + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + Some((wrapper, inner_ty)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::date_time_with_time_zone("seaorm_to_chrono_tz", "DateTimeWithTimeZone")] + #[case::date_time_utc("seaorm_to_chrono_utc", "DateTimeUtc")] + #[case::date_time_local("seaorm_to_chrono_local", "DateTimeLocal")] + #[case::non_path_reference("seaorm_to_chrono_ref_str", "&str")] + #[case::regular_type_passthrough("seaorm_to_chrono_string", "String")] + fn convert_seaorm_type_to_chrono_snapshot(#[case] snapshot_name: &str, #[case] input: &str) { + let ty: syn::Type = syn::parse_str(input).unwrap(); + insta::assert_snapshot!( + snapshot_name, + convert_seaorm_type_to_chrono(&ty, &[]).to_string() + ); + } + + #[rstest] + #[case::option_datetime("with_chrono_option_datetime", "Option")] + #[case::vec_datetime("with_chrono_vec_datetime", "Vec")] + #[case::plain_type_passthrough("with_chrono_plain_i32", "i32")] + fn convert_type_with_chrono_snapshot(#[case] snapshot_name: &str, #[case] input: &str) { + let ty: syn::Type = syn::parse_str(input).unwrap(); + insta::assert_snapshot!( + snapshot_name, + convert_type_with_chrono(&ty, &[]).to_string() + ); + } + + #[test] + fn test_convert_seaorm_type_to_chrono_empty_path() { + let ty = syn::Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }); + let tokens = convert_seaorm_type_to_chrono(&ty, &[]); + assert!(tokens.to_string().is_empty() || tokens.to_string().trim().is_empty()); + } + + #[test] + fn test_convert_seaorm_type_field_data_with_generic() { + let ty: syn::Type = syn::parse_str("FieldData").unwrap(); + let output = convert_seaorm_type_to_chrono(&ty, &[]).to_string(); + assert!(output.contains("vespera :: multipart :: FieldData")); + assert!(output.contains("vespera :: tempfile :: NamedTempFile")); + } + + #[test] + fn test_convert_seaorm_type_field_data_without_generic() { + let ty: syn::Type = syn::parse_str("FieldData").unwrap(); + let output = convert_seaorm_type_to_chrono(&ty, &[]).to_string(); + assert!(output.contains("vespera :: multipart :: FieldData")); + assert!(!output.contains("NamedTempFile")); + } + + #[test] + fn test_convert_seaorm_type_field_data_with_non_type_generic() { + let ty: syn::Type = syn::parse_str("FieldData<'a>").unwrap(); + let output = convert_seaorm_type_to_chrono(&ty, &[]).to_string(); + assert!(output.contains("vespera :: multipart :: FieldData")); + } + + #[test] + fn test_convert_seaorm_type_named_temp_file() { + let ty: syn::Type = syn::parse_str("NamedTempFile").unwrap(); + let output = convert_seaorm_type_to_chrono(&ty, &[]).to_string(); + assert_eq!(output.trim(), "vespera :: tempfile :: NamedTempFile"); + } + + #[test] + fn test_convert_type_with_chrono_json_alias_uses_public_value_path() { + let ty: syn::Type = syn::parse_str("Json").unwrap(); + let tokens = convert_type_with_chrono( + &ty, + &[ + "crate".to_string(), + "models".to_string(), + "json_case".to_string(), + ], + ); + assert_eq!(tokens.to_string().trim(), "vespera :: serde_json :: Value"); + } +} diff --git a/crates/vespera_macro/src/schema_macro/seaorm/relations.rs b/crates/vespera_macro/src/schema_macro/seaorm/relations.rs new file mode 100644 index 00000000..4fce6c44 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/relations.rs @@ -0,0 +1,475 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Type; + +use super::attrs::{extract_belongs_to_from_field, extract_relation_enum, extract_via_rel}; +use crate::schema_macro::type_utils::is_option_type; + +/// Relation field info for generating `from_model` code. +#[derive(Clone)] +pub struct RelationFieldInfo { + pub field_name: syn::Ident, + pub relation_type: String, + pub schema_path: TokenStream, + pub is_optional: bool, + pub inline_type_info: Option<(syn::Ident, Vec)>, + pub relation_enum: Option, + pub fk_column: Option, + pub via_rel: Option, +} + +/// Check if a field in the struct is optional (`Option`). +pub fn is_field_optional_in_struct(struct_item: &syn::ItemStruct, field_name: &str) -> bool { + if let syn::Fields::Named(fields_named) = &struct_item.fields { + for field in &fields_named.named { + if let Some(ident) = &field.ident + && ident == field_name + { + return is_option_type(&field.ty); + } + } + } + false +} + +/// Convert a `SeaORM` relation type to a Schema type AND return relation info. +pub fn convert_relation_type_to_schema_with_info( + ty: &Type, + field_attrs: &[syn::Attribute], + parsed_struct: &syn::ItemStruct, + source_module_path: &[String], + field_name: syn::Ident, +) -> Option<(TokenStream, RelationFieldInfo)> { + let Type::Path(type_path) = ty else { + return None; + }; + + let segment = type_path.path.segments.last()?; + let ident_str = segment.ident.to_string(); + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() else { + return None; + }; + let Type::Path(inner_path) = inner_ty else { + return None; + }; + + let schema_path = schema_path_tokens(&inner_path.path, source_module_path); + + match ident_str.as_str() { + "HasOne" => Some(single_relation( + "HasOne", + field_name, + field_attrs, + parsed_struct, + schema_path, + )), + "HasMany" => { + let relation_enum = extract_relation_enum(field_attrs); + let via_rel = extract_via_rel(field_attrs); + let converted = quote! { Vec<#schema_path> }; + let info = RelationFieldInfo { + field_name, + relation_type: "HasMany".to_string(), + schema_path, + is_optional: false, + inline_type_info: None, + relation_enum, + fk_column: None, + via_rel, + }; + Some((converted, info)) + } + "BelongsTo" => Some(single_relation( + "BelongsTo", + field_name, + field_attrs, + parsed_struct, + schema_path, + )), + _ => None, + } +} + +fn schema_path_tokens(path: &syn::Path, source_module_path: &[String]) -> TokenStream { + let segments: Vec = path.segments.iter().map(|s| s.ident.to_string()).collect(); + let absolute_segments = absolute_schema_segments(&segments, source_module_path); + let path_idents: Vec = absolute_segments + .iter() + .map(|s| syn::Ident::new(s, proc_macro2::Span::call_site())) + .collect(); + quote! { #(#path_idents)::* } +} + +fn absolute_schema_segments(segments: &[String], source_module_path: &[String]) -> Vec { + if !segments.is_empty() && segments[0] == "super" { + let super_count = segments.iter().take_while(|s| *s == "super").count(); + let parent_path_len = source_module_path.len().saturating_sub(super_count); + let mut abs = Vec::with_capacity(parent_path_len + segments.len() - super_count); + abs.extend_from_slice(&source_module_path[..parent_path_len]); + abs.extend(segments.iter().skip(super_count).map(entity_to_schema)); + abs + } else if !segments.is_empty() && segments[0] == "crate" { + segments.iter().map(entity_to_schema).collect() + } else { + let parent_path_len = source_module_path.len().saturating_sub(1); + let mut abs = Vec::with_capacity(parent_path_len + segments.len()); + abs.extend_from_slice(&source_module_path[..parent_path_len]); + abs.extend(segments.iter().map(entity_to_schema)); + abs + } +} + +fn entity_to_schema(segment: &String) -> String { + if segment == "Entity" { + "Schema".to_string() + } else { + segment.clone() + } +} + +fn single_relation( + relation_type: &str, + field_name: syn::Ident, + field_attrs: &[syn::Attribute], + parsed_struct: &syn::ItemStruct, + schema_path: TokenStream, +) -> (TokenStream, RelationFieldInfo) { + let fk_field = extract_belongs_to_from_field(field_attrs); + let relation_enum = extract_relation_enum(field_attrs); + let is_optional = fk_field + .as_ref() + .is_none_or(|f| is_field_optional_in_struct(parsed_struct, f)); + + let converted = if is_optional { + quote! { Option> } + } else { + quote! { Box<#schema_path> } + }; + let info = RelationFieldInfo { + field_name, + relation_type: relation_type.to_string(), + schema_path, + is_optional, + inline_type_info: None, + relation_enum, + fk_column: fk_field, + via_rel: None, + }; + (converted, info) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_struct(def: &str) -> syn::ItemStruct { + syn::parse_str(def).unwrap() + } + + fn ident(name: &str) -> syn::Ident { + syn::Ident::new(name, proc_macro2::Span::call_site()) + } + + #[test] + fn test_is_field_optional_in_struct_optional() { + let struct_item = make_test_struct("struct Model { id: i32, user_id: Option }"); + assert!(is_field_optional_in_struct(&struct_item, "user_id")); + } + + #[test] + fn test_is_field_optional_in_struct_required() { + let struct_item = make_test_struct("struct Model { id: i32, user_id: i32 }"); + assert!(!is_field_optional_in_struct(&struct_item, "user_id")); + } + + #[test] + fn test_is_field_optional_in_struct_field_not_found() { + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!(!is_field_optional_in_struct(&struct_item, "nonexistent")); + } + + #[test] + fn test_is_field_optional_in_struct_tuple_struct() { + let struct_item: syn::ItemStruct = + syn::parse_str("struct TupleStruct(i32, Option);").unwrap(); + assert!(!is_field_optional_in_struct(&struct_item, "0")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_non_path_type() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_empty_segments() { + let ty = syn::Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_no_angle_brackets() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_non_type_generic() { + let ty: syn::Type = syn::parse_str("HasOne<'a>").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_non_path_inner() { + let ty: syn::Type = syn::parse_str("HasOne<&str>").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_has_one_optional() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32, user_id: Option }"); + let attrs = vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &attrs, + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + assert_eq!(info.relation_type, "HasOne"); + assert!(info.is_optional); + assert!(tokens.to_string().contains("Option")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_has_one_required() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32, user_id: i32 }"); + let attrs = vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &attrs, + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + assert_eq!(info.relation_type, "HasOne"); + assert!(!info.is_optional); + assert!(tokens.to_string().contains("Box")); + assert!(!tokens.to_string().contains("Option")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_has_one_no_fk() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &[], + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + assert!(info.is_optional); + assert!(tokens.to_string().contains("Option")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_has_many() { + let ty: syn::Type = syn::parse_str("HasMany").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "user".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &[], + &struct_item, + &module_path, + ident("memos"), + ) + .unwrap(); + assert_eq!(info.relation_type, "HasMany"); + assert!(!info.is_optional); + assert!(tokens.to_string().contains("Vec")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_belongs_to_optional() { + let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32, user_id: Option }"); + let attrs = vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &attrs, + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + assert_eq!(info.relation_type, "BelongsTo"); + assert!(info.is_optional); + assert!(tokens.to_string().contains("Option")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_belongs_to_required() { + let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32, user_id: i32 }"); + let attrs = vec![syn::parse_quote!(#[sea_orm(belongs_to, from = "user_id")])]; + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, info) = convert_relation_type_to_schema_with_info( + &ty, + &attrs, + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + assert_eq!(info.relation_type, "BelongsTo"); + assert!(!info.is_optional); + assert!(!tokens.to_string().contains("Option")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_unknown_relation() { + let ty: syn::Type = syn::parse_str("SomeOtherType").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + assert!( + convert_relation_type_to_schema_with_info(&ty, &[], &struct_item, &[], ident("user")) + .is_none() + ); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_super_path() { + let ty: syn::Type = syn::parse_str("HasMany").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "user".to_string(), + ]; + let (tokens, _) = convert_relation_type_to_schema_with_info( + &ty, + &[], + &struct_item, + &module_path, + ident("memos"), + ) + .unwrap(); + let output = tokens.to_string(); + assert!(output.contains("crate")); + assert!(output.contains("models")); + assert!(output.contains("memo")); + assert!(output.contains("Schema")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_crate_path() { + let ty: syn::Type = syn::parse_str("HasMany").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "user".to_string(), + ]; + let (tokens, _) = convert_relation_type_to_schema_with_info( + &ty, + &[], + &struct_item, + &module_path, + ident("memos"), + ) + .unwrap(); + let output = tokens.to_string(); + assert!(output.contains("crate")); + assert!(output.contains("models")); + assert!(output.contains("memo")); + assert!(output.contains("Schema")); + assert!(!output.contains("Entity")); + } + + #[test] + fn test_convert_relation_type_to_schema_with_info_relative_path() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + let struct_item = make_test_struct("struct Model { id: i32 }"); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let (tokens, _) = convert_relation_type_to_schema_with_info( + &ty, + &[], + &struct_item, + &module_path, + ident("user"), + ) + .unwrap(); + let output = tokens.to_string(); + assert!(output.contains("crate")); + assert!(output.contains("models")); + assert!(output.contains("user")); + assert!(output.contains("Schema")); + } +} diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_local.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_local.snap new file mode 100644 index 00000000..dd938041 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_local.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_seaorm_type_to_chrono(&ty, &[]).to_string()" +--- +vespera :: chrono :: DateTime < vespera :: chrono :: Local > diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_ref_str.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_ref_str.snap new file mode 100644 index 00000000..0460e4fb --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_ref_str.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_seaorm_type_to_chrono(&ty, &[]).to_string()" +--- +& str diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_string.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_string.snap new file mode 100644 index 00000000..24e7c352 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_string.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_seaorm_type_to_chrono(&ty, &[]).to_string()" +--- +String diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_tz.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_tz.snap new file mode 100644 index 00000000..ee7a1782 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_tz.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_seaorm_type_to_chrono(&ty, &[]).to_string()" +--- +vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_utc.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_utc.snap new file mode 100644 index 00000000..929b264c --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__seaorm_to_chrono_utc.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_seaorm_type_to_chrono(&ty, &[]).to_string()" +--- +vespera :: chrono :: DateTime < vespera :: chrono :: Utc > diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_option_datetime.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_option_datetime.snap new file mode 100644 index 00000000..12924ed7 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_option_datetime.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_type_with_chrono(&ty, &[]).to_string()" +--- +Option < vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > > diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_plain_i32.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_plain_i32.snap new file mode 100644 index 00000000..faa57f11 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_plain_i32.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_type_with_chrono(&ty, &[]).to_string()" +--- +i32 diff --git a/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_vec_datetime.snap b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_vec_datetime.snap new file mode 100644 index 00000000..0a258b67 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/seaorm/snapshots/vespera_macro__schema_macro__seaorm__conversion__tests__with_chrono_vec_datetime.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/seaorm/tests.rs +expression: "convert_type_with_chrono(&ty, &[]).to_string()" +--- +Vec < vespera :: chrono :: DateTime < vespera :: chrono :: FixedOffset > > diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_crate_qualified.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_crate_qualified.snap new file mode 100644 index 00000000..d37ce27f --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_crate_qualified.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/tests.rs +expression: "build_entity_path_from_schema_path(&schema_path, &[]).to_string()" +--- +crate :: models :: user :: Entity diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_deeply_nested.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_deeply_nested.snap new file mode 100644 index 00000000..02f179a4 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_deeply_nested.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/tests.rs +expression: "build_entity_path_from_schema_path(&schema_path, &[]).to_string()" +--- +crate :: api :: models :: entities :: user :: Entity diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_simple_module.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_simple_module.snap new file mode 100644 index 00000000..0308b334 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_simple_module.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/tests.rs +expression: "build_entity_path_from_schema_path(&schema_path, &[]).to_string()" +--- +user :: Entity diff --git a/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_single_segment.snap b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_single_segment.snap new file mode 100644 index 00000000..8cb5bbc6 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/snapshots/vespera_macro__schema_macro__from_model__tests__entity_path_single_segment.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespera_macro/src/schema_macro/from_model/tests.rs +expression: "build_entity_path_from_schema_path(&schema_path, &[]).to_string()" +--- +Entity diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs deleted file mode 100644 index 3368fecf..00000000 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ /dev/null @@ -1,2392 +0,0 @@ -//! Tests for schema_macro module -//! -//! This file contains all unit tests for the schema generation functionality. - -use std::collections::HashMap; - -use serial_test::serial; - -use super::*; - -fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { - StructMetadata::new(name.to_string(), definition.to_string()) -} - -fn to_storage(items: Vec) -> HashMap { - items.into_iter().map(|s| (s.name.clone(), s)).collect() -} - -#[test] -fn test_generate_schema_code_simple_struct() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(User); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_ok()); - let output = result.unwrap().to_string(); - assert!(output.contains("properties")); - assert!(output.contains("Schema")); -} - -#[test] -fn test_generate_schema_code_with_omit() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String, pub password: String }", - )]); - - let tokens = quote!(User, omit = ["password"]); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_ok()); - let output = result.unwrap().to_string(); - assert!(output.contains("properties")); -} - -#[test] -fn test_generate_schema_code_with_pick() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String, pub email: String }", - )]); - - let tokens = quote!(User, pick = ["id", "name"]); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_ok()); - let output = result.unwrap().to_string(); - assert!(output.contains("properties")); -} - -#[test] -fn test_generate_schema_code_type_not_found() { - let storage: HashMap = HashMap::new(); - - let tokens = quote!(NonExistent); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("not found")); -} - -#[test] -fn test_generate_schema_code_malformed_definition() { - let storage = to_storage(vec![create_test_struct_metadata( - "BadStruct", - "this is not valid rust code {{{", - )]); - - let tokens = quote!(BadStruct); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("failed to parse")); -} - -#[test] -fn test_generate_schema_type_code_pick_nonexistent_field() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(NewUser from User, pick = ["nonexistent"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("does not exist")); - assert!(err.contains("nonexistent")); -} - -#[test] -fn test_generate_schema_type_code_omit_nonexistent_field() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(NewUser from User, omit = ["nonexistent"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("does not exist")); - assert!(err.contains("nonexistent")); -} - -#[test] -fn test_generate_schema_type_code_rename_nonexistent_field() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(NewUser from User, rename = [("nonexistent", "new_name")]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("does not exist")); - assert!(err.contains("nonexistent")); -} - -#[test] -fn test_generate_schema_type_code_type_not_found() { - let storage: HashMap = HashMap::new(); - - let tokens = quote!(NewUser from NonExistent); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("not found")); -} - -#[test] -fn test_generate_schema_type_code_success() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(CreateUser from User, pick = ["name"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("CreateUser")); - assert!(output.contains("name")); -} - -#[test] -fn test_generate_schema_type_code_with_omit() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String, pub password: String }", - )]); - - let tokens = quote!(SafeUser from User, omit = ["password"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("SafeUser")); - assert!(!output.contains("password")); -} - -#[test] -fn test_generate_schema_type_code_with_add() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserWithExtra from User, add = [("extra": String)]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("UserWithExtra")); - assert!(output.contains("extra")); -} - -#[test] -fn test_generate_schema_type_code_relation_fields_can_be_omitted_and_readded_with_custom_types() { - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "article")] - pub struct Model { - pub id: i64, - pub title: String, - pub user: HasOne, - pub category: HasOne, - pub article_review_users: HasMany - }"#, - )]); - - let tokens = quote!( - ArticleResponse from Model, - omit = ["user", "category", "article_review_users"], - add = [ - ("user": Option), - ("category": Option), - ("article_review_users": Vec) - ] - ); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("pub user : Option < UserInArticle >")); - assert!(output.contains("pub category : Option < CategoryInArticle >")); - assert!(output.contains("pub article_review_users : Vec < ArticleReviewUserInArticle >")); - assert!(!output.contains("Box < Schema >")); - assert!(!output.contains("impl From")); -} - -#[test] -fn test_generate_schema_type_code_same_file_relation_adapters_for_add_mode() { - let storage = to_storage(vec![ - create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "article")] - pub struct Model { - pub id: i64, - pub title: String, - pub user: HasOne, - pub category: HasOne, - pub article_review_users: HasMany - }"#, - ), - create_test_struct_metadata( - "UserInArticle", - "struct UserInArticle { id: i32, name: String }", - ), - create_test_struct_metadata( - "CategoryInArticle", - "struct CategoryInArticle { id: i64, name: String }", - ), - ]); - - let tokens = quote!( - ArticleResponse from Model, - add = [("article_review_users": Vec)] - ); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("pub user : __VesperaArticleResponseUserRelation")); - assert!(output.contains("pub category : __VesperaArticleResponseCategoryRelation")); - assert!(output.contains("impl From < Option <")); - assert!(output.contains("for __VesperaArticleResponseUserRelation")); - assert!(output.contains("for __VesperaArticleResponseCategoryRelation")); - assert!(output.contains("impl Clone for UserInArticle")); - assert!(output.contains("impl Clone for CategoryInArticle")); -} - -#[test] -fn test_maybe_generate_same_file_relation_override_skips_redundant_clone_and_deserialize_impls() { - // Same-file relation override DTOs that ALREADY carry `Clone` and - // `Deserialize` derives must NOT have the macro re-emit those - // impls — otherwise the generated code would conflict with the - // user-provided derive. Hits the "DTO already has derive" empty- - // quote branches inside `maybe_generate_same_file_relation_override`. - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "HasOne".to_string(), - schema_path: quote!(crate::models::user::Schema), - is_optional: true, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - // Bare `Clone` and `Deserialize` idents — has_derive matches the - // single-segment path, hitting the empty-quote branches at lines - // 208 (clone_impl) and 222 (deserialize_impl). - let storage = to_storage(vec![create_test_struct_metadata( - "UserInArticle", - r"#[derive(Clone, Deserialize)] - struct UserInArticle { id: i32, name: String }", - )]); - let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); - - let (override_field_ty, helper_tokens) = - maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) - .expect("override generation should succeed") - .expect("DTO is present in storage → override should be generated"); - - let output = helper_tokens.to_string(); - let field_ty = override_field_ty.to_string(); - assert!( - field_ty.contains("__VesperaArticleResponseUserRelation"), - "expected override field type to reference relation adapter, got: {field_ty}" - ); - // No `impl Clone for UserInArticle` — DTO already derives Clone. - assert!( - !output.contains("impl Clone for UserInArticle"), - "macro should skip Clone impl when DTO already derives Clone, got: {output}" - ); - // No proxy `Deserialize` derive struct — DTO already derives Deserialize. - assert!( - !output.contains("__VesperaArticleResponseUserProxy"), - "macro should skip Deserialize proxy when DTO already derives Deserialize, got: {output}" - ); - // Relation wrapper struct still emitted regardless of derives. - assert!( - output.contains("__VesperaArticleResponseUserRelation"), - "relation wrapper missing: {output}" - ); -} - -#[test] -fn test_generate_schema_type_code_generates_from_impl() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserResponse from User, pick = ["id", "name"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("impl From")); - assert!(output.contains("for UserResponse")); -} - -#[test] -fn test_generate_schema_type_code_no_from_impl_with_add() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserWithExtra from User, add = [("extra": String)]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!( - output.contains("UserWithExtra"), - "expected struct UserWithExtra in output: {output}" - ); - assert!( - !output.contains("impl From"), - "expected no From impl when `add` is used: {output}" - ); -} - -// ======================== -// is_parseable_type tests -// ======================== - -#[test] -fn test_is_parseable_type_primitives() { - for ty_str in &[ - "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize", - "f32", "f64", "bool", "String", "Decimal", - ] { - let ty: syn::Type = syn::parse_str(ty_str).unwrap(); - assert!(is_parseable_type(&ty), "{ty_str} should be parseable"); - } -} - -#[test] -fn test_is_parseable_type_non_parseable() { - let ty: syn::Type = syn::parse_str("MyEnum").unwrap(); - assert!(!is_parseable_type(&ty)); -} - -#[test] -fn test_is_parseable_type_non_path() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - assert!(!is_parseable_type(&ty)); -} - -// ====================================== -// generate_sea_orm_default_attrs tests -// ====================================== - -#[test] -fn test_sea_orm_default_attrs_optional_field_skips() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("i32").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, true, &mut fns); - assert!(serde.is_empty()); - assert!(schema.is_empty()); - assert!(fns.is_empty()); -} - -#[test] -fn test_sea_orm_default_attrs_no_default_and_no_pk() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(unique)])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("String").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "email", &ty, &ty, false, &mut fns); - assert!(serde.is_empty()); - assert!(schema.is_empty()); - assert!(fns.is_empty()); -} - -#[test] -fn test_sea_orm_default_attrs_primary_key_generates_defaults() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("i32").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "primary_key should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains('0'), - "primary_key i32 should have schema default 0: {schema_str}" - ); - assert_eq!(fns.len(), 1, "should generate a default function"); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_generates_defaults() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "created_at", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "SQL function default should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("1970-01-01"), - "DateTimeWithTimeZone should have epoch default: {schema_str}" - ); - assert_eq!(fns.len(), 1, "should generate a default function"); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_uuid() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(primary_key, default_value = "gen_random_uuid()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("Uuid").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "UUID SQL default should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("00000000-0000-0000-0000-000000000000"), - "Uuid should have nil UUID default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_unknown_type_skips() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(default_value = "SOME_FUNC()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("MyCustomType").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "field", &ty, &ty, false, &mut fns); - assert!(serde.is_empty(), "unknown type should skip serde default"); - assert!(schema.is_empty(), "unknown type should skip schema default"); - assert!(fns.is_empty()); -} - -#[test] -fn test_sea_orm_default_attrs_existing_serde_default() { - let attrs: Vec = vec![ - syn::parse_quote!(#[sea_orm(default_value = "42")]), - syn::parse_quote!(#[serde(default)]), - ]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("i32").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, false, &mut fns); - // serde attr should be empty (already has serde default) - assert!(serde.is_empty()); - // schema attr should still be generated - let schema_str = schema.to_string(); - assert!( - schema_str.contains("schema"), - "should have schema attr: {schema_str}" - ); - assert!( - fns.is_empty(), - "no default fn needed when serde(default) exists" - ); -} - -#[test] -fn test_sea_orm_default_attrs_non_parseable_type() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "Active")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("MyEnum").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "status", &ty, &ty, false, &mut fns); - // serde attr empty (non-parseable type) - assert!(serde.is_empty()); - // schema attr still generated - let schema_str = schema.to_string(); - assert!( - schema_str.contains("schema"), - "should have schema attr: {schema_str}" - ); - assert!(fns.is_empty()); -} - -#[test] -fn test_sea_orm_default_attrs_full_generation() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("i32").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "count", &ty, &ty, false, &mut fns); - // Both serde and schema attrs should be generated - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "should have serde attr: {serde_str}" - ); - assert!( - serde_str.contains("default_Test_count"), - "should reference generated fn: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("schema"), - "should have schema attr: {schema_str}" - ); - // Default function should be generated - assert_eq!(fns.len(), 1, "should generate one default function"); - let fn_str = fns[0].to_string(); - assert!( - fn_str.contains("default_Test_count"), - "fn name should match: {fn_str}" - ); -} - -#[test] -fn test_generate_schema_type_code_with_partial_all() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String, pub bio: Option }", - )]); - - let tokens = quote!(UpdateUser from User, partial); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("Option < i32 >")); - assert!(output.contains("Option < String >")); -} - -#[test] -fn test_generate_schema_type_code_with_partial_fields() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String, pub email: String }", - )]); - - let tokens = quote!(UpdateUser from User, partial = ["name"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!( - output.contains("UpdateUser"), - "should contain generated struct name: {output}" - ); -} - -// ============================================================ -// Coverage: omit_default in generate_schema_type_code (line 180) -// ============================================================ - -#[test] -fn test_generate_schema_type_code_with_omit_default() { - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "items")] - pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub name: String, - #[sea_orm(default_value = "NOW()")] - pub created_at: DateTimeWithTimeZone, - }"#, - )]); - - let tokens = quote!(CreateItemRequest from Model, omit_default); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // id (primary_key) and created_at (default_value) should be omitted - assert!( - !output.contains("id :"), - "id should be omitted by omit_default: {output}" - ); - assert!( - !output.contains("created_at"), - "created_at should be omitted by omit_default: {output}" - ); - // name should remain - assert!(output.contains("name"), "name should remain: {output}"); -} - -// ============================================================ -// Coverage: SQL function default with existing serde default (line 554) -// ============================================================ - -#[test] -fn test_sea_orm_default_attrs_sql_function_with_existing_serde_default() { - let attrs: Vec = vec![ - syn::parse_quote!(#[sea_orm(default_value = "NOW()")]), - syn::parse_quote!(#[serde(default)]), - ]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "created_at", - &ty, - &ty, - false, - &mut fns, - ); - // serde attr should be empty (already has serde default) - assert!(serde.is_empty()); - // schema attr should still be generated - let schema_str = schema.to_string(); - assert!( - schema_str.contains("schema"), - "should have schema attr: {schema_str}" - ); - assert!( - schema_str.contains("1970-01-01"), - "should have epoch default: {schema_str}" - ); - assert!( - fns.is_empty(), - "no default fn needed when serde(default) exists" - ); -} - -// ============================================================ -// Coverage: sql_function_default_for_type branches (lines 580-615) -// ============================================================ - -#[test] -fn test_sea_orm_default_attrs_sql_function_non_path_type() { - // Non-Path type (reference) triggers early return None in sql_function_default_for_type - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "field", &ty, &ty, false, &mut fns); - assert!(serde.is_empty(), "non-Path type should skip serde default"); - assert!( - schema.is_empty(), - "non-Path type should skip schema default" - ); - assert!(fns.is_empty()); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_datetime() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("DateTime").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "created_at", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "DateTime should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("1970-01-01T00:00:00+00:00"), - "DateTime should have epoch default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_naive_datetime() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("NaiveDateTime").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "created_at", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "NaiveDateTime should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("1970-01-01T00:00:00"), - "NaiveDateTime should have epoch default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_naive_date() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("NaiveDate").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "date_field", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "NaiveDate should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("1970-01-01"), - "NaiveDate should have date default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_naive_time() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("NaiveTime").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "time_field", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "NaiveTime should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("00:00:00"), - "NaiveTime should have time default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_time_type() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("Time").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "time_field", - &ty, - &ty, - false, - &mut fns, - ); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "Time should generate serde default: {serde_str}" - ); - let schema_str = schema.to_string(); - assert!( - schema_str.contains("00:00:00"), - "Time should have time default: {schema_str}" - ); - assert_eq!(fns.len(), 1); -} - -// --- Coverage: is_parseable_type empty segments --- - -#[test] -fn test_is_parseable_type_empty_segments() { - // Synthetically construct a Type::Path with empty segments (impossible through parsing) - let ty = syn::Type::Path(syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: syn::punctuated::Punctuated::new(), - }, - }); - assert!(!is_parseable_type(&ty)); -} - -#[test] -fn test_generate_schema_type_code_partial_nonexistent_field() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UpdateUser from User, partial = ["nonexistent"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("does not exist")); - assert!(err.contains("nonexistent")); -} - -#[test] -fn test_generate_schema_type_code_partial_from_impl_wraps_some() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UpdateUser from User, partial); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("Some (source . id)")); - assert!(output.contains("Some (source . name)")); -} - -#[test] -fn test_generate_schema_type_code_preserves_struct_doc() { - let input = SchemaTypeInput { - new_type: syn::Ident::new("NewUser", proc_macro2::Span::call_site()), - source_type: syn::parse_str("User").unwrap(), - omit: None, - pick: None, - rename: None, - add: None, - derive_clone: true, - partial: None, - schema_name: None, - ignore_schema: false, - rename_all: None, - multipart: false, - omit_default: false, - }; - let struct_def = StructMetadata { - name: "User".to_string(), - definition: r" - /// User struct documentation - pub struct User { - /// The user ID - pub id: i32, - /// The user name - pub name: String, - } - " - .to_string(), - include_in_openapi: true, - field_defaults: std::collections::BTreeMap::new(), - }; - let storage = to_storage(vec![struct_def]); - let result = generate_schema_type_code(&input, &storage); - assert!(result.is_ok()); - let (tokens, _) = result.unwrap(); - let tokens_str = tokens.to_string(); - assert!(tokens_str.contains("User struct documentation") || tokens_str.contains("doc")); -} - -// Tests for serde attribute filtering from source struct - -#[test] -fn test_generate_schema_type_code_inherits_source_rename_all() { - // Source struct has serde(rename_all = "snake_case") - let storage = to_storage(vec![create_test_struct_metadata( - "User", - r#"#[serde(rename_all = "snake_case")] - pub struct User { pub id: i32, pub user_name: String }"#, - )]); - - let tokens = quote!(UserResponse from User); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should use snake_case from source - assert!(output.contains("rename_all")); - assert!(output.contains("snake_case")); -} - -#[test] -fn test_generate_schema_type_code_override_rename_all() { - // Source has snake_case, but we override with camelCase - let storage = to_storage(vec![create_test_struct_metadata( - "User", - r#"#[serde(rename_all = "snake_case")] - pub struct User { pub id: i32, pub user_name: String }"#, - )]); - - let tokens = quote!(UserResponse from User, rename_all = "camelCase"); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should use camelCase (our override) - assert!(output.contains("camelCase")); -} - -// Tests for field rename processing - -#[test] -fn test_generate_schema_type_code_with_rename() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserDTO from User, rename = [("id", "user_id")]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("user_id")); - // The From impl should map user_id from source.id - assert!(output.contains("From")); -} - -#[test] -fn test_generate_schema_type_code_rename_preserves_serde_rename() { - // Source field already has serde(rename), which should be preserved as the JSON name - let storage = to_storage(vec![create_test_struct_metadata( - "User", - r#"pub struct User { - pub id: i32, - #[serde(rename = "userName")] - pub name: String - }"#, - )]); - - let tokens = quote!(UserDTO from User, rename = [("name", "user_name")]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // The Rust field is renamed to user_name - assert!(output.contains("user_name")); - // The JSON name should be preserved as userName - assert!(output.contains("userName") || output.contains("rename")); -} - -// Tests for schema derive and name attribute generation - -#[test] -fn test_generate_schema_type_code_with_ignore_schema() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserInternal from User, ignore); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should NOT contain vespera::Schema derive - assert!(!output.contains("vespera :: Schema")); -} - -#[test] -fn test_generate_schema_type_code_with_custom_name() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserResponse from User, name = "CustomUserSchema"); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should contain schema(name = "...") attribute - assert!(output.contains("schema")); - assert!(output.contains("CustomUserSchema")); - // Metadata should be returned - assert!(metadata.is_some()); - let meta = metadata.unwrap(); - assert_eq!(meta.name, "CustomUserSchema"); -} - -#[test] -fn test_generate_schema_type_code_with_clone_false() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub name: String }", - )]); - - let tokens = quote!(UserNonClone from User, clone = false); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should NOT contain Clone derive - assert!(!output.contains("Clone ,")); -} - -// Test for SeaORM model detection - -#[test] -fn test_generate_schema_type_code_seaorm_model_detection() { - // Source struct has sea_orm attribute - should be detected as SeaORM model - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "users")] - pub struct Model { pub id: i32, pub name: String }"#, - )]); - - let tokens = quote!(UserSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("UserSchema")); -} - -// Test tuple struct handling - -#[test] -fn test_generate_schema_type_code_tuple_struct() { - // Tuple structs have no named fields - let storage = to_storage(vec![create_test_struct_metadata( - "Point", - "pub struct Point(pub i32, pub i32);", - )]); - - let tokens = quote!(PointDTO from Point); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("PointDTO")); -} - -// Test raw identifier fields - -#[test] -fn test_generate_schema_type_code_raw_identifier_field() { - // Field name is a Rust keyword with r# prefix - let storage = to_storage(vec![create_test_struct_metadata( - "Config", - "pub struct Config { pub id: i32, pub r#type: String }", - )]); - - let tokens = quote!(ConfigDTO from Config); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("ConfigDTO")); -} - -// Test Option field not double-wrapped with partial - -#[test] -fn test_generate_schema_type_code_partial_no_double_option() { - // bio is already Option, partial should NOT wrap it again - let storage = to_storage(vec![create_test_struct_metadata( - "User", - "pub struct User { pub id: i32, pub bio: Option }", - )]); - - let tokens = quote!(UpdateUser from User, partial); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // bio should remain Option, not Option> - assert!(!output.contains("Option < Option")); -} - -// Test serde(skip) fields are excluded - -#[test] -fn test_generate_schema_code_excludes_serde_skip_fields() { - let storage = to_storage(vec![create_test_struct_metadata( - "User", - r"pub struct User { - pub id: i32, - #[serde(skip)] - pub internal_state: String, - pub name: String - }", - )]); - - let tokens = quote!(User); - let input: SchemaInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_code(&input, &storage); - - assert!(result.is_ok()); - let output = result.unwrap().to_string(); - // internal_state should be excluded from schema properties - assert!(!output.contains("internal_state")); - assert!(output.contains("name")); -} - -// Tests for qualified path storage fallback -// Note: This tests the case where is_qualified_path returns true -// and we find the struct in schema_storage rather than via file lookup - -#[test] -fn test_generate_schema_type_code_qualified_path_storage_lookup() { - // Use a qualified path like crate::models::user::Model - // The storage contains Model, so it should fallback to storage lookup - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - "pub struct Model { pub id: i32, pub name: String }", - )]); - - // Note: This qualified path won't find files (no real filesystem), - // so it falls back to storage lookup by the simple name "Model" - let tokens = quote!(UserSchema from crate::models::user::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - // This should succeed by finding Model in storage - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("UserSchema")); -} - -// Test for qualified path not found error - -#[test] -fn test_generate_schema_type_code_qualified_path_not_found() { - // Empty storage - qualified path should fail - let storage: HashMap = HashMap::new(); - - let tokens = quote!(UserSchema from crate::models::user::NonExistent); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - // Should fail with "not found" error - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("not found")); -} - -// Tests for HasMany excluded by default - -#[test] -fn test_generate_schema_type_code_has_many_excluded_by_default() { - // SeaORM model with HasMany relation - should be excluded by default - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "users")] - pub struct Model { - pub id: i32, - pub name: String, - pub memos: HasMany - }"#, - )]); - - let tokens = quote!(UserSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // HasMany field should NOT appear in output (excluded by default) - assert!(!output.contains("memos")); - // But regular fields should appear - assert!(output.contains("name")); -} - -// Test for relation conversion failure skip - -#[test] -fn test_generate_schema_type_code_relation_conversion_failure() { - // Model with relation type but missing generic args - conversion should fail - // The field should be skipped - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "users")] - pub struct Model { - pub id: i32, - pub name: String, - pub broken: HasMany - }"#, - )]); - - let tokens = quote!(UserSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - // Should succeed but skip the broken field - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Broken field should be skipped - assert!(!output.contains("broken")); - // Regular fields should appear - assert!(output.contains("name")); -} - -// Coverage test for BelongsTo relation type conversion - -#[test] -fn test_generate_schema_type_code_belongs_to_relation() { - // SeaORM model with BelongsTo relation - should be included - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "memos")] - pub struct Model { - pub id: i32, - pub user_id: i32, - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id")] - pub user: BelongsTo - }"#, - )]); - - let tokens = quote!(MemoSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // BelongsTo should be included (converted to Box or similar) - assert!(output.contains("user")); -} - -// Coverage test for HasOne relation type - -#[test] -fn test_generate_schema_type_code_has_one_relation() { - // SeaORM model with HasOne relation - should be included - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "users")] - pub struct Model { - pub id: i32, - pub name: String, - pub profile: HasOne - }"#, - )]); - - let tokens = quote!(UserSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // HasOne should be included - assert!(output.contains("profile")); -} - -// Test for relation fields push into relation_fields - -#[test] -fn test_generate_schema_type_code_seaorm_model_with_relation_generates_from_model() { - // When a SeaORM model has FK relations (HasOne/BelongsTo), - // it should generate from_model impl instead of From impl - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "memos")] - pub struct Model { - pub id: i32, - pub title: String, - pub user: BelongsTo - }"#, - )]); - - let tokens = quote!(MemoSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should have relation field - assert!(output.contains("user")); - // Should NOT have regular From impl (because of relation) - // The From impl is only generated when there are no relation fields -} - -// Test for from_model generation with relations -// Note: This requires is_source_seaorm_model && has_relation_fields -// The from_model generation happens but needs file lookup for full path - -#[test] -fn test_generate_schema_type_code_from_model_generation() { - // SeaORM model with relation should trigger from_model generation - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "memos")] - pub struct Model { - pub id: i32, - pub user: BelongsTo - }"#, - )]); - - let tokens = quote!(MemoSchema from Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Has relation field - assert!(output.contains("user")); - // Regular impl From should NOT be present (because has relations) - // Check that we don't have "impl From < Model > for MemoSchema" - // (Relations disable the automatic From impl) -} - -#[test] -#[serial] -fn test_generate_schema_type_code_qualified_path_file_lookup_success() { - // Tests: qualified path found via file lookup, module_path used when source is empty - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs with Model struct - let user_model = r" -pub struct Model { - pub id: i32, - pub name: String, - pub email: String, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Use qualified path - file lookup should succeed - let tokens = quote!(UserSchema from crate::models::user::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); // Empty storage - force file lookup - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("UserSchema")); - assert!(output.contains("id")); - assert!(output.contains("name")); - assert!(output.contains("email")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_simple_name_file_lookup_fallback() { - // Tests: simple name (not in storage) found via file lookup with schema_name hint - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs with Model struct - let user_model = r" -pub struct Model { - pub id: i32, - pub username: String, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Use simple name with schema_name hint - file lookup should find it via hint - // name = "UserSchema" provides hint to look in user.rs - let tokens = quote!(Schema from Model, name = "UserSchema"); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); // Empty storage - force file lookup - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("Schema")); - assert!(output.contains("id")); - assert!(output.contains("username")); - // Metadata should be returned for custom name - assert!(metadata.is_some()); - assert_eq!(metadata.unwrap().name, "UserSchema"); -} - -// ============================================================ -// Tests for HasMany explicit pick with inline type -// ============================================================ - -#[test] -#[serial] -fn test_generate_schema_type_code_has_many_explicit_pick_inline_type() { - // Tests: HasMany is explicitly picked, inline type is generated - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create memo.rs with Model struct (the target of HasMany) - let memo_model = r" -pub struct Model { - pub id: i32, - pub title: String, - pub content: String, -} -"; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Create user.rs with Model struct that has HasMany relation - let user_model = r#" -#[sea_orm(table_name = "users")] -pub struct Model { - pub id: i32, - pub name: String, - pub memos: HasMany, -} -"#; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Explicitly pick HasMany field - should generate inline type - let tokens = quote!(UserSchema from crate::models::user::Model, pick = ["id", "name", "memos"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should have inline type definition for memos - assert!(output.contains("UserSchema")); - assert!(output.contains("memos")); - // Inline type should be Vec - assert!(output.contains("Vec <")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_has_many_explicit_pick_file_not_found() { - // Tests: HasMany is explicitly picked but target file not found - should skip field - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs with Model struct that has HasMany to nonexistent model - let user_model = r#" -#[sea_orm(table_name = "users")] -pub struct Model { - pub id: i32, - pub name: String, - pub items: HasMany, -} -"#; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Explicitly pick HasMany field - file not found, should skip - let tokens = quote!(UserSchema from crate::models::user::Model, pick = ["id", "name", "items"]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // items field should be skipped (file not found for inline type) - assert!(!output.contains("items")); - // But other fields should exist - assert!(output.contains("id")); - assert!(output.contains("name")); -} - -#[test] -fn test_derive_response_base_name_handles_known_suffixes_and_fallback() { - assert_eq!(derive_response_base_name("UserResponse"), "User"); - assert_eq!(derive_response_base_name("UserRequest"), "User"); - assert_eq!(derive_response_base_name("UserSchema"), "User"); - assert_eq!(derive_response_base_name("User"), "User"); -} - -#[test] -fn test_find_same_file_struct_metadata_reads_test_fixture_from_current_module() { - let storage: HashMap = HashMap::new(); - let metadata = find_same_file_struct_metadata("__VesperaSameFileLookupFixture", &storage) - .expect("fixture should be found in schema_macro/mod.rs"); - - assert_eq!(metadata.name, "__VesperaSameFileLookupFixture"); - assert!( - metadata - .definition - .contains("__VesperaSameFileLookupFixture") - ); - assert!(metadata.definition.contains("value")); -} - -#[test] -fn test_has_derive_ignores_non_derive_attrs_and_detects_requested_derive() { - let struct_item: syn::ItemStruct = syn::parse_str( - r#" - #[serde(rename_all = "camelCase")] - #[derive(Clone, Debug)] - struct Sample { - value: i32, - } - "#, - ) - .unwrap(); - - assert!(has_derive(&struct_item, "Clone")); - assert!(!has_derive(&struct_item, "Deserialize")); -} - -#[test] -fn test_build_named_struct_field_assignments_rejects_tuple_structs() { - let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); - let source_expr = quote!(source); - let error = build_named_struct_field_assignments(&struct_item, &source_expr).unwrap_err(); - assert!(error.to_string().contains("named-field struct")); -} - -#[test] -fn test_build_proxy_fields_rejects_tuple_structs() { - let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); - let error = build_proxy_fields(&struct_item).unwrap_err(); - assert!(error.to_string().contains("named-field struct")); -} - -#[test] -fn test_build_proxy_to_dto_assignments_rejects_tuple_structs() { - let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); - let error = build_proxy_to_dto_assignments(&struct_item).unwrap_err(); - assert!(error.to_string().contains("named-field struct")); -} - -#[test] -fn test_build_clone_assignments_rejects_tuple_structs() { - let struct_item: syn::ItemStruct = syn::parse_str("struct TupleDto(String);").unwrap(); - let error = build_clone_assignments(&struct_item).unwrap_err(); - assert!(error.to_string().contains("named-field struct")); -} - -#[test] -fn test_maybe_generate_same_file_relation_override_returns_none_when_dto_is_missing() { - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "HasOne".to_string(), - schema_path: quote!(crate::models::user::Schema), - is_optional: true, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - let storage: HashMap = HashMap::new(); - let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); - - let result = - maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) - .expect("missing dto should not error"); - assert!(result.is_none()); -} - -#[test] -fn test_maybe_generate_same_file_relation_override_returns_none_for_invalid_model_type() { - let rel_info = RelationFieldInfo { - field_name: syn::Ident::new("user", proc_macro2::Span::call_site()), - relation_type: "HasOne".to_string(), - schema_path: quote!(?), - is_optional: true, - inline_type_info: None, - relation_enum: None, - fk_column: None, - via_rel: None, - }; - - let storage = to_storage(vec![create_test_struct_metadata( - "UserInArticle", - "struct UserInArticle { id: i32 }", - )]); - let new_type_name = syn::Ident::new("ArticleResponse", proc_macro2::Span::call_site()); - - let result = - maybe_generate_same_file_relation_override(&new_type_name, "user", &rel_info, &storage) - .expect("invalid model type should not error"); - assert!(result.is_none()); -} - -#[test] -fn test_generate_schema_type_code_normal_mode_relation_rename_and_custom_name() { - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "articles")] - pub struct Model { - pub id: i32, - pub name: String, - pub owner: HasOne - }"#, - )]); - - let tokens = quote!( - ArticleResponse from Model, - name = "CustomArticleSchema", - rename = [("name", "display_name")] - ); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("display_name")); - assert!(output.contains("owner")); - assert!(output.contains("Clone")); - assert!(output.contains("CustomArticleSchema")); - assert_eq!(metadata.unwrap().name, "CustomArticleSchema"); -} - -#[test] -fn test_generate_schema_type_code_multipart_with_add_and_custom_name() { - let storage = to_storage(vec![create_test_struct_metadata( - "Upload", - "pub struct Upload { pub id: i32, pub name: String }", - )]); - - let tokens = quote!( - UploadForm from Upload, - multipart, - name = "UploadFormSchema", - add = [("extra": String)] - ); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("vespera :: Multipart")); - assert!(output.contains("extra")); - assert!(output.contains("UploadFormSchema")); - assert_eq!(metadata.unwrap().name, "UploadFormSchema"); -} - -// ============================================================ -// Tests for BelongsTo/HasOne circular reference inline types -// ============================================================ - -#[test] -#[serial] -fn test_generate_schema_type_code_belongs_to_circular_inline_optional() { - // Tests: BelongsTo with circular reference, optional field (is_optional = true) - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs with Model that references memo (circular) - let user_model = r#" -#[sea_orm(table_name = "users")] -pub struct Model { - pub id: i32, - pub name: String, - pub memo: BelongsTo, -} -"#; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Create memo.rs with Model that references user (completing the circle) - let memo_model = r#" -#[sea_orm(table_name = "memos")] -pub struct Model { - pub id: i32, - pub title: String, - pub user_id: i32, - pub user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Generate schema from memo - has BelongsTo user which has circular ref back - let tokens = quote!(MemoSchema from crate::models::memo::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should have inline type definition for circular relation - assert!(output.contains("MemoSchema")); - assert!(output.contains("user")); - // BelongsTo is optional by default, so should have Option> - assert!(output.contains("Option < Box <")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_has_one_circular_inline_required() { - // Tests: HasOne with circular reference, required field (is_optional = false) - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create profile.rs with Model that references user (circular) - let profile_model = r#" -#[sea_orm(table_name = "profiles")] -pub struct Model { - pub id: i32, - pub bio: String, - pub user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("profile.rs"), profile_model).unwrap(); - - // Create user.rs with Model that has HasOne profile - // HasOne with required FK becomes required (non-optional) - let user_model = r#" -#[sea_orm(table_name = "users")] -pub struct Model { - pub id: i32, - pub name: String, - pub profile_id: i32, - #[sea_orm(has_one = "super::profile::Entity", from = "profile_id")] - pub profile: HasOne, -} -"#; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Generate schema from user - has HasOne profile which has circular ref back - let tokens = quote!(UserSchema from crate::models::user::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should have inline type definition for circular relation - assert!(output.contains("UserSchema")); - assert!(output.contains("profile")); - // HasOne with required FK should have Box<...> (not Option>) - assert!(output.contains("Box <")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_belongs_to_circular_inline_required_file() { - // Tests: BelongsTo with circular reference AND required FK (is_optional = false) - // This requires file-based lookup with: - // 1. #[sea_orm(from = "required_fk")] where required_fk is NOT Option - // 2. Circular reference between two models - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs with Model that references memo (circular) - let user_model = r#" -#[sea_orm(table_name = "users")] -pub struct Model { - pub id: i32, - pub name: String, - pub memo_id: i32, - #[sea_orm(belongs_to, from = "memo_id", to = "id")] - pub memo: BelongsTo, -} -"#; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Create memo.rs with Model that references user (completing the circle) - // Note: using flag-style `belongs_to` with `from = "user_id"` - let memo_model = r#" -#[sea_orm(table_name = "memos")] -pub struct Model { - pub id: i32, - pub title: String, - pub user_id: i32, - #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: BelongsTo, -} -"#; - std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // Generate schema from memo - has BelongsTo user which has circular ref back - // The user_id field is required (not Option), so is_optional = false - // This should generate Box<...> instead of Option> - let tokens = quote!(MemoSchema from crate::models::memo::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok(), "Should generate schema: {:?}", result.err()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should have inline type definition for circular relation - assert!( - output.contains("MemoSchema"), - "Should contain MemoSchema: {output}" - ); - assert!( - output.contains("user"), - "Should contain user field: {output}" - ); - // BelongsTo with required FK (user_id: i32) should generate Box<...> not Option> - assert!( - output.contains("pub user : Box <"), - "BelongsTo with required FK should generate Box<>, not Option>. Output: {output}" - ); -} - -#[test] -fn test_seaorm_relation_required_fk_directly() { - // Test the convert_relation_type_to_schema_with_info function directly - // to verify is_optional = false when FK is required - use crate::schema_macro::seaorm::{ - convert_relation_type_to_schema_with_info, extract_belongs_to_from_field, - is_field_optional_in_struct, - }; - - // Use the same attribute format that works in seaorm tests: belongs_to (flag), not belongs_to = "..." - let struct_def = r#" -#[sea_orm(table_name = "memos")] -pub struct Model { - pub id: i32, - pub user_id: i32, - #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: BelongsTo, -} -"#; - let parsed_struct: syn::ItemStruct = syn::parse_str(struct_def).unwrap(); - - // Get the user field - let syn::Fields::Named(fields_named) = &parsed_struct.fields else { - panic!("Expected named fields") - }; - - let user_field = fields_named - .named - .iter() - .find(|f| f.ident.as_ref().is_some_and(|i| i == "user")) - .expect("user field not found"); - - // Debug: Check if extract_belongs_to_from_field works - let fk_field = extract_belongs_to_from_field(&user_field.attrs); - assert_eq!( - fk_field, - Some("user_id".to_string()), - "Should extract FK field from attribute" - ); - - // Debug: Check if is_field_optional_in_struct works - let is_fk_optional = is_field_optional_in_struct(&parsed_struct, "user_id"); - assert!(!is_fk_optional, "user_id: i32 should not be optional"); - - let result = convert_relation_type_to_schema_with_info( - &user_field.ty, - &user_field.attrs, - &parsed_struct, - &[ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ], - user_field.ident.clone().unwrap(), - ); - - assert!(result.is_some(), "Should convert BelongsTo relation"); - let (_, rel_info) = result.unwrap(); - assert_eq!(rel_info.relation_type, "BelongsTo"); - // The FK field user_id is i32 (not Option), so is_optional should be false - assert!( - !rel_info.is_optional, - "BelongsTo with required FK (user_id: i32) should have is_optional = false" - ); -} - -#[test] -fn test_extract_belongs_to_from_field_with_equals_value() { - // Test that extract_belongs_to_from_field works with belongs_to = "..." format - use crate::schema_macro::seaorm::extract_belongs_to_from_field; - - // Format 1: belongs_to (flag style) - known to work - let attrs1: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to, from = "user_id", to = "id")] - )]; - let result1 = extract_belongs_to_from_field(&attrs1); - assert_eq!( - result1, - Some("user_id".to_string()), - "Flag style should work" - ); - - // Format 2: belongs_to = "..." (value style) - testing this - let attrs2: Vec = vec![syn::parse_quote!( - #[sea_orm(belongs_to = "super::user::Entity", from = "user_id", to = "id")] - )]; - let result2 = extract_belongs_to_from_field(&attrs2); - assert_eq!( - result2, - Some("user_id".to_string()), - "Value style should also work" - ); -} - -// ============================================================ -// Tests for multipart mode -// ============================================================ - -#[test] -fn test_generate_schema_type_code_multipart_basic() { - // Tests: multipart mode generates Multipart derive, suppresses From impl - let storage = to_storage(vec![create_test_struct_metadata( - "UploadRequest", - "pub struct UploadRequest { pub name: String, pub description: Option }", - )]); - - let tokens = quote!(PatchUpload from UploadRequest, multipart); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should derive Multipart - assert!(output.contains("Multipart")); - // Should NOT have From impl (multipart suppresses it) - assert!(!output.contains("impl From")); - // Should have the struct fields - assert!(output.contains("name")); - assert!(output.contains("description")); -} - -#[test] -fn test_generate_schema_type_code_multipart_with_rename() { - // Tests: multipart mode with field rename - let storage = to_storage(vec![create_test_struct_metadata( - "UploadRequest", - "pub struct UploadRequest { pub name: String, pub file_path: String }", - )]); - - let tokens = quote!(RenamedUpload from UploadRequest, multipart, rename = [("file_path", "document_path")]); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should derive Multipart - assert!(output.contains("Multipart")); - // Should have renamed field - assert!(output.contains("document_path")); - // Original name should NOT appear as field - assert!(!output.contains("file_path")); -} - -#[test] -fn test_generate_schema_type_code_multipart_with_form_data_attrs() { - // Tests: multipart mode preserves #[form_data] attributes from source - let storage = to_storage(vec![create_test_struct_metadata( - "UploadRequest", - r#"pub struct UploadRequest { - pub name: String, - #[form_data(limit = "10MiB")] - pub file: String - }"#, - )]); - - let tokens = quote!(PatchUpload from UploadRequest, multipart); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should preserve form_data attributes - assert!(output.contains("form_data")); - assert!(output.contains("limit")); -} - -#[test] -fn test_generate_schema_type_code_multipart_skips_relations() { - // Tests: multipart mode skips relation fields - let storage = to_storage(vec![create_test_struct_metadata( - "Model", - r#"#[sea_orm(table_name = "memos")] - pub struct Model { - pub id: i32, - pub title: String, - pub user: BelongsTo - }"#, - )]); - - let tokens = quote!(MemoUpload from Model, multipart); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Relation field should be skipped in multipart mode - assert!(!output.contains("user")); - // Regular fields should be present - assert!(output.contains("id")); - assert!(output.contains("title")); - // Should derive Multipart - assert!(output.contains("Multipart")); -} - -#[test] -fn test_generate_schema_type_code_multipart_partial() { - // Coverage for multipart + partial combination - let storage = to_storage(vec![create_test_struct_metadata( - "UploadRequest", - "pub struct UploadRequest { pub name: String, pub tags: String }", - )]); - - let tokens = quote!(PatchUpload from UploadRequest, multipart, partial); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let result = generate_schema_type_code(&input, &storage); - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - // Should derive Multipart - assert!(output.contains("Multipart")); - // Fields should be wrapped in Option (partial) - assert!(output.contains("Option")); - // Should NOT have From impl - assert!(!output.contains("impl From")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_qualified_path_with_nonempty_module_path() { - // Tests: qualified path with explicit module segments that are not empty - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - std::fs::create_dir_all(&models_dir).unwrap(); - - // Create user.rs - let user_model = r" -pub struct Model { - pub id: i32, - pub name: String, -} -"; - std::fs::write(models_dir.join("user.rs"), user_model).unwrap(); - - // Save original CARGO_MANIFEST_DIR - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: This is a test that runs single-threaded - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - // crate::models::user::Model - this is a qualified path - // extract_module_path should return ["crate", "models", "user"] - // So the if source_module_path.is_empty() check should be false - let tokens = quote!(UserSchema from crate::models::user::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - - let result = generate_schema_type_code(&input, &storage); - - // Restore CARGO_MANIFEST_DIR - // SAFETY: This is a test that runs single-threaded - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("UserSchema")); -} - -#[test] -#[serial] -fn test_generate_schema_type_code_cross_module_json_alias_uses_public_path() { - use tempfile::TempDir; - - let temp_dir = TempDir::new().unwrap(); - let src_dir = temp_dir.path().join("src"); - let models_dir = src_dir.join("models"); - let routes_dir = src_dir.join("routes"); - std::fs::create_dir_all(&models_dir).unwrap(); - std::fs::create_dir_all(&routes_dir).unwrap(); - - let json_case_model = r#" -use sea_orm::entity::prelude::*; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "json_case")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub payload: Json, -} - -impl ActiveModelBehavior for ActiveModel {} -"#; - std::fs::write(models_dir.join("json_case.rs"), json_case_model).unwrap(); - std::fs::write( - routes_dir.join("json_case.rs"), - "vespera::schema_type!(RouteJsonCaseSchema from crate::models::json_case::Model);", - ) - .unwrap(); - - let original_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let tokens = quote!(RouteJsonCaseSchema from crate::models::json_case::Model); - let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); - let storage: HashMap = HashMap::new(); - let result = generate_schema_type_code(&input, &storage); - - unsafe { - if let Some(dir) = original_manifest_dir { - std::env::set_var("CARGO_MANIFEST_DIR", dir); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - - assert!(result.is_ok()); - let (tokens, _metadata) = result.unwrap(); - let output = tokens.to_string(); - assert!(output.contains("pub payload : vespera :: serde_json :: Value")); - assert!(!output.contains("crate :: models :: json_case :: Json")); -} diff --git a/crates/vespera_macro/src/schema_macro/transformation.rs b/crates/vespera_macro/src/schema_macro/transformation.rs index ce2dce6c..b5f88267 100644 --- a/crates/vespera_macro/src/schema_macro/transformation.rs +++ b/crates/vespera_macro/src/schema_macro/transformation.rs @@ -184,259 +184,7 @@ pub fn should_wrap_in_option( } #[cfg(test)] -mod tests { - use super::*; +mod tests; - #[test] - fn test_build_omit_set() { - let omit = Some(vec!["password".to_string(), "secret".to_string()]); - let set = build_omit_set(omit.as_ref()); - - assert!(set.contains("password")); - assert!(set.contains("secret")); - assert_eq!(set.len(), 2); - } - - #[test] - fn test_build_omit_set_none() { - let set = build_omit_set(None); - assert!(set.is_empty()); - } - - #[test] - fn test_build_pick_set() { - let pick = Some(vec!["id".to_string(), "name".to_string()]); - let set = build_pick_set(pick.as_ref()); - - assert!(set.contains("id")); - assert!(set.contains("name")); - assert_eq!(set.len(), 2); - } - - #[test] - fn test_build_partial_config_all() { - let partial = Some(PartialMode::All); - let (all, set) = build_partial_config(&partial); - - assert!(all); - assert!(set.is_empty()); - } - - #[test] - fn test_build_partial_config_fields() { - let partial = Some(PartialMode::Fields(vec![ - "name".to_string(), - "email".to_string(), - ])); - let (all, set) = build_partial_config(&partial); - - assert!(!all); - assert!(set.contains("name")); - assert!(set.contains("email")); - } - - #[test] - fn test_build_partial_config_none() { - let (all, set) = build_partial_config(&None); - - assert!(!all); - assert!(set.is_empty()); - } - - #[test] - fn test_build_rename_map() { - let rename = Some(vec![ - ("id".to_string(), "user_id".to_string()), - ("name".to_string(), "full_name".to_string()), - ]); - let map = build_rename_map(rename.as_ref()); - - assert_eq!(map.get("id"), Some(&"user_id".to_string())); - assert_eq!(map.get("name"), Some(&"full_name".to_string())); - } - - #[test] - fn test_build_rename_map_none() { - let map = build_rename_map(None); - assert!(map.is_empty()); - } - - #[test] - fn test_extract_serde_attrs_without_rename_all() { - let attrs: Vec = vec![ - syn::parse_quote!(#[serde(rename_all = "camelCase")]), - syn::parse_quote!(#[serde(default)]), - syn::parse_quote!(#[doc = "Some doc"]), - ]; - - let filtered = extract_serde_attrs_without_rename_all(&attrs); - - assert_eq!(filtered.len(), 1); - // Should keep #[serde(default)] but not #[serde(rename_all = ...)] - } - - #[test] - fn test_extract_doc_attrs() { - let attrs: Vec = vec![ - syn::parse_quote!(#[doc = "First doc"]), - syn::parse_quote!(#[serde(default)]), - syn::parse_quote!(#[doc = "Second doc"]), - ]; - - let docs = extract_doc_attrs(&attrs); - - assert_eq!(docs.len(), 2); - } - - #[test] - fn test_determine_rename_all_with_input() { - let attrs: Vec = - vec![syn::parse_quote!(#[serde(rename_all = "snake_case")])]; - - let result = determine_rename_all(Some(&"PascalCase".to_string()), &attrs); - - assert_eq!(result, "PascalCase"); - } - - #[test] - fn test_determine_rename_all_from_source() { - let attrs: Vec = - vec![syn::parse_quote!(#[serde(rename_all = "snake_case")])]; - - let result = determine_rename_all(None, &attrs); - - assert_eq!(result, "snake_case"); - } - - #[test] - fn test_determine_rename_all_default() { - let attrs: Vec = vec![]; - - let result = determine_rename_all(None, &attrs); - - assert_eq!(result, "camelCase"); - } - - #[test] - fn test_extract_field_serde_attrs() { - let attrs: Vec = vec![ - syn::parse_quote!(#[serde(rename = "userId")]), - syn::parse_quote!(#[doc = "The user ID"]), - syn::parse_quote!(#[serde(default)]), - ]; - - let serde_attrs = extract_field_serde_attrs(&attrs); - - assert_eq!(serde_attrs.len(), 2); - } - - #[test] - #[allow(clippy::similar_names)] - fn test_filter_out_serde_rename() { - let attr1: syn::Attribute = syn::parse_quote!(#[serde(rename = "userId")]); - let attr2: syn::Attribute = syn::parse_quote!(#[serde(default)]); - let attrs: Vec<&syn::Attribute> = vec![&attr1, &attr2]; - - let filtered = filter_out_serde_rename(&attrs); - - assert_eq!(filtered.len(), 1); - } - - #[test] - fn test_should_skip_field_omit() { - let omit_set: HashSet = ["password".to_string()].into_iter().collect(); - let pick_set: HashSet = HashSet::new(); - - assert!(should_skip_field("password", &omit_set, &pick_set)); - assert!(!should_skip_field("name", &omit_set, &pick_set)); - } - - #[test] - fn test_should_skip_field_pick() { - let omit_set: HashSet = HashSet::new(); - let pick_set: HashSet = - ["id".to_string(), "name".to_string()].into_iter().collect(); - - assert!(should_skip_field("email", &omit_set, &pick_set)); - assert!(!should_skip_field("id", &omit_set, &pick_set)); - } - - #[test] - fn test_should_skip_field_no_filters() { - let omit_set: HashSet = HashSet::new(); - let pick_set: HashSet = HashSet::new(); - - assert!(!should_skip_field("any_field", &omit_set, &pick_set)); - } - - #[test] - fn test_should_wrap_in_option_partial_all() { - let partial_set: HashSet = HashSet::new(); - - assert!(should_wrap_in_option( - "name", - true, - &partial_set, - false, - false - )); - assert!(!should_wrap_in_option( - "name", - true, - &partial_set, - true, - false - )); // already option - assert!(!should_wrap_in_option( - "rel", - true, - &partial_set, - false, - true - )); // relation - } - - #[test] - fn test_extract_form_data_attrs() { - let attrs: Vec = vec![ - syn::parse_quote!(#[form_data(limit = "10MiB")]), - syn::parse_quote!(#[serde(default)]), - syn::parse_quote!(#[doc = "Some doc"]), - syn::parse_quote!(#[form_data(field_name = "my_file")]), - ]; - - let form_data = extract_form_data_attrs(&attrs); - assert_eq!(form_data.len(), 2); - } - - #[test] - fn test_extract_form_data_attrs_empty() { - let attrs: Vec = vec![ - syn::parse_quote!(#[serde(default)]), - syn::parse_quote!(#[doc = "Some doc"]), - ]; - - let form_data = extract_form_data_attrs(&attrs); - assert!(form_data.is_empty()); - } - - #[test] - fn test_should_wrap_in_option_partial_fields() { - let partial_set: HashSet = ["name".to_string()].into_iter().collect(); - - assert!(should_wrap_in_option( - "name", - false, - &partial_set, - false, - false - )); - assert!(!should_wrap_in_option( - "email", - false, - &partial_set, - false, - false - )); - } -} +#[cfg(test)] +mod schema_type_option_tests; diff --git a/crates/vespera_macro/src/schema_macro/transformation/schema_type_option_tests.rs b/crates/vespera_macro/src/schema_macro/transformation/schema_type_option_tests.rs new file mode 100644 index 00000000..eb443f53 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/transformation/schema_type_option_tests.rs @@ -0,0 +1,502 @@ +use std::collections::HashMap; + +use quote::quote; + +use crate::metadata::StructMetadata; +use crate::schema_macro::{ + SchemaInput, SchemaTypeInput, generate_schema_code, generate_schema_type_code, +}; + +fn create_test_struct_metadata(name: &str, definition: &str) -> StructMetadata { + StructMetadata::new(name.to_string(), definition.to_string()) +} + +fn to_storage(items: Vec) -> HashMap { + items.into_iter().map(|s| (s.name.clone(), s)).collect() +} + +// Tests for field rename processing + +#[test] +fn test_generate_schema_type_code_with_rename() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserDTO from User, rename = [("id", "user_id")]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("user_id")); + // The From impl should map user_id from source.id + assert!(output.contains("From")); +} + +#[test] +fn test_generate_schema_type_code_rename_preserves_serde_rename() { + // Source field already has serde(rename), which should be preserved as the JSON name + let storage = to_storage(vec![create_test_struct_metadata( + "User", + r#"pub struct User { + pub id: i32, + #[serde(rename = "userName")] + pub name: String + }"#, + )]); + + let tokens = quote!(UserDTO from User, rename = [("name", "user_name")]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // The Rust field is renamed to user_name + assert!(output.contains("user_name")); + // The JSON name should be preserved as userName + assert!(output.contains("userName") || output.contains("rename")); +} + +#[test] +fn test_generate_schema_type_code_rename_invalid_target_errors_not_panics() { + // Regression: a `rename` target that is not a valid Rust identifier used + // to panic the proc-macro at `syn::Ident::new`. It must now return a + // spanned `Err` so the user sees a compile diagnostic, not an aborted + // expansion. + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserDTO from User, rename = [("id", "user-id")]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err(), "invalid rename target must Err, not panic"); + assert!(result.unwrap_err().to_string().contains("user-id")); +} + +#[test] +fn test_generate_schema_type_code_add_invalid_ident_errors_not_panics() { + // Same class of bug as rename: an `add` field name that is not a valid + // identifier must Err, not panic at `syn::Ident::new`. + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32 }", + )]); + + let tokens = quote!(UserDTO from User, add = [("bad-field": String)]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_err(), "invalid add ident must Err, not panic"); + assert!(result.unwrap_err().to_string().contains("bad-field")); +} + +#[test] +fn test_schema_type_duplicate_param_rejected() { + // A repeated parameter must be a spanned parse error, not a silent + // last-value-wins overwrite. + let tokens = quote!(UserDTO from User, pick = ["id"], pick = ["name"]); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err(), "duplicate `pick` must be rejected"); + assert!( + result + .err() + .unwrap() + .to_string() + .contains("duplicate parameter") + ); +} + +#[test] +fn test_schema_type_duplicate_bare_flag_rejected() { + let tokens = quote!(UserDTO from User, partial, partial); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err(), "duplicate bare `partial` must be rejected"); +} + +// Tests for schema derive and name attribute generation + +#[test] +fn test_generate_schema_type_code_with_ignore_schema() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserInternal from User, ignore); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should NOT contain vespera::Schema derive + assert!(!output.contains("vespera :: Schema")); +} + +#[test] +fn test_generate_schema_type_code_with_custom_name() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserResponse from User, name = "CustomUserSchema"); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should contain schema(name = "...") attribute + assert!(output.contains("schema")); + assert!(output.contains("CustomUserSchema")); + // Metadata should be returned + assert!(metadata.is_some()); + let meta = metadata.unwrap(); + assert_eq!(meta.name, "CustomUserSchema"); +} + +#[test] +fn test_generate_schema_type_code_with_clone_false() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub name: String }", + )]); + + let tokens = quote!(UserNonClone from User, clone = false); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should NOT contain Clone derive + assert!(!output.contains("Clone ,")); +} + +// Test for SeaORM model detection + +#[test] +fn test_generate_schema_type_code_seaorm_model_detection() { + // Source struct has sea_orm attribute - should be detected as SeaORM model + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "users")] + pub struct Model { pub id: i32, pub name: String }"#, + )]); + + let tokens = quote!(UserSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("UserSchema")); +} + +// Test tuple struct handling + +#[test] +fn test_generate_schema_type_code_tuple_struct() { + // Tuple structs have no named fields + let storage = to_storage(vec![create_test_struct_metadata( + "Point", + "pub struct Point(pub i32, pub i32);", + )]); + + let tokens = quote!(PointDTO from Point); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("PointDTO")); +} + +// Test raw identifier fields + +#[test] +fn test_generate_schema_type_code_raw_identifier_field() { + // Field name is a Rust keyword with r# prefix + let storage = to_storage(vec![create_test_struct_metadata( + "Config", + "pub struct Config { pub id: i32, pub r#type: String }", + )]); + + let tokens = quote!(ConfigDTO from Config); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("ConfigDTO")); +} + +// Test Option field not double-wrapped with partial + +#[test] +fn test_generate_schema_type_code_partial_no_double_option() { + // bio is already Option, partial should NOT wrap it again + let storage = to_storage(vec![create_test_struct_metadata( + "User", + "pub struct User { pub id: i32, pub bio: Option }", + )]); + + let tokens = quote!(UpdateUser from User, partial); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // bio should remain Option, not Option> + assert!(!output.contains("Option < Option")); +} + +// Test serde(skip) fields are excluded + +#[test] +fn test_generate_schema_code_excludes_serde_skip_fields() { + let storage = to_storage(vec![create_test_struct_metadata( + "User", + r"pub struct User { + pub id: i32, + #[serde(skip)] + pub internal_state: String, + pub name: String + }", + )]); + + let tokens = quote!(User); + let input: SchemaInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_code(&input, &storage); + + assert!(result.is_ok()); + let output = result.unwrap().to_string(); + // internal_state should be excluded from schema properties + assert!(!output.contains("internal_state")); + assert!(output.contains("name")); +} + +// Tests for qualified path storage fallback: a qualified source path like +// `crate::models::user::Model` resolves through schema_storage rather than +// via file lookup. + +#[test] +fn test_generate_schema_type_code_qualified_path_storage_lookup() { + // Use a qualified path like crate::models::user::Model + // The storage contains Model, so it should fallback to storage lookup + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + "pub struct Model { pub id: i32, pub name: String }", + )]); + + // Note: This qualified path won't find files (no real filesystem), + // so it falls back to storage lookup by the simple name "Model" + let tokens = quote!(UserSchema from crate::models::user::Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + // This should succeed by finding Model in storage + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + assert!(output.contains("UserSchema")); +} + +// Test for qualified path not found error + +#[test] +fn test_generate_schema_type_code_qualified_path_not_found() { + // Empty storage - qualified path should fail + let storage: HashMap = HashMap::new(); + + let tokens = quote!(UserSchema from crate::models::user::NonExistent); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + // Should fail with "not found" error + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("not found")); +} + +// Tests for HasMany excluded by default + +#[test] +fn test_generate_schema_type_code_has_many_excluded_by_default() { + // SeaORM model with HasMany relation - should be excluded by default + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "users")] + pub struct Model { + pub id: i32, + pub name: String, + pub memos: HasMany + }"#, + )]); + + let tokens = quote!(UserSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // HasMany field should NOT appear in output (excluded by default) + assert!(!output.contains("memos")); + // But regular fields should appear + assert!(output.contains("name")); +} + +// Test for relation conversion failure skip + +#[test] +fn test_generate_schema_type_code_relation_conversion_failure() { + // Model with relation type but missing generic args - conversion should fail + // The field should be skipped + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "users")] + pub struct Model { + pub id: i32, + pub name: String, + pub broken: HasMany + }"#, + )]); + + let tokens = quote!(UserSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + // Should succeed but skip the broken field + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Broken field should be skipped + assert!(!output.contains("broken")); + // Regular fields should appear + assert!(output.contains("name")); +} + +// Coverage test for BelongsTo relation type conversion + +#[test] +fn test_generate_schema_type_code_belongs_to_relation() { + // SeaORM model with BelongsTo relation - should be included + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "memos")] + pub struct Model { + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to = "super::user::Entity", from = "user_id")] + pub user: BelongsTo + }"#, + )]); + + let tokens = quote!(MemoSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // BelongsTo should be included (converted to Box or similar) + assert!(output.contains("user")); +} + +// Coverage test for HasOne relation type + +#[test] +fn test_generate_schema_type_code_has_one_relation() { + // SeaORM model with HasOne relation - should be included + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "users")] + pub struct Model { + pub id: i32, + pub name: String, + pub profile: HasOne + }"#, + )]); + + let tokens = quote!(UserSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // HasOne should be included + assert!(output.contains("profile")); +} + +// Test for relation fields push into relation_fields + +#[test] +fn test_generate_schema_type_code_seaorm_model_with_relation_generates_from_model() { + // When a SeaORM model has FK relations (HasOne/BelongsTo), + // it should generate from_model impl instead of From impl + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "memos")] + pub struct Model { + pub id: i32, + pub title: String, + pub user: BelongsTo + }"#, + )]); + + let tokens = quote!(MemoSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Should have relation field + assert!(output.contains("user")); + // Should NOT have regular From impl (because of relation) + // The From impl is only generated when there are no relation fields +} + +// Test for from_model generation with relations +// Note: This requires is_source_seaorm_model && has_relation_fields +// The from_model generation happens but needs file lookup for full path + +#[test] +fn test_generate_schema_type_code_from_model_generation() { + // SeaORM model with relation should trigger from_model generation + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "memos")] + pub struct Model { + pub id: i32, + pub user: BelongsTo + }"#, + )]); + + let tokens = quote!(MemoSchema from Model); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // Has relation field + assert!(output.contains("user")); + // Regular impl From should NOT be present (because has relations) + // Check that we don't have "impl From < Model > for MemoSchema" + // (Relations disable the automatic From impl) +} diff --git a/crates/vespera_macro/src/schema_macro/transformation/tests.rs b/crates/vespera_macro/src/schema_macro/transformation/tests.rs new file mode 100644 index 00000000..5d17340e --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/transformation/tests.rs @@ -0,0 +1,251 @@ +use super::*; + +#[test] +fn test_build_omit_set() { + let omit = Some(vec!["password".to_string(), "secret".to_string()]); + let set = build_omit_set(omit.as_ref()); + + assert!(set.contains("password")); + assert!(set.contains("secret")); + assert_eq!(set.len(), 2); +} + +#[test] +fn test_build_omit_set_none() { + let set = build_omit_set(None); + assert!(set.is_empty()); +} + +#[test] +fn test_build_pick_set() { + let pick = Some(vec!["id".to_string(), "name".to_string()]); + let set = build_pick_set(pick.as_ref()); + + assert!(set.contains("id")); + assert!(set.contains("name")); + assert_eq!(set.len(), 2); +} + +#[test] +fn test_build_partial_config_all() { + let partial = Some(PartialMode::All); + let (all, set) = build_partial_config(&partial); + + assert!(all); + assert!(set.is_empty()); +} + +#[test] +fn test_build_partial_config_fields() { + let partial = Some(PartialMode::Fields(vec![ + "name".to_string(), + "email".to_string(), + ])); + let (all, set) = build_partial_config(&partial); + + assert!(!all); + assert!(set.contains("name")); + assert!(set.contains("email")); +} + +#[test] +fn test_build_partial_config_none() { + let (all, set) = build_partial_config(&None); + + assert!(!all); + assert!(set.is_empty()); +} + +#[test] +fn test_build_rename_map() { + let rename = Some(vec![ + ("id".to_string(), "user_id".to_string()), + ("name".to_string(), "full_name".to_string()), + ]); + let map = build_rename_map(rename.as_ref()); + + assert_eq!(map.get("id"), Some(&"user_id".to_string())); + assert_eq!(map.get("name"), Some(&"full_name".to_string())); +} + +#[test] +fn test_build_rename_map_none() { + let map = build_rename_map(None); + assert!(map.is_empty()); +} + +#[test] +fn test_extract_serde_attrs_without_rename_all() { + let attrs: Vec = vec![ + syn::parse_quote!(#[serde(rename_all = "camelCase")]), + syn::parse_quote!(#[serde(default)]), + syn::parse_quote!(#[doc = "Some doc"]), + ]; + + let filtered = extract_serde_attrs_without_rename_all(&attrs); + + assert_eq!(filtered.len(), 1); + // Should keep #[serde(default)] but not #[serde(rename_all = ...)] +} + +#[test] +fn test_extract_doc_attrs() { + let attrs: Vec = vec![ + syn::parse_quote!(#[doc = "First doc"]), + syn::parse_quote!(#[serde(default)]), + syn::parse_quote!(#[doc = "Second doc"]), + ]; + + let docs = extract_doc_attrs(&attrs); + + assert_eq!(docs.len(), 2); +} + +#[test] +fn test_determine_rename_all_with_input() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(rename_all = "snake_case")])]; + + let result = determine_rename_all(Some(&"PascalCase".to_string()), &attrs); + + assert_eq!(result, "PascalCase"); +} + +#[test] +fn test_determine_rename_all_from_source() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(rename_all = "snake_case")])]; + + let result = determine_rename_all(None, &attrs); + + assert_eq!(result, "snake_case"); +} + +#[test] +fn test_determine_rename_all_default() { + let attrs: Vec = vec![]; + + let result = determine_rename_all(None, &attrs); + + assert_eq!(result, "camelCase"); +} + +#[test] +fn test_extract_field_serde_attrs() { + let attrs: Vec = vec![ + syn::parse_quote!(#[serde(rename = "userId")]), + syn::parse_quote!(#[doc = "The user ID"]), + syn::parse_quote!(#[serde(default)]), + ]; + + let serde_attrs = extract_field_serde_attrs(&attrs); + + assert_eq!(serde_attrs.len(), 2); +} + +#[test] +#[allow(clippy::similar_names)] +fn test_filter_out_serde_rename() { + let attr1: syn::Attribute = syn::parse_quote!(#[serde(rename = "userId")]); + let attr2: syn::Attribute = syn::parse_quote!(#[serde(default)]); + let attrs: Vec<&syn::Attribute> = vec![&attr1, &attr2]; + + let filtered = filter_out_serde_rename(&attrs); + + assert_eq!(filtered.len(), 1); +} + +#[test] +fn test_should_skip_field_omit() { + let omit_set: HashSet = ["password".to_string()].into_iter().collect(); + let pick_set: HashSet = HashSet::new(); + + assert!(should_skip_field("password", &omit_set, &pick_set)); + assert!(!should_skip_field("name", &omit_set, &pick_set)); +} + +#[test] +fn test_should_skip_field_pick() { + let omit_set: HashSet = HashSet::new(); + let pick_set: HashSet = ["id".to_string(), "name".to_string()].into_iter().collect(); + + assert!(should_skip_field("email", &omit_set, &pick_set)); + assert!(!should_skip_field("id", &omit_set, &pick_set)); +} + +#[test] +fn test_should_skip_field_no_filters() { + let omit_set: HashSet = HashSet::new(); + let pick_set: HashSet = HashSet::new(); + + assert!(!should_skip_field("any_field", &omit_set, &pick_set)); +} + +#[test] +fn test_should_wrap_in_option_partial_all() { + let partial_set: HashSet = HashSet::new(); + + assert!(should_wrap_in_option( + "name", + true, + &partial_set, + false, + false + )); + assert!(!should_wrap_in_option( + "name", + true, + &partial_set, + true, + false + )); // already option + assert!(!should_wrap_in_option( + "rel", + true, + &partial_set, + false, + true + )); // relation +} + +#[test] +fn test_extract_form_data_attrs() { + let attrs: Vec = vec![ + syn::parse_quote!(#[form_data(limit = "10MiB")]), + syn::parse_quote!(#[serde(default)]), + syn::parse_quote!(#[doc = "Some doc"]), + syn::parse_quote!(#[form_data(field_name = "my_file")]), + ]; + + let form_data = extract_form_data_attrs(&attrs); + assert_eq!(form_data.len(), 2); +} + +#[test] +fn test_extract_form_data_attrs_empty() { + let attrs: Vec = vec![ + syn::parse_quote!(#[serde(default)]), + syn::parse_quote!(#[doc = "Some doc"]), + ]; + + let form_data = extract_form_data_attrs(&attrs); + assert!(form_data.is_empty()); +} + +#[test] +fn test_should_wrap_in_option_partial_fields() { + let partial_set: HashSet = ["name".to_string()].into_iter().collect(); + + assert!(should_wrap_in_option( + "name", + false, + &partial_set, + false, + false + )); + assert!(!should_wrap_in_option( + "email", + false, + &partial_set, + false, + false + )); +} diff --git a/crates/vespera_macro/src/schema_macro/type_utils.rs b/crates/vespera_macro/src/schema_macro/type_utils.rs index b7189ed5..bd009d9b 100644 --- a/crates/vespera_macro/src/schema_macro/type_utils.rs +++ b/crates/vespera_macro/src/schema_macro/type_utils.rs @@ -7,27 +7,85 @@ use quote::quote; use serde_json; use syn::{GenericArgument, PathArguments, Type}; -/// Primitive type names shared across the crate. -/// Used by both `is_primitive_type()` (parser) and `is_parseable_type()` (schema_macro). -/// Note: `"str"` is intentionally excluded — only `is_primitive_type()` considers `str`, -/// since it appears in parser contexts but not in schema_macro type parsing. +/// SeaORM relation wrapper kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeaOrmRelationKind { + /// `HasOne` relation. + HasOne, + /// `HasMany` relation. + HasMany, + /// `BelongsTo` relation. + BelongsTo, +} + +impl SeaOrmRelationKind { + /// Whether the relation is FK-backed on the current model. + #[inline] + pub const fn is_fk_backed(self) -> bool { + matches!(self, Self::HasOne | Self::BelongsTo) + } +} + +/// Return the final path segment for path-like types. +#[inline] +pub fn last_path_segment(ty: &Type) -> Option<&syn::PathSegment> { + let Type::Path(type_path) = ty else { + return None; + }; + type_path.path.segments.last() +} + +/// Return the first generic type argument on a path segment. +#[inline] +pub fn first_generic_type_arg(segment: &syn::PathSegment) -> Option<&Type> { + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + args.args.iter().find_map(|arg| match arg { + GenericArgument::Type(inner) => Some(inner), + _ => None, + }) +} + +/// Inspect a `syn` type and return the SeaORM relation kind if its final path +/// segment is one of the supported relation wrappers. +pub fn seaorm_relation_kind(ty: &Type) -> Option { + let segment = last_path_segment(ty)?; + if segment.ident == "HasOne" { + Some(SeaOrmRelationKind::HasOne) + } else if segment.ident == "HasMany" { + Some(SeaOrmRelationKind::HasMany) + } else if segment.ident == "BelongsTo" { + Some(SeaOrmRelationKind::BelongsTo) + } else { + None + } +} + +/// Extract the inner target type of a SeaORM relation wrapper. +pub fn seaorm_relation_inner_type(ty: &Type) -> Option<&Type> { + let segment = last_path_segment(ty)?; + seaorm_relation_kind(ty)?; + first_generic_type_arg(segment) +} + +/// Primitive type names shared across parser and schema-macro type parsing. pub const PRIMITIVE_TYPE_NAMES: &[&str] = &[ "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize", "f32", "f64", "bool", "String", "Decimal", ]; -/// Normalize a `TokenStream` or `Type` to a compact string by removing all whitespace. -/// -/// This replaces the common `.to_string().replace(' ', "")` pattern used throughout -/// the codebase to produce deterministic path strings for comparison and cache keys. -/// -/// Removes spaces, newlines, and carriage returns — `proc_macro2`'s `Display` impl -/// may insert newlines when token sequences exceed an internal line-length threshold, -/// which would break substring checks like `contains("HasOne<")`. +/// Normalize a `TokenStream` or `Type` to a compact string by removing whitespace. #[inline] pub fn normalize_token_str(displayable: &impl std::fmt::Display) -> String { let s = displayable.to_string(); - if s.contains(|c: char| c.is_ascii_whitespace()) { + // Allocation profile: the `to_string` is unavoidable (`Display` -> owned + // `String`); a second allocation happens only when whitespace is actually + // present and must be stripped. The fast-path gate scans raw bytes rather + // than chars — every ASCII whitespace byte is a standalone code unit in + // valid UTF-8, so the byte scan is equivalent to a char scan but skips the + // per-char UTF-8 decode on the common (whitespace-free) path. + if s.bytes().any(|b| b.is_ascii_whitespace()) { s.replace(|c: char| c.is_ascii_whitespace(), "") } else { s @@ -49,35 +107,36 @@ pub fn extract_type_name(ty: &Type) -> Result { } } -/// Check if a type is a qualified path (has multiple segments like `crate::models::User`) -pub fn is_qualified_path(ty: &Type) -> bool { - match ty { - Type::Path(type_path) => type_path.path.segments.len() > 1, - _ => false, +/// Extract the inner `T` from `Option`. +/// +/// Uses the last path segment so qualified forms such as +/// `std::option::Option` and `core::option::Option` are treated the same +/// as a bare `Option`. +pub fn option_inner(ty: &Type) -> Option<&Type> { + let Type::Path(type_path) = ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + if segment.ident != "Option" { + return None; } + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + args.args.iter().find_map(|arg| match arg { + GenericArgument::Type(inner) => Some(inner), + _ => None, + }) } -/// Check if a type is Option +/// Check if a type is `Option`. pub fn is_option_type(ty: &Type) -> bool { - match ty { - Type::Path(type_path) => type_path - .path - .segments - .first() - .is_some_and(|s| s.ident == "Option"), - _ => false, - } + option_inner(ty).is_some() } /// Check if a type is a `SeaORM` relation type (`HasOne`, `HasMany`, `BelongsTo`) pub fn is_seaorm_relation_type(ty: &Type) -> bool { - match ty { - Type::Path(type_path) => type_path.path.segments.last().is_some_and(|segment| { - let ident = segment.ident.to_string(); - matches!(ident.as_str(), "HasOne" | "HasMany" | "BelongsTo") - }), - _ => false, - } + seaorm_relation_kind(ty).is_some() } /// Check if a struct is a `SeaORM` Model (has #[`sea_orm::model`] or #[`sea_orm(table_name` = ...)] attribute) @@ -346,13 +405,13 @@ pub fn snake_to_pascal_case(s: &str) -> String { /// Check if a type is `HashMap` or `BTreeMap` pub fn is_map_type(ty: &Type) -> bool { - if let Type::Path(type_path) = ty { - let path = &type_path.path; - if !path.segments.is_empty() { - let segment = path.segments.last().unwrap(); - let ident_str = segment.ident.to_string(); - return ident_str == "HashMap" || ident_str == "BTreeMap"; - } + // `segments.last()` yields `None` for an empty path, so the let-chain + // both replaces the prior `is_empty()` guard + `unwrap()` and skips the + // per-call `ident.to_string()` allocation (`Ident: PartialEq`). + if let Type::Path(type_path) = ty + && let Some(segment) = type_path.path.segments.last() + { + return segment.ident == "HashMap" || segment.ident == "BTreeMap"; } false } @@ -398,534 +457,4 @@ pub fn get_type_default(ty: &Type) -> Option { } #[cfg(test)] -mod tests { - use rstest::rstest; - - use super::*; - fn empty_type_path() -> syn::Type { - syn::Type::Path(syn::TypePath { - qself: None, - path: syn::Path { - leading_colon: None, - segments: syn::punctuated::Punctuated::new(), - }, - }) - } - - #[rstest] - #[case("hello", "Hello")] - #[case("world", "World")] - #[case("", "")] - #[case("a", "A")] - #[case("ABC", "ABC")] - #[case("camelCase", "CamelCase")] - fn test_capitalize_first(#[case] input: &str, #[case] expected: &str) { - assert_eq!(capitalize_first(input), expected); - } - - #[rstest] - #[case("comments", "Comments")] - #[case("target_user_notifications", "TargetUserNotifications")] - #[case("memo_comments", "MemoComments")] - #[case("", "")] - #[case("a", "A")] - #[case("user_id", "UserId")] - #[case("ABC", "ABC")] - fn test_snake_to_pascal_case(#[case] input: &str, #[case] expected: &str) { - assert_eq!(snake_to_pascal_case(input), expected); - } - - #[rstest] - #[case("bool", true)] - #[case("i32", true)] - #[case("String", true)] - #[case("Vec", true)] - #[case("Option", true)] - #[case("HashMap", true)] - #[case("DateTime", true)] - #[case("Uuid", true)] - #[case("Decimal", true)] - #[case("DateTimeWithTimeZone", true)] - #[case("CustomType", false)] - #[case("MyStruct", false)] - fn test_is_primitive_or_known_type(#[case] name: &str, #[case] expected: bool) { - assert_eq!(is_primitive_or_known_type(name), expected); - } - - #[test] - fn test_extract_type_name_simple() { - let ty: syn::Type = syn::parse_str("User").unwrap(); - let name = extract_type_name(&ty).unwrap(); - assert_eq!(name, "User"); - } - - #[test] - fn test_extract_type_name_with_path() { - let ty: syn::Type = syn::parse_str("crate::models::User").unwrap(); - let name = extract_type_name(&ty).unwrap(); - assert_eq!(name, "User"); - } - - #[test] - fn test_extract_type_name_non_path_error() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let result = extract_type_name(&ty); - assert!(result.is_err()); - } - - #[test] - fn test_is_qualified_path_simple() { - let ty: syn::Type = syn::parse_str("User").unwrap(); - assert!(!is_qualified_path(&ty)); - } - - #[test] - fn test_is_qualified_path_crate_path() { - let ty: syn::Type = syn::parse_str("crate::models::User").unwrap(); - assert!(is_qualified_path(&ty)); - } - - #[test] - fn test_is_qualified_path_non_path_type() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - assert!(!is_qualified_path(&ty)); - } - - #[test] - fn test_is_option_type_true() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(is_option_type(&ty)); - } - - #[test] - fn test_is_option_type_false() { - let ty: syn::Type = syn::parse_str("String").unwrap(); - assert!(!is_option_type(&ty)); - } - - #[test] - fn test_is_option_type_vec_false() { - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - assert!(!is_option_type(&ty)); - } - - #[test] - fn test_is_option_type_non_path() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - assert!(!is_option_type(&ty)); - } - - #[test] - fn test_is_option_type_empty_path() { - let ty = empty_type_path(); - assert!(!is_option_type(&ty)); - } - - #[test] - fn test_is_seaorm_relation_type_has_one() { - let ty: syn::Type = syn::parse_str("HasOne").unwrap(); - assert!(is_seaorm_relation_type(&ty)); - } - - #[test] - fn test_is_seaorm_relation_type_has_many() { - let ty: syn::Type = syn::parse_str("HasMany").unwrap(); - assert!(is_seaorm_relation_type(&ty)); - } - - #[test] - fn test_is_seaorm_relation_type_belongs_to() { - let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); - assert!(is_seaorm_relation_type(&ty)); - } - - #[test] - fn test_is_seaorm_relation_type_regular_type() { - let ty: syn::Type = syn::parse_str("String").unwrap(); - assert!(!is_seaorm_relation_type(&ty)); - } - - #[test] - fn test_is_seaorm_relation_type_non_path() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - assert!(!is_seaorm_relation_type(&ty)); - } - - #[test] - fn test_is_seaorm_relation_type_empty_path() { - let ty = empty_type_path(); - assert!(!is_seaorm_relation_type(&ty)); - } - - #[test] - fn test_is_seaorm_model_with_sea_orm_attr() { - let struct_item: syn::ItemStruct = syn::parse_str( - r#" - #[sea_orm(table_name = "users")] - struct Model { - id: i32, - } - "#, - ) - .unwrap(); - assert!(is_seaorm_model(&struct_item)); - } - - #[test] - fn test_is_seaorm_model_with_qualified_attr() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - #[sea_orm::model] - struct Model { - id: i32, - } - ", - ) - .unwrap(); - assert!(is_seaorm_model(&struct_item)); - } - - #[test] - fn test_is_seaorm_model_regular_struct() { - let struct_item: syn::ItemStruct = syn::parse_str( - r" - #[derive(Debug)] - struct User { - id: i32, - } - ", - ) - .unwrap(); - assert!(!is_seaorm_model(&struct_item)); - } - - #[test] - fn test_extract_module_path_simple() { - let ty: syn::Type = syn::parse_str("User").unwrap(); - let result = extract_module_path(&ty); - assert!(result.is_empty()); - } - - #[test] - fn test_extract_module_path_qualified() { - let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); - let result = extract_module_path(&ty); - assert_eq!(result, vec!["crate", "models", "user"]); - } - - #[test] - fn test_extract_module_path_non_path_type() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let result = extract_module_path(&ty); - assert!(result.is_empty()); - } - - #[test] - fn test_resolve_type_to_absolute_path_non_path_type() { - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let module_path = vec!["crate".to_string(), "models".to_string()]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - assert!(output.contains("& str")); - } - - #[test] - fn test_resolve_type_to_absolute_path_already_qualified() { - let ty: syn::Type = syn::parse_str("crate::models::User").unwrap(); - let module_path = vec!["crate".to_string(), "other".to_string()]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - assert!(output.contains("crate :: models :: User")); - } - - #[test] - fn test_resolve_type_to_absolute_path_primitive() { - let ty: syn::Type = syn::parse_str("String").unwrap(); - let module_path = vec!["crate".to_string(), "models".to_string()]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - assert_eq!(output.trim(), "String"); - } - - #[test] - fn test_resolve_type_to_absolute_path_known_type_with_generic_args() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - let module_path = vec!["crate".to_string(), "models".to_string()]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - assert_eq!(output.trim(), "Option < String >"); - } - - #[test] - fn test_resolve_type_to_absolute_path_decimal() { - let ty: syn::Type = syn::parse_str("Decimal").unwrap(); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "review".to_string(), - ]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - // Decimal is a known type — must NOT be resolved to crate::models::review::Decimal - assert_eq!(output.trim(), "Decimal"); - } - - #[test] - fn test_resolve_type_to_absolute_path_json_alias_uses_public_path() { - let ty: syn::Type = syn::parse_str("Json").unwrap(); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "json_case".to_string(), - ]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - assert_eq!(output.trim(), "vespera :: serde_json :: Value"); - } - - #[test] - fn test_resolve_type_to_absolute_path_known_container_normalizes_inner_json_alias() { - let ty: syn::Type = syn::parse_str("HashMap").unwrap(); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "json_case".to_string(), - ]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - assert!(output.contains("HashMap < String , vespera :: serde_json :: Value >")); - assert!(!output.contains("crate :: models :: json_case :: Json")); - } - - #[test] - fn test_resolve_type_to_absolute_path_custom_type() { - let ty: syn::Type = syn::parse_str("MemoStatus").unwrap(); - let module_path = vec![ - "crate".to_string(), - "models".to_string(), - "memo".to_string(), - ]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - assert!(output.contains("crate :: models :: memo :: MemoStatus")); - } - - #[test] - fn test_resolve_type_to_absolute_path_empty_module() { - let ty: syn::Type = syn::parse_str("CustomType").unwrap(); - let module_path: Vec = vec![]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - assert_eq!(output.trim(), "CustomType"); - } - - #[test] - fn test_resolve_type_to_absolute_path_with_generics() { - let ty: syn::Type = syn::parse_str("CustomType").unwrap(); - let module_path = vec!["crate".to_string(), "models".to_string()]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - assert!(output.contains("crate :: models :: CustomType < T >")); - } - - #[test] - fn test_resolve_type_to_absolute_path_empty_segments() { - let ty = empty_type_path(); - let module_path = vec!["crate".to_string()]; - let tokens = resolve_type_to_absolute_path(&ty, &module_path); - let output = tokens.to_string(); - assert!(output.trim().is_empty()); - } - - #[rstest] - #[case("HashMap", true)] - #[case("BTreeMap", true)] - #[case("String", false)] - #[case("Vec", false)] - fn test_is_map_type(#[case] type_str: &str, #[case] expected: bool) { - let ty: syn::Type = syn::parse_str(type_str).unwrap(); - assert_eq!(is_map_type(&ty), expected); - } - - #[rstest] - #[case("String", Some(serde_json::Value::String(String::new())))] - #[case("i32", Some(serde_json::Value::Number(serde_json::Number::from(0))))] - #[case( - "Decimal", - Some(serde_json::Value::Number(serde_json::Number::from(0))) - )] - #[case("bool", Some(serde_json::Value::Bool(false)))] - #[case("f64", Some(serde_json::Value::Number(serde_json::Number::from_f64(0.0).unwrap())))] - #[case("CustomType", None)] - fn test_get_type_default(#[case] type_str: &str, #[case] expected: Option) { - let ty: syn::Type = syn::parse_str(type_str).unwrap(); - let result = get_type_default(&ty); - match expected { - Some(exp) => { - assert!(result.is_some()); - let res = result.unwrap(); - assert_eq!(res, exp); - } - None => assert!(result.is_none()), - } - } - - #[test] - fn test_is_primitive_like_true() { - let ty: syn::Type = syn::parse_str("String").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_vec_of_primitives() { - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_option_of_primitives() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_custom_type() { - let ty: syn::Type = syn::parse_str("User").unwrap(); - assert!(!is_primitive_like(&ty)); - } - - // Edge case tests for type_utils functions - - #[test] - fn test_extract_type_name_empty_path_error() { - let ty = empty_type_path(); - let result = extract_type_name(&ty); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("type path has no segments") - ); - } - - #[test] - fn test_is_map_type_empty_path() { - let ty = empty_type_path(); - assert!(!is_map_type(&ty)); - } - - #[test] - fn test_is_primitive_like_vec_string() { - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_vec_i32() { - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_option_string() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_option_bool() { - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_vec_of_custom_type() { - // Vec is a known type, so Vec is considered primitive-like - let ty: syn::Type = syn::parse_str("Vec").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_option_of_custom_type() { - // Option is a known type, so Option is considered primitive-like - let ty: syn::Type = syn::parse_str("Option").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_nested_vec_option() { - let ty: syn::Type = syn::parse_str("Vec>").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_nested_option_vec() { - let ty: syn::Type = syn::parse_str("Option>").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_is_primitive_like_vec_of_datetime() { - let ty: syn::Type = syn::parse_str("Vec>").unwrap(); - assert!(is_primitive_like(&ty)); - } - - #[test] - fn test_normalize_known_type_in_generic_non_path_and_empty_path() { - let ref_ty: syn::Type = syn::parse_str("&str").unwrap(); - assert_eq!( - normalize_known_type_in_generic(&ref_ty, &[]).to_string(), - quote!(&str).to_string() - ); - - let empty_ty = empty_type_path(); - assert_eq!( - normalize_known_type_in_generic(&empty_ty, &[]).to_string(), - quote!(#empty_ty).to_string() - ); - } - - #[test] - fn test_normalize_known_type_in_generic_preserves_qualified_paths_and_leading_colon() { - let ty: syn::Type = syn::parse_str("::crate::models::CustomType").unwrap(); - let output = normalize_known_type_in_generic(&ty, &[]).to_string(); - assert!(output.contains(":: crate :: models :: CustomType")); - } - - #[test] - fn test_normalize_known_type_in_generic_preserves_qualified_paths_without_leading_colon() { - let ty: syn::Type = syn::parse_str("crate::models::CustomType").unwrap(); - let output = normalize_known_type_in_generic(&ty, &[]).to_string(); - assert!(output.contains("crate :: models :: CustomType")); - } - - #[test] - fn test_render_path_arguments_handles_lifetime_and_parenthesized_args() { - let lifetime_ty: syn::Type = syn::parse_str("Borrowed<'a>").unwrap(); - let lifetime_args = match lifetime_ty { - syn::Type::Path(type_path) => type_path.path.segments.last().unwrap().arguments.clone(), - _ => panic!("expected path type"), - }; - assert_eq!( - render_path_arguments(&lifetime_args, &[]).to_string(), - "< 'a >" - ); - - let fn_args = PathArguments::Parenthesized(syn::parse_quote!((i32) -> String)); - let fn_output = render_path_arguments(&fn_args, &[]).to_string(); - assert!(fn_output.contains("(i32)")); - assert!(fn_output.contains("-> String")); - } - - #[test] - fn test_resolve_type_to_absolute_path_leading_colon_and_empty_path() { - let ty: syn::Type = syn::parse_str("::crate::models::User").unwrap(); - let tokens = resolve_type_to_absolute_path(&ty, &["ignored".to_string()]); - assert!(tokens.to_string().contains(":: crate :: models :: User")); - - let empty_ty = empty_type_path(); - let tokens = resolve_type_to_absolute_path(&empty_ty, &["crate".to_string()]); - assert!(tokens.to_string().trim().is_empty()); - } -} +mod tests; diff --git a/crates/vespera_macro/src/schema_macro/type_utils/tests.rs b/crates/vespera_macro/src/schema_macro/type_utils/tests.rs new file mode 100644 index 00000000..e5fddc79 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/type_utils/tests.rs @@ -0,0 +1,511 @@ +use rstest::rstest; + +use super::*; +fn empty_type_path() -> syn::Type { + syn::Type::Path(syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: syn::punctuated::Punctuated::new(), + }, + }) +} + +#[rstest] +#[case("hello", "Hello")] +#[case("world", "World")] +#[case("", "")] +#[case("a", "A")] +#[case("ABC", "ABC")] +#[case("camelCase", "CamelCase")] +fn test_capitalize_first(#[case] input: &str, #[case] expected: &str) { + assert_eq!(capitalize_first(input), expected); +} + +#[rstest] +#[case("comments", "Comments")] +#[case("target_user_notifications", "TargetUserNotifications")] +#[case("memo_comments", "MemoComments")] +#[case("", "")] +#[case("a", "A")] +#[case("user_id", "UserId")] +#[case("ABC", "ABC")] +fn test_snake_to_pascal_case(#[case] input: &str, #[case] expected: &str) { + assert_eq!(snake_to_pascal_case(input), expected); +} + +#[rstest] +#[case("bool", true)] +#[case("i32", true)] +#[case("String", true)] +#[case("Vec", true)] +#[case("Option", true)] +#[case("HashMap", true)] +#[case("DateTime", true)] +#[case("Uuid", true)] +#[case("Decimal", true)] +#[case("DateTimeWithTimeZone", true)] +#[case("CustomType", false)] +#[case("MyStruct", false)] +fn test_is_primitive_or_known_type(#[case] name: &str, #[case] expected: bool) { + assert_eq!(is_primitive_or_known_type(name), expected); +} + +#[test] +fn test_extract_type_name_simple() { + let ty: syn::Type = syn::parse_str("User").unwrap(); + let name = extract_type_name(&ty).unwrap(); + assert_eq!(name, "User"); +} + +#[test] +fn test_extract_type_name_with_path() { + let ty: syn::Type = syn::parse_str("crate::models::User").unwrap(); + let name = extract_type_name(&ty).unwrap(); + assert_eq!(name, "User"); +} + +#[test] +fn test_extract_type_name_non_path_error() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let result = extract_type_name(&ty); + assert!(result.is_err()); +} + +#[test] +fn test_is_option_type_true() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(is_option_type(&ty)); +} + +#[test] +fn test_is_option_type_false() { + let ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(!is_option_type(&ty)); +} + +#[test] +fn test_is_option_type_vec_false() { + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + assert!(!is_option_type(&ty)); +} + +#[test] +fn test_is_option_type_non_path() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + assert!(!is_option_type(&ty)); +} + +#[test] +fn test_is_option_type_empty_path() { + let ty = empty_type_path(); + assert!(!is_option_type(&ty)); +} + +#[test] +fn test_is_seaorm_relation_type_has_one() { + let ty: syn::Type = syn::parse_str("HasOne").unwrap(); + assert!(is_seaorm_relation_type(&ty)); +} + +#[test] +fn test_is_seaorm_relation_type_has_many() { + let ty: syn::Type = syn::parse_str("HasMany").unwrap(); + assert!(is_seaorm_relation_type(&ty)); +} + +#[test] +fn test_is_seaorm_relation_type_belongs_to() { + let ty: syn::Type = syn::parse_str("BelongsTo").unwrap(); + assert!(is_seaorm_relation_type(&ty)); +} + +#[test] +fn test_is_seaorm_relation_type_regular_type() { + let ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(!is_seaorm_relation_type(&ty)); +} + +#[test] +fn test_is_seaorm_relation_type_non_path() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + assert!(!is_seaorm_relation_type(&ty)); +} + +#[test] +fn test_is_seaorm_relation_type_empty_path() { + let ty = empty_type_path(); + assert!(!is_seaorm_relation_type(&ty)); +} + +#[test] +fn test_is_seaorm_model_with_sea_orm_attr() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + #[sea_orm(table_name = "users")] + struct Model { + id: i32, + } + "#, + ) + .unwrap(); + assert!(is_seaorm_model(&struct_item)); +} + +#[test] +fn test_is_seaorm_model_with_qualified_attr() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + #[sea_orm::model] + struct Model { + id: i32, + } + ", + ) + .unwrap(); + assert!(is_seaorm_model(&struct_item)); +} + +#[test] +fn test_is_seaorm_model_regular_struct() { + let struct_item: syn::ItemStruct = syn::parse_str( + r" + #[derive(Debug)] + struct User { + id: i32, + } + ", + ) + .unwrap(); + assert!(!is_seaorm_model(&struct_item)); +} + +#[test] +fn test_extract_module_path_simple() { + let ty: syn::Type = syn::parse_str("User").unwrap(); + let result = extract_module_path(&ty); + assert!(result.is_empty()); +} + +#[test] +fn test_extract_module_path_qualified() { + let ty: syn::Type = syn::parse_str("crate::models::user::Model").unwrap(); + let result = extract_module_path(&ty); + assert_eq!(result, vec!["crate", "models", "user"]); +} + +#[test] +fn test_extract_module_path_non_path_type() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let result = extract_module_path(&ty); + assert!(result.is_empty()); +} + +#[test] +fn test_resolve_type_to_absolute_path_non_path_type() { + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let module_path = vec!["crate".to_string(), "models".to_string()]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert!(output.contains("& str")); +} + +#[test] +fn test_resolve_type_to_absolute_path_already_qualified() { + let ty: syn::Type = syn::parse_str("crate::models::User").unwrap(); + let module_path = vec!["crate".to_string(), "other".to_string()]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert!(output.contains("crate :: models :: User")); +} + +#[test] +fn test_resolve_type_to_absolute_path_primitive() { + let ty: syn::Type = syn::parse_str("String").unwrap(); + let module_path = vec!["crate".to_string(), "models".to_string()]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert_eq!(output.trim(), "String"); +} + +#[test] +fn test_resolve_type_to_absolute_path_known_type_with_generic_args() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + let module_path = vec!["crate".to_string(), "models".to_string()]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert_eq!(output.trim(), "Option < String >"); +} + +#[test] +fn test_resolve_type_to_absolute_path_decimal() { + let ty: syn::Type = syn::parse_str("Decimal").unwrap(); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "review".to_string(), + ]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + // Decimal is a known type — must NOT be resolved to crate::models::review::Decimal + assert_eq!(output.trim(), "Decimal"); +} + +#[test] +fn test_resolve_type_to_absolute_path_json_alias_uses_public_path() { + let ty: syn::Type = syn::parse_str("Json").unwrap(); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "json_case".to_string(), + ]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert_eq!(output.trim(), "vespera :: serde_json :: Value"); +} + +#[test] +fn test_resolve_type_to_absolute_path_known_container_normalizes_inner_json_alias() { + let ty: syn::Type = syn::parse_str("HashMap").unwrap(); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "json_case".to_string(), + ]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert!(output.contains("HashMap < String , vespera :: serde_json :: Value >")); + assert!(!output.contains("crate :: models :: json_case :: Json")); +} + +#[test] +fn test_resolve_type_to_absolute_path_custom_type() { + let ty: syn::Type = syn::parse_str("MemoStatus").unwrap(); + let module_path = vec![ + "crate".to_string(), + "models".to_string(), + "memo".to_string(), + ]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert!(output.contains("crate :: models :: memo :: MemoStatus")); +} + +#[test] +fn test_resolve_type_to_absolute_path_empty_module() { + let ty: syn::Type = syn::parse_str("CustomType").unwrap(); + let module_path: Vec = vec![]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert_eq!(output.trim(), "CustomType"); +} + +#[test] +fn test_resolve_type_to_absolute_path_with_generics() { + let ty: syn::Type = syn::parse_str("CustomType").unwrap(); + let module_path = vec!["crate".to_string(), "models".to_string()]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert!(output.contains("crate :: models :: CustomType < T >")); +} + +#[test] +fn test_resolve_type_to_absolute_path_empty_segments() { + let ty = empty_type_path(); + let module_path = vec!["crate".to_string()]; + let tokens = resolve_type_to_absolute_path(&ty, &module_path); + let output = tokens.to_string(); + assert!(output.trim().is_empty()); +} + +#[rstest] +#[case("HashMap", true)] +#[case("BTreeMap", true)] +#[case("String", false)] +#[case("Vec", false)] +fn test_is_map_type(#[case] type_str: &str, #[case] expected: bool) { + let ty: syn::Type = syn::parse_str(type_str).unwrap(); + assert_eq!(is_map_type(&ty), expected); +} + +#[rstest] +#[case("String", Some(serde_json::Value::String(String::new())))] +#[case("i32", Some(serde_json::Value::Number(serde_json::Number::from(0))))] +#[case( + "Decimal", + Some(serde_json::Value::Number(serde_json::Number::from(0))) +)] +#[case("bool", Some(serde_json::Value::Bool(false)))] +#[case("f64", Some(serde_json::Value::Number(serde_json::Number::from_f64(0.0).unwrap())))] +#[case("CustomType", None)] +fn test_get_type_default(#[case] type_str: &str, #[case] expected: Option) { + let ty: syn::Type = syn::parse_str(type_str).unwrap(); + let result = get_type_default(&ty); + match expected { + Some(exp) => { + assert!(result.is_some()); + let res = result.unwrap(); + assert_eq!(res, exp); + } + None => assert!(result.is_none()), + } +} + +#[test] +fn test_is_primitive_like_true() { + let ty: syn::Type = syn::parse_str("String").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_vec_of_primitives() { + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_option_of_primitives() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_custom_type() { + let ty: syn::Type = syn::parse_str("User").unwrap(); + assert!(!is_primitive_like(&ty)); +} + +// Edge case tests for type_utils functions + +#[test] +fn test_extract_type_name_empty_path_error() { + let ty = empty_type_path(); + let result = extract_type_name(&ty); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("type path has no segments") + ); +} + +#[test] +fn test_is_map_type_empty_path() { + let ty = empty_type_path(); + assert!(!is_map_type(&ty)); +} + +#[test] +fn test_is_primitive_like_vec_string() { + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_vec_i32() { + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_option_string() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_option_bool() { + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_vec_of_custom_type() { + // Vec is a known type, so Vec is considered primitive-like + let ty: syn::Type = syn::parse_str("Vec").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_option_of_custom_type() { + // Option is a known type, so Option is considered primitive-like + let ty: syn::Type = syn::parse_str("Option").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_nested_vec_option() { + let ty: syn::Type = syn::parse_str("Vec>").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_nested_option_vec() { + let ty: syn::Type = syn::parse_str("Option>").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_is_primitive_like_vec_of_datetime() { + let ty: syn::Type = syn::parse_str("Vec>").unwrap(); + assert!(is_primitive_like(&ty)); +} + +#[test] +fn test_normalize_known_type_in_generic_non_path_and_empty_path() { + let ref_ty: syn::Type = syn::parse_str("&str").unwrap(); + assert_eq!( + normalize_known_type_in_generic(&ref_ty, &[]).to_string(), + quote!(&str).to_string() + ); + + let empty_ty = empty_type_path(); + assert_eq!( + normalize_known_type_in_generic(&empty_ty, &[]).to_string(), + quote!(#empty_ty).to_string() + ); +} + +#[test] +fn test_normalize_known_type_in_generic_preserves_qualified_paths_and_leading_colon() { + let ty: syn::Type = syn::parse_str("::crate::models::CustomType").unwrap(); + let output = normalize_known_type_in_generic(&ty, &[]).to_string(); + assert!(output.contains(":: crate :: models :: CustomType")); +} + +#[test] +fn test_normalize_known_type_in_generic_preserves_qualified_paths_without_leading_colon() { + let ty: syn::Type = syn::parse_str("crate::models::CustomType").unwrap(); + let output = normalize_known_type_in_generic(&ty, &[]).to_string(); + assert!(output.contains("crate :: models :: CustomType")); +} + +#[test] +fn test_render_path_arguments_handles_lifetime_and_parenthesized_args() { + let lifetime_ty: syn::Type = syn::parse_str("Borrowed<'a>").unwrap(); + let lifetime_args = match lifetime_ty { + syn::Type::Path(type_path) => type_path.path.segments.last().unwrap().arguments.clone(), + _ => panic!("expected path type"), + }; + assert_eq!( + render_path_arguments(&lifetime_args, &[]).to_string(), + "< 'a >" + ); + + let fn_args = PathArguments::Parenthesized(syn::parse_quote!((i32) -> String)); + let fn_output = render_path_arguments(&fn_args, &[]).to_string(); + assert!(fn_output.contains("(i32)")); + assert!(fn_output.contains("-> String")); +} + +#[test] +fn test_resolve_type_to_absolute_path_leading_colon_and_empty_path() { + let ty: syn::Type = syn::parse_str("::crate::models::User").unwrap(); + let tokens = resolve_type_to_absolute_path(&ty, &["ignored".to_string()]); + assert!(tokens.to_string().contains(":: crate :: models :: User")); + + let empty_ty = empty_type_path(); + let tokens = resolve_type_to_absolute_path(&empty_ty, &["crate".to_string()]); + assert!(tokens.to_string().trim().is_empty()); +} diff --git a/crates/vespera_macro/src/schema_macro/validation.rs b/crates/vespera_macro/src/schema_macro/validation.rs index 550017b2..a5f206d7 100644 --- a/crates/vespera_macro/src/schema_macro/validation.rs +++ b/crates/vespera_macro/src/schema_macro/validation.rs @@ -27,7 +27,42 @@ //! schema_type!(BadSchema from Model, pick = ["nonexistent"]); //! ``` -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet}; + +fn sorted_source_fields(source_field_names: &HashSet) -> Vec<&str> { + source_field_names + .iter() + .map(String::as_str) + .collect::>() + .into_iter() + .collect() +} + +fn validate_fields_exist<'a>( + kind: &str, + fields: impl IntoIterator, + source_field_names: &HashSet, + source_type: &syn::Type, + source_type_name: &str, +) -> Result<(), syn::Error> { + for field in fields { + if !source_field_names.contains(field) { + let prefix = if kind == "partial" { + "partial field" + } else { + "field" + }; + return Err(syn::Error::new_spanned( + source_type, + format!( + "{prefix} `{field}` does not exist in type `{source_type_name}`. Available fields: {:?}", + sorted_source_fields(source_field_names) + ), + )); + } + } + Ok(()) +} /// Validates that all fields in `pick` exist in the source struct. /// @@ -38,22 +73,13 @@ pub fn validate_pick_fields( source_type: &syn::Type, source_type_name: &str, ) -> Result<(), syn::Error> { - if let Some(fields) = pick_fields { - for field in fields { - if !source_field_names.contains(field) { - return Err(syn::Error::new_spanned( - source_type, - format!( - "field `{}` does not exist in type `{}`. Available fields: {:?}", - field, - source_type_name, - source_field_names.iter().collect::>() - ), - )); - } - } - } - Ok(()) + validate_fields_exist( + "pick", + pick_fields.into_iter().flatten().map(String::as_str), + source_field_names, + source_type, + source_type_name, + ) } /// Validates that all fields in `omit` exist in the source struct. @@ -65,46 +91,84 @@ pub fn validate_omit_fields( source_type: &syn::Type, source_type_name: &str, ) -> Result<(), syn::Error> { - if let Some(fields) = omit_fields { - for field in fields { - if !source_field_names.contains(field) { - return Err(syn::Error::new_spanned( - source_type, - format!( - "field `{}` does not exist in type `{}`. Available fields: {:?}", - field, - source_type_name, - source_field_names.iter().collect::>() - ), - )); - } - } - } - Ok(()) + validate_fields_exist( + "omit", + omit_fields.into_iter().flatten().map(String::as_str), + source_field_names, + source_type, + source_type_name, + ) } -/// Validates that all source fields in `rename` exist in the source struct. +/// Returns `true` when `name` is a legal Rust identifier — i.e. the +/// downstream `syn::Ident::new(name, ..)` that turns a `rename`/`add` +/// target into a struct field identifier cannot panic on it. /// -/// Returns an error if any source field in a rename pair does not exist. +/// `syn::parse_str::` rejects non-identifiers (`"user-id"`, +/// `"a b"`, `""`, a leading digit) AND reserved keywords (`"type"`, +/// `"match"`) — both of which would otherwise either panic +/// `Ident::new` or emit a struct field that fails to compile. Raw +/// identifiers (`"r#type"`) are accepted. +fn is_valid_field_ident(name: &str) -> bool { + syn::parse_str::(name).is_ok() +} + +/// Validates a `rename` pair list: every **source** field must exist in +/// the source struct, and every **target** name must be a legal Rust +/// identifier. +/// +/// The target check is what stops a `schema_type!(.., rename = [("id", +/// "user-id")])` (or a keyword target like `"type"`) from panicking the +/// proc-macro at `syn::Ident::new` — it now surfaces as a spanned compile +/// error instead of an opaque expansion abort. pub fn validate_rename_fields( rename_pairs: Option<&Vec<(String, String)>>, source_field_names: &HashSet, source_type: &syn::Type, source_type_name: &str, ) -> Result<(), syn::Error> { - if let Some(pairs) = rename_pairs { - for (from_field, _) in pairs { - if !source_field_names.contains(from_field) { - return Err(syn::Error::new_spanned( - source_type, - format!( - "field `{}` does not exist in type `{}`. Available fields: {:?}", - from_field, - source_type_name, - source_field_names.iter().collect::>() - ), - )); - } + validate_fields_exist( + "rename", + rename_pairs + .into_iter() + .flatten() + .map(|(from_field, _)| from_field.as_str()), + source_field_names, + source_type, + source_type_name, + )?; + for (from_field, to_field) in rename_pairs.into_iter().flatten() { + if !is_valid_field_ident(to_field) { + return Err(syn::Error::new_spanned( + source_type, + format!( + "rename target `{to_field}` (for source field `{from_field}`) is not a valid \ + Rust identifier; use letters/digits/`_` (not starting with a digit) and avoid \ + reserved keywords" + ), + )); + } + } + Ok(()) +} + +/// Validates that every `add = [(name: Type)]` field name is a legal Rust +/// identifier, so the `syn::Ident::new(name, ..)` that materializes the +/// added field cannot panic on a non-identifier / keyword name (same +/// class of bug as an invalid `rename` target). +pub fn validate_add_field_idents( + add: Option<&Vec<(String, syn::Type)>>, + source_type: &syn::Type, +) -> Result<(), syn::Error> { + for (name, _) in add.into_iter().flatten() { + if !is_valid_field_ident(name) { + return Err(syn::Error::new_spanned( + source_type, + format!( + "`add` field name `{name}` is not a valid Rust identifier; use \ + letters/digits/`_` (not starting with a digit) and avoid reserved keywords" + ), + )); } } Ok(()) @@ -119,22 +183,13 @@ pub fn validate_partial_fields( source_type: &syn::Type, source_type_name: &str, ) -> Result<(), syn::Error> { - if let Some(fields) = partial_fields { - for field in fields { - if !source_field_names.contains(field) { - return Err(syn::Error::new_spanned( - source_type, - format!( - "partial field `{}` does not exist in type `{}`. Available fields: {:?}", - field, - source_type_name, - source_field_names.iter().collect::>() - ), - )); - } - } - } - Ok(()) + validate_fields_exist( + "partial", + partial_fields.into_iter().flatten().map(String::as_str), + source_field_names, + source_type, + source_type_name, + ) } /// Extracts all field names from a struct's named fields. @@ -241,6 +296,64 @@ mod tests { assert!(err.contains("does not exist")); } + #[test] + fn test_validate_rename_fields_invalid_target_ident() { + // Renaming to a non-identifier ("user-id") must surface as a spanned + // error, NOT panic the proc-macro at the downstream `syn::Ident::new`. + let source_fields = create_field_names(&["id", "name"]); + let rename = Some(vec![("id".to_string(), "user-id".to_string())]); + let ty: syn::Type = syn::parse2(quote!(User)).unwrap(); + + let result = validate_rename_fields(rename.as_ref(), &source_fields, &ty, "User"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("not a valid")); + assert!(err.contains("user-id")); + } + + #[test] + fn test_validate_rename_fields_keyword_target_rejected() { + // A reserved keyword target ("type") would emit an uncompilable field + // and `syn::Ident::new` rejects it — surface a clean error instead. + let source_fields = create_field_names(&["id"]); + let rename = Some(vec![("id".to_string(), "type".to_string())]); + let ty: syn::Type = syn::parse2(quote!(User)).unwrap(); + + let result = validate_rename_fields(rename.as_ref(), &source_fields, &ty, "User"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_rename_fields_raw_ident_target_ok() { + // A raw identifier target (`r#type`) is a legal field name and must pass. + let source_fields = create_field_names(&["id"]); + let rename = Some(vec![("id".to_string(), "r#type".to_string())]); + let ty: syn::Type = syn::parse2(quote!(User)).unwrap(); + + let result = validate_rename_fields(rename.as_ref(), &source_fields, &ty, "User"); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_add_field_idents_valid() { + let ty: syn::Type = syn::parse2(quote!(User)).unwrap(); + let add = Some(vec![ + ("extra".to_string(), syn::parse_quote!(String)), + ("count".to_string(), syn::parse_quote!(i32)), + ]); + assert!(validate_add_field_idents(add.as_ref(), &ty).is_ok()); + } + + #[test] + fn test_validate_add_field_idents_invalid() { + // An `add` name that is not a valid identifier must error, not panic. + let ty: syn::Type = syn::parse2(quote!(User)).unwrap(); + let add = Some(vec![("bad-name".to_string(), syn::parse_quote!(String))]); + let result = validate_add_field_idents(add.as_ref(), &ty); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("bad-name")); + } + #[test] fn test_validate_partial_fields_success() { let source_fields = create_field_names(&["id", "name", "email"]); diff --git a/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_route_headers_and_examples.snap b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_route_headers_and_examples.snap new file mode 100644 index 00000000..88f1aeff --- /dev/null +++ b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_route_headers_and_examples.snap @@ -0,0 +1,93 @@ +--- +source: crates/vespera_macro/src/openapi_generator.rs +expression: "serde_json::to_string_pretty(&doc).unwrap()" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "Headers API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "paths": { + "/users": { + "post": { + "operationId": "create_user", + "tags": [ + "users" + ], + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "Bearer token", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Trace-Id", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + }, + "example": { + "name": "Alice" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + }, + "example": { + "name": "Alice" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + } + }, + "tags": [ + { + "name": "users" + } + ] +} diff --git a/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_route_operation_metadata.snap b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_route_operation_metadata.snap new file mode 100644 index 00000000..7f7194f4 --- /dev/null +++ b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_route_operation_metadata.snap @@ -0,0 +1,55 @@ +--- +source: crates/vespera_macro/src/openapi_generator.rs +expression: "serde_json::to_string_pretty(&doc).unwrap()" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "Operation Metadata API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "paths": { + "/users/{id}": { + "get": { + "operationId": "getUser", + "tags": [ + "users" + ], + "summary": "Get a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + }, + "deprecated": true + } + } + }, + "tags": [ + { + "name": "users" + } + ] +} diff --git a/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_security_schemes_and_route_security.snap b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_security_schemes_and_route_security.snap new file mode 100644 index 00000000..0a315245 --- /dev/null +++ b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_security_schemes_and_route_security.snap @@ -0,0 +1,64 @@ +--- +source: crates/vespera_macro/src/openapi_generator.rs +expression: "serde_json::to_string_pretty(&doc).unwrap()" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "Security API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "paths": { + "/secure": { + "get": { + "operationId": "secure_route", + "tags": [ + "secure" + ], + "description": "A secured route", + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "description": "JWT bearer token", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "secure" + } + ] +} diff --git a/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_security_schemes_sorted_order.snap b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_security_schemes_sorted_order.snap new file mode 100644 index 00000000..566030ca --- /dev/null +++ b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_security_schemes_sorted_order.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespera_macro/src/openapi_generator.rs +expression: "serde_json::to_string_pretty(&doc).unwrap()" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "Security API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "paths": {}, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "description": "API key", + "name": "X-API-Key", + "in": "header" + }, + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "zBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + } +} diff --git a/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_tag_descriptions.snap b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_tag_descriptions.snap new file mode 100644 index 00000000..35d935a1 --- /dev/null +++ b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_tag_descriptions.snap @@ -0,0 +1,48 @@ +--- +source: crates/vespera_macro/src/openapi_generator.rs +expression: "serde_json::to_string_pretty(&doc).unwrap()" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "Tags API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "paths": { + "/users": { + "get": { + "operationId": "list_users", + "tags": [ + "users" + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "tags": [ + { + "name": "admin", + "description": "Admin operations" + }, + { + "name": "users", + "description": "User operations" + } + ] +} diff --git a/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_typed_route_responses.snap b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_typed_route_responses.snap new file mode 100644 index 00000000..c1b00134 --- /dev/null +++ b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_typed_route_responses.snap @@ -0,0 +1,78 @@ +--- +source: crates/vespera_macro/src/openapi_generator.rs +expression: "serde_json::to_string_pretty(&doc).unwrap()" +--- +{ + "openapi": "3.1.0", + "info": { + "title": "Typed Responses API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "paths": { + "/users/{id}": { + "get": { + "operationId": "get_user", + "tags": [ + "users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "NotFoundError": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + }, + "tags": [ + { + "name": "users" + } + ] +} diff --git a/crates/vespera_macro/src/vespera_impl.rs b/crates/vespera_macro/src/vespera_impl.rs index 6d03f032..b82f2f88 100644 --- a/crates/vespera_macro/src/vespera_impl.rs +++ b/crates/vespera_macro/src/vespera_impl.rs @@ -1,1868 +1,18 @@ //! Core implementation of vespera! and `export_app`! macros. //! -//! This module orchestrates the entire macro execution flow: -//! - Route discovery via filesystem scanning -//! - `OpenAPI` spec generation -//! - File I/O for writing `OpenAPI` JSON -//! - Router code generation -//! -//! # Overview -//! -//! This is the main orchestrator for the two primary macros: -//! - `vespera!()` - Generates a complete Axum router with `OpenAPI` spec -//! - `export_app!()` - Exports a router for merging into parent apps -//! -//! The execution flow is: -//! 1. Parse macro arguments via [`router_codegen`] -//! 2. Discover routes via [`collector::collect_metadata`] -//! 3. Generate `OpenAPI` spec via [`openapi_generator`] -//! 4. Write `OpenAPI` JSON files (if configured) -//! 5. Generate router code via [`router_codegen::generate_router_code`] -//! -//! # Key Functions -//! -//! - [`process_vespera_macro`] - Main vespera! macro implementation -//! - [`process_export_app`] - Main `export_app`! macro implementation -//! - [`generate_and_write_openapi`] - `OpenAPI` generation and file I/O - -use std::{ - collections::HashMap, - hash::{Hash, Hasher}, - path::Path, +//! Public orchestrators and helper functions are re-exported from child +//! modules to preserve `crate::vespera_impl::...` call paths. + +mod cache; +mod openapi_io; +mod orchestrator; +mod path_utils; +mod route_merge; + +#[allow(unused_imports)] +pub use openapi_io::{ + OpenApiWriteResult, ensure_openapi_files_from_cache, generate_and_write_openapi, }; - -use proc_macro2::Span; -use quote::quote; - -use serde::{Deserialize, Serialize}; - -use crate::{ - collector::{collect_file_fingerprints, collect_metadata}, - error::{MacroResult, err_call_site}, - metadata::{CollectedMetadata, StructMetadata}, - openapi_generator::generate_openapi_doc_with_metadata, - route_impl::StoredRouteInfo, - router_codegen::{ProcessedVesperaInput, generate_router_code}, -}; - -/// Docs info tuple type alias for cleaner signatures -pub type DocsInfo = (Option, Option, Option); - -/// Cache for avoiding redundant route scanning and OpenAPI generation. -/// Persisted to `target/vespera/routes.cache` across builds. -#[derive(Serialize, Deserialize)] -struct VesperaCache { - /// Macro crate version — invalidates cache when macro code changes - #[serde(default)] - macro_version: String, - /// File path → modification time (secs since UNIX_EPOCH) - file_fingerprints: HashMap, - /// Hash of SCHEMA_STORAGE contents - schema_hash: u64, - /// Hash of OpenAPI config (title, version, servers, docs_url, etc.) - config_hash: u64, - /// Cached route/struct metadata - metadata: CollectedMetadata, - /// Compact JSON for docs embedding (None if docs disabled) - spec_json: Option, - /// Pretty JSON for file output (None if no openapi file configured) - spec_pretty: Option, -} - -/// Compute a deterministic hash of SCHEMA_STORAGE contents. -fn compute_schema_hash(schema_storage: &HashMap) -> u64 { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - let mut keys: Vec<&String> = schema_storage.keys().collect(); - keys.sort(); - for key in keys { - key.hash(&mut hasher); - let meta = &schema_storage[key]; - meta.name.hash(&mut hasher); - meta.definition.hash(&mut hasher); - meta.include_in_openapi.hash(&mut hasher); - } - hasher.finish() -} - -/// Compute a deterministic hash of OpenAPI config fields. -fn compute_config_hash(processed: &ProcessedVesperaInput) -> u64 { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - processed.title.hash(&mut hasher); - processed.version.hash(&mut hasher); - processed.docs_url.hash(&mut hasher); - processed.redoc_url.hash(&mut hasher); - processed.openapi_file_names.hash(&mut hasher); - if let Some(ref servers) = processed.servers { - for s in servers { - s.url.hash(&mut hasher); - } - } - for merge_path in &processed.merge { - quote!(#merge_path).to_string().hash(&mut hasher); - } - hasher.finish() -} - -/// Get the path to the routes cache file. -fn get_cache_path() -> std::path::PathBuf { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); - let manifest_path = Path::new(&manifest_dir); - find_target_dir(manifest_path) - .join("vespera") - .join("routes.cache") -} - -/// Try to read and deserialize a cache file. Returns None on any failure. -fn read_cache(cache_path: &Path) -> Option { - let content = std::fs::read_to_string(cache_path).ok()?; - serde_json::from_str(&content).ok() -} - -/// Write cache to disk. Failures are silently ignored (cache is best-effort). -fn write_cache(cache_path: &Path, cache: &VesperaCache) { - if let Some(parent) = cache_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string(cache) { - let _ = std::fs::write(cache_path, json); - } -} - -/// Generate `OpenAPI` JSON and write to files, returning docs info -pub fn generate_and_write_openapi( - input: &ProcessedVesperaInput, - metadata: &CollectedMetadata, - file_asts: HashMap, - route_storage: &[StoredRouteInfo], -) -> MacroResult { - if input.openapi_file_names.is_empty() && input.docs_url.is_none() && input.redoc_url.is_none() - { - return Ok((None, None, None)); - } - - let mut openapi_doc = generate_openapi_doc_with_metadata( - input.title.clone(), - input.version.clone(), - input.servers.clone(), - metadata, - Some(file_asts), - route_storage, - ); - - // Merge specs from child apps at compile time - if !input.merge.is_empty() - && let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") - { - let manifest_path = Path::new(&manifest_dir); - let target_dir = find_target_dir(manifest_path); - let vespera_dir = target_dir.join("vespera"); - - for merge_path in &input.merge { - // Extract the struct name (last segment, e.g., "ThirdApp" from "third::ThirdApp") - if let Some(last_segment) = merge_path.segments.last() { - let struct_name = last_segment.ident.to_string(); - let spec_file = vespera_dir.join(format!("{struct_name}.openapi.json")); - - if let Ok(spec_content) = std::fs::read_to_string(&spec_file) - && let Ok(child_spec) = - serde_json::from_str::(&spec_content) - { - openapi_doc.merge(child_spec); - } - } - } - } - - // NOTE on F-01: an earlier audit suggested serialising the - // `OpenApi` document once into `serde_json::Value` and emitting - // pretty + compact from the cached `Value`. We deliberately do - // **not** do that here. Going through `Value` re-orders every - // object's keys alphabetically (because the default - // `serde_json::Map` is `BTreeMap`-backed), which silently changes - // the field order in every user-visible `openapi.json` file. The - // marginal build-time saving is not worth churning the output of a - // file users diff in CI. Keep two direct serialisations. - // - // Pretty-print for user-visible files. - if !input.openapi_file_names.is_empty() { - let json_pretty = serde_json::to_string_pretty(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?; - for openapi_file_name in &input.openapi_file_names { - let file_path = Path::new(openapi_file_name); - if let Some(parent) = file_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| err_call_site(format!("OpenAPI output: failed to create directory '{}'. Error: {}. Ensure the path is valid and writable.", parent.display(), e)))?; - } - let should_write = - std::fs::read_to_string(file_path).map_or(true, |existing| existing != json_pretty); - if should_write { - std::fs::write(file_path, &json_pretty).map_err(|e| err_call_site(format!("OpenAPI output: failed to write file '{openapi_file_name}'. Error: {e}. Ensure the file path is writable.")))?; - } - } - } - - // Compact JSON for embedding (smaller binary, faster downstream compilation). - let spec_json = if input.docs_url.is_some() || input.redoc_url.is_some() { - Some(serde_json::to_string(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?) - } else { - None - }; - - Ok((input.docs_url.clone(), input.redoc_url.clone(), spec_json)) -} - -/// Find the folder path for route scanning -pub fn find_folder_path(folder_name: &str) -> MacroResult { - let root = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| { - err_call_site( - "CARGO_MANIFEST_DIR is not set. vespera macros must be used within a cargo build.", - ) - })?; - let path = format!("{root}/src/{folder_name}"); - let path = Path::new(&path); - if path.exists() && path.is_dir() { - return Ok(path.to_path_buf()); - } - - Ok(Path::new(folder_name).to_path_buf()) -} - -/// Find the workspace root's target directory -pub fn find_target_dir(manifest_path: &Path) -> std::path::PathBuf { - // Look for workspace root by finding a Cargo.toml with [workspace] section - let mut current = Some(manifest_path); - let mut last_with_lock = None; - - while let Some(dir) = current { - // Check if this directory has Cargo.lock - if dir.join("Cargo.lock").exists() { - last_with_lock = Some(dir.to_path_buf()); - } - - // Check if this is a workspace root (has Cargo.toml with [workspace]). - // `read_to_string` already fails when the file does not exist, so the - // previous `.exists()` pre-flight is redundant — drop it to save one - // stat per iteration of the walk. - if let Ok(contents) = std::fs::read_to_string(dir.join("Cargo.toml")) - && contents.contains("[workspace]") - { - return dir.join("target"); - } - - current = dir.parent(); - } - - // If we found a Cargo.lock but no [workspace], use the topmost one - if let Some(lock_dir) = last_with_lock { - return lock_dir.join("target"); - } - - // Fallback: use manifest dir's target - manifest_path.join("target") -} - -/// Supplement collector's `RouteMetadata` with data from `ROUTE_STORAGE`. -/// -/// `#[route]` stores metadata at attribute expansion time. -/// `collector.rs` re-parses the same data from file ASTs. -/// This function merges ROUTE_STORAGE data into collector's output, -/// preferring ROUTE_STORAGE values when they provide richer info. -/// -/// Matching is by function name. If multiple routes share a function name, -/// the match is ambiguous and ROUTE_STORAGE data is skipped for safety. -fn merge_route_storage_data(metadata: &mut CollectedMetadata, route_storage: &[StoredRouteInfo]) { - if route_storage.is_empty() { - return; - } - - // Build `fn_name -> Option<&StoredRouteInfo>` index in a single pass: - // `Some(_)` when the name is unique, `None` when it is ambiguous - // (appears more than once). This turns the previous O(N*M) nested - // scan into O(N + M). - let mut stored_index: HashMap<&str, Option<&StoredRouteInfo>> = - HashMap::with_capacity(route_storage.len()); - for stored in route_storage { - stored_index - .entry(stored.fn_name.as_str()) - .and_modify(|slot| *slot = None) - .or_insert(Some(stored)); - } - - for route in &mut metadata.routes { - // Skip if no match or ambiguous (multiple routes share fn_name). - let Some(Some(stored)) = stored_index.get(route.function_name.as_str()) else { - continue; - }; - - // Supplement with ROUTE_STORAGE data — only override when an - // explicit value is present. - if let Some(ref tags) = stored.tags { - route.tags = Some(tags.clone()); - } - if let Some(ref desc) = stored.description { - route.description = Some(desc.clone()); - } - if let Some(ref status) = stored.error_status { - route.error_status = Some(status.clone()); - } - } -} - -/// Write cached OpenAPI spec to output files if they are stale or missing. -pub fn ensure_openapi_files_from_cache( - openapi_file_names: &[String], - spec_pretty: Option<&str>, -) -> syn::Result<()> { - let Some(pretty) = spec_pretty else { - return Ok(()); - }; - for openapi_file_name in openapi_file_names { - let file_path = Path::new(openapi_file_name); - let should_write = - std::fs::read_to_string(file_path).map_or(true, |existing| existing != *pretty); - if should_write { - if let Some(parent) = file_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| { - syn::Error::new( - Span::call_site(), - format!( - "OpenAPI output: failed to create directory '{}': {}", - parent.display(), - e - ), - ) - })?; - } - std::fs::write(file_path, pretty).map_err(|e| { - syn::Error::new( - Span::call_site(), - format!("OpenAPI output: failed to write file '{openapi_file_name}': {e}"), - ) - })?; - } - } - Ok(()) -} - -/// Write compact spec JSON to target dir for `include_str!` embedding. -fn write_spec_for_embedding( - spec_json: Option, -) -> syn::Result> { - let Some(json) = spec_json else { - return Ok(None); - }; - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); - let manifest_path = Path::new(&manifest_dir); - let target_dir = find_target_dir(manifest_path); - let vespera_dir = target_dir.join("vespera"); - std::fs::create_dir_all(&vespera_dir).map_err(|e| { - syn::Error::new( - Span::call_site(), - format!( - "vespera! macro: failed to create directory '{}': {}", - vespera_dir.display(), - e - ), - ) - })?; - let spec_file = vespera_dir.join("vespera_spec.json"); - let should_write = - std::fs::read_to_string(&spec_file).map_or(true, |existing| existing != json); - if should_write { - std::fs::write(&spec_file, &json).map_err(|e| { - syn::Error::new( - Span::call_site(), - format!( - "vespera! macro: failed to write spec file '{}': {}", - spec_file.display(), - e - ), - ) - })?; - } - let path_str = spec_file.display().to_string().replace('\\', "/"); - Ok(Some(quote::quote! { include_str!(#path_str) })) -} - -/// Process vespera macro - extracted for testability -#[allow(clippy::too_many_lines)] -pub fn process_vespera_macro( - processed: &ProcessedVesperaInput, - schema_storage: &HashMap, - route_storage: &[StoredRouteInfo], -) -> syn::Result { - let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { - Some(std::time::Instant::now()) - } else { - None - }; - - let folder_path = find_folder_path(&processed.folder_name)?; - if !folder_path.exists() { - return Err(syn::Error::new( - Span::call_site(), - format!( - "vespera! macro: route folder '{}' not found. Create src/{} or specify a different folder with `dir = \"your_folder\"`.", - processed.folder_name, processed.folder_name - ), - )); - } - - // --- Incremental cache check --- - let cache_path = get_cache_path(); - let fingerprints = collect_file_fingerprints(&folder_path) - .map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: {e}")))?; - let schema_hash = compute_schema_hash(schema_storage); - let config_hash = compute_config_hash(processed); - - let macro_version = env!("CARGO_PKG_VERSION").to_string(); - let cached = read_cache(&cache_path); - let cache_hit = cached.as_ref().is_some_and(|c| { - c.macro_version == macro_version - && c.file_fingerprints == fingerprints - && c.schema_hash == schema_hash - && c.config_hash == config_hash - }); - - let (metadata, spec_json) = if cache_hit { - let cache = cached.unwrap(); - let mut metadata = cache.metadata; - metadata.structs.extend(schema_storage.values().cloned()); - merge_route_storage_data(&mut metadata, route_storage); - metadata - .check_duplicate_schema_names() - .map_err(|msg| syn::Error::new(Span::call_site(), format!("vespera! macro: {msg}")))?; - - // Ensure openapi.json files exist and are up-to-date from cache - ensure_openapi_files_from_cache( - &processed.openapi_file_names, - cache.spec_pretty.as_deref(), - )?; - - (metadata, cache.spec_json) - } else { - let (mut metadata, file_asts) = collect_metadata(&folder_path, &processed.folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: failed to scan route folder '{}'. Error: {}. Check that all .rs files have valid Rust syntax.", processed.folder_name, e)))?; - - // Clone metadata before extending (cache stores file-only structs) - let cache_metadata = metadata.clone(); - metadata.structs.extend(schema_storage.values().cloned()); - merge_route_storage_data(&mut metadata, route_storage); - metadata - .check_duplicate_schema_names() - .map_err(|msg| syn::Error::new(Span::call_site(), format!("vespera! macro: {msg}")))?; - - let (_, _, spec_json) = - generate_and_write_openapi(processed, &metadata, file_asts, route_storage)?; - - // Read back spec_pretty from first openapi file for caching - let spec_pretty = processed - .openapi_file_names - .first() - .and_then(|f| std::fs::read_to_string(f).ok()); - - // Persist cache (best-effort, failures are silent) - write_cache( - &cache_path, - &VesperaCache { - macro_version: macro_version.clone(), - file_fingerprints: fingerprints, - schema_hash, - config_hash, - metadata: cache_metadata, - spec_json: spec_json.clone(), - spec_pretty, - }, - ); - - (metadata, spec_json) - }; - - // Write compact spec for include_str! embedding - let spec_tokens = write_spec_for_embedding(spec_json)?; - - // --- Cron job discovery from CRON_STORAGE --- - // #[cron("...")] attribute already registers metadata at expansion time. - // No folder scanning needed — just read the storage. - let cron_jobs: Vec = { - let storage = crate::CRON_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let src_dir = std::env::var("CARGO_MANIFEST_DIR") - .map(|d| { - let p = std::path::PathBuf::from(d).join("src"); - // Canonicalize for reliable prefix stripping - let canonical = p.canonicalize().unwrap_or(p); - canonical.display().to_string().replace('\\', "/") - }) - .unwrap_or_default(); - storage - .iter() - .map(|s| { - // Derive module path from file_path relative to src/ - let module_path = s - .file_path - .as_ref() - .map(|fp| { - let canonical = std::path::Path::new(fp) - .canonicalize() - .map_or_else(|_| fp.clone(), |p| p.display().to_string()); - let normalized = canonical.replace('\\', "/"); - let relative = normalized - .strip_prefix(&src_dir) - .map_or(&*normalized, |rest| rest.trim_start_matches('/')); - // Convert path to module path: strip .rs, replace / with ::, strip mod - // Replace hyphens with underscores (Rust module convention) - relative - .trim_end_matches(".rs") - .replace('/', "::") - .replace('-', "_") - .trim_end_matches("::mod") - .to_string() - }) - .unwrap_or_default(); - crate::metadata::CronMetadata { - expression: s.expression.clone(), - function_name: s.fn_name.clone(), - module_path, - file_path: s.file_path.clone().unwrap_or_default(), - } - }) - .collect() - }; - - let result = Ok(generate_router_code( - &metadata, - processed.docs_url.as_deref(), - processed.redoc_url.as_deref(), - spec_tokens, - &processed.merge, - &cron_jobs, - )); - - if let Some(start) = profile_start { - eprintln!( - "[vespera-profile] vespera! macro total: {:?}", - start.elapsed() - ); - crate::schema_macro::print_profile_summary(); - } - - result -} - -/// Process `export_app` macro - extracted for testability -pub fn process_export_app( - name: &syn::Ident, - folder_name: &str, - schema_storage: &HashMap, - manifest_dir: &str, - route_storage: &[StoredRouteInfo], -) -> syn::Result { - let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { - Some(std::time::Instant::now()) - } else { - None - }; - - let folder_path = find_folder_path(folder_name)?; - if !folder_path.exists() { - return Err(syn::Error::new( - Span::call_site(), - format!( - "export_app! macro: route folder '{folder_name}' not found. Create src/{folder_name} or specify a different folder with `dir = \"your_folder\"`.", - ), - )); - } - - let (mut metadata, file_asts) = collect_metadata(&folder_path, folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to scan route folder '{folder_name}'. Error: {e}. Check that all .rs files have valid Rust syntax.")))?; - metadata.structs.extend(schema_storage.values().cloned()); - merge_route_storage_data(&mut metadata, route_storage); - metadata - .check_duplicate_schema_names() - .map_err(|msg| syn::Error::new(Span::call_site(), format!("export_app! macro: {msg}")))?; - - // Generate OpenAPI spec JSON string - let openapi_doc = generate_openapi_doc_with_metadata( - None, - None, - None, - &metadata, - Some(file_asts), - route_storage, - ); - let spec_json = serde_json::to_string(&openapi_doc).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to serialize OpenAPI spec to JSON. Error: {e}. Check that all schema types are serializable.")))?; - - // Write spec to temp file for compile-time merging by parent apps - let name_str = name.to_string(); - let manifest_path = Path::new(manifest_dir); - let target_dir = find_target_dir(manifest_path); - let vespera_dir = target_dir.join("vespera"); - std::fs::create_dir_all(&vespera_dir).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to create build cache directory '{}'. Error: {}. Ensure the target directory is writable.", vespera_dir.display(), e)))?; - let spec_file = vespera_dir.join(format!("{name_str}.openapi.json")); - std::fs::write(&spec_file, &spec_json).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to write OpenAPI spec file '{}'. Error: {}. Ensure the file path is writable.", spec_file.display(), e)))?; - let spec_path_str = spec_file.display().to_string().replace('\\', "/"); - - // Generate router code (without docs routes, no merge) - let router_code = generate_router_code(&metadata, None, None, None, &[], &[]); - - let result = Ok(quote! { - /// Auto-generated vespera app struct - pub struct #name; - - impl #name { - /// OpenAPI specification as JSON string - pub const OPENAPI_SPEC: &'static str = include_str!(#spec_path_str); - - /// Create the router for this app. - /// Returns `Router<()>` which can be merged into any other router. - pub fn router() -> vespera::axum::Router<()> { - #router_code - } - } - }); - - if let Some(start) = profile_start { - eprintln!( - "[vespera-profile] export_app! macro total: {:?}", - start.elapsed() - ); - crate::schema_macro::print_profile_summary(); - } - - result -} - -#[cfg(test)] -mod tests { - use std::fs; - - use tempfile::TempDir; - - use super::*; - use crate::metadata::RouteMetadata; - - fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { - let file_path = dir.path().join(filename); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).expect("Failed to create parent directory"); - } - fs::write(&file_path, content).expect("Failed to write temp file"); - file_path - } - - // ========== Tests for generate_and_write_openapi ========== - - #[test] - fn test_generate_and_write_openapi_no_output() { - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - let (docs_url, redoc_url, spec_json) = result.unwrap(); - assert!(docs_url.is_none()); - assert!(redoc_url.is_none()); - assert!(spec_json.is_none()); - } - - #[test] - fn test_generate_and_write_openapi_docs_only() { - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: Some("Test API".to_string()), - version: Some("1.0.0".to_string()), - docs_url: Some("/docs".to_string()), - redoc_url: None, - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - let (docs_url, redoc_url, spec_json) = result.unwrap(); - assert!(docs_url.is_some()); - assert_eq!(docs_url.unwrap(), "/docs"); - assert!(spec_json.is_some()); - let json = spec_json.unwrap(); - assert!(json.contains("\"openapi\"")); - assert!(json.contains("Test API")); - assert!(redoc_url.is_none()); - } - - #[test] - fn test_generate_and_write_openapi_redoc_only() { - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: Some("/redoc".to_string()), - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - let (docs_url, redoc_url, spec_json) = result.unwrap(); - assert!(docs_url.is_none()); - assert!(redoc_url.is_some()); - assert_eq!(redoc_url.unwrap(), "/redoc"); - assert!(spec_json.is_some()); - } - - #[test] - fn test_generate_and_write_openapi_both_docs() { - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: Some("/docs".to_string()), - redoc_url: Some("/redoc".to_string()), - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - let (docs_url, redoc_url, spec_json) = result.unwrap(); - assert!(docs_url.is_some()); - assert!(redoc_url.is_some()); - assert!(spec_json.is_some()); - } - - #[test] - fn test_generate_and_write_openapi_file_output() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("test-openapi.json"); - - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![output_path.to_string_lossy().to_string()], - title: Some("File Test".to_string()), - version: Some("2.0.0".to_string()), - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - - // Verify file was written - assert!(output_path.exists()); - let content = fs::read_to_string(&output_path).unwrap(); - assert!(content.contains("\"openapi\"")); - assert!(content.contains("File Test")); - assert!(content.contains("2.0.0")); - } - - #[test] - fn test_generate_and_write_openapi_creates_directories() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("nested/dir/openapi.json"); - - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![output_path.to_string_lossy().to_string()], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - - // Verify nested directories and file were created - assert!(output_path.exists()); - } - - // ========== Tests for find_folder_path ========== - // Note: find_folder_path uses CARGO_MANIFEST_DIR which is set during cargo test - - #[test] - fn test_find_folder_path_nonexistent_returns_path() { - // When the constructed path doesn't exist, it falls back to using folder_name directly - let result = find_folder_path("nonexistent_folder_xyz").unwrap(); - // It should return a PathBuf (either from src/nonexistent... or just the folder name) - assert!(result.to_string_lossy().contains("nonexistent_folder_xyz")); - } - - // ========== Tests for find_target_dir ========== - - #[test] - fn test_find_target_dir_no_workspace() { - // Test fallback to manifest dir's target - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let manifest_path = temp_dir.path(); - let result = find_target_dir(manifest_path); - assert_eq!(result, manifest_path.join("target")); - } - - #[test] - fn test_find_target_dir_with_cargo_lock() { - // Test finding target dir with Cargo.lock present - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let manifest_path = temp_dir.path(); - - // Create Cargo.lock (but no [workspace] in Cargo.toml) - fs::write(manifest_path.join("Cargo.lock"), "").expect("Failed to write Cargo.lock"); - - let result = find_target_dir(manifest_path); - // Should use the directory with Cargo.lock - assert_eq!(result, manifest_path.join("target")); - } - - #[test] - fn test_find_target_dir_with_workspace() { - // Test finding workspace root - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let workspace_root = temp_dir.path(); - - // Create a workspace Cargo.toml - fs::write( - workspace_root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crate1\"]", - ) - .expect("Failed to write Cargo.toml"); - - // Create nested crate directory - let crate_dir = workspace_root.join("crate1"); - fs::create_dir(&crate_dir).expect("Failed to create crate dir"); - fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"crate1\"") - .expect("Failed to write Cargo.toml"); - - let result = find_target_dir(&crate_dir); - // Should return workspace root's target - assert_eq!(result, workspace_root.join("target")); - } - - #[test] - fn test_find_target_dir_workspace_with_cargo_lock() { - // Test that [workspace] takes priority over Cargo.lock - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let workspace_root = temp_dir.path(); - - // Create workspace Cargo.toml and Cargo.lock - fs::write( - workspace_root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crate1\"]", - ) - .expect("Failed to write Cargo.toml"); - fs::write(workspace_root.join("Cargo.lock"), "").expect("Failed to write Cargo.lock"); - - // Create nested crate - let crate_dir = workspace_root.join("crate1"); - fs::create_dir(&crate_dir).expect("Failed to create crate dir"); - fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"crate1\"") - .expect("Failed to write Cargo.toml"); - - let result = find_target_dir(&crate_dir); - assert_eq!(result, workspace_root.join("target")); - } - - #[test] - fn test_find_target_dir_deeply_nested() { - // Test deeply nested crate structure - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let workspace_root = temp_dir.path(); - - // Create workspace - fs::write( - workspace_root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/*\"]", - ) - .expect("Failed to write Cargo.toml"); - - // Create deeply nested crate - let deep_crate = workspace_root.join("crates/group/my-crate"); - fs::create_dir_all(&deep_crate).expect("Failed to create nested dirs"); - fs::write(deep_crate.join("Cargo.toml"), "[package]").expect("Failed to write Cargo.toml"); - - let result = find_target_dir(&deep_crate); - assert_eq!(result, workspace_root.join("target")); - } - - // ========== Tests for process_vespera_macro ========== - - #[test] - fn test_process_vespera_macro_folder_not_found() { - let processed = ProcessedVesperaInput { - folder_name: "nonexistent_folder_xyz_123".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - let result = process_vespera_macro(&processed, &HashMap::new(), &[]); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("route folder") && err.contains("not found")); - } - - #[test] - fn test_process_vespera_macro_collect_metadata_error() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an invalid route file (will cause parse error but collect_metadata handles it) - create_temp_file(&temp_dir, "invalid.rs", "not valid rust code {{{"); - - let processed = ProcessedVesperaInput { - folder_name: temp_dir.path().to_string_lossy().to_string(), - openapi_file_names: vec![], - title: Some("Test API".to_string()), - version: Some("1.0.0".to_string()), - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - // This exercises the collect_metadata path (which handles parse errors gracefully) - let result = process_vespera_macro(&processed, &HashMap::new(), &[]); - // Result may succeed or fail depending on how collect_metadata handles invalid files - let _ = result; - } - - #[test] - fn test_process_vespera_macro_with_schema_storage() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an empty file (valid but no routes) - create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); - - let schema_storage = HashMap::from([( - "TestSchema".to_string(), - StructMetadata::new( - "TestSchema".to_string(), - "struct TestSchema { id: i32 }".to_string(), - ), - )]); - - let processed = ProcessedVesperaInput { - folder_name: temp_dir.path().to_string_lossy().to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: Some("/docs".to_string()), - redoc_url: Some("/redoc".to_string()), - servers: None, - merge: vec![], - }; - - // This exercises the schema_storage extend path - let result = process_vespera_macro(&processed, &schema_storage, &[]); - // We only care about exercising the code path - let _ = result; - } - - #[test] - #[serial_test::serial] - fn test_process_vespera_macro_with_cron_storage() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create src/ subfolder structure to simulate a real project - let src_dir = temp_dir.path().join("src"); - std::fs::create_dir_all(src_dir.join("routes")).expect("create routes dir"); - std::fs::write(src_dir.join("routes").join("health.rs"), "// empty\n") - .expect("write health.rs"); - - // Set CARGO_MANIFEST_DIR so module path derivation works - let old_manifest = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { - std::env::set_var( - "CARGO_MANIFEST_DIR", - temp_dir.path().to_string_lossy().as_ref(), - ); - } - - // Populate CRON_STORAGE with a fake cron entry - { - let mut storage = crate::CRON_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - storage.push(crate::cron_impl::StoredCronInfo { - fn_name: "test_cron_job".to_string(), - expression: "0 */5 * * * *".to_string(), - file_path: Some( - src_dir - .join("routes") - .join("health.rs") - .display() - .to_string(), - ), - }); - } - - let processed = ProcessedVesperaInput { - folder_name: src_dir.join("routes").to_string_lossy().to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - // This exercises the CRON_STORAGE → CronMetadata derivation path - let result = process_vespera_macro(&processed, &HashMap::new(), &[]); - assert!( - result.is_ok(), - "Should succeed with cron storage: {result:?}" - ); - - // Clean up CRON_STORAGE - { - let mut storage = crate::CRON_STORAGE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - storage.retain(|s| s.fn_name != "test_cron_job"); - } - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(val) = old_manifest { - std::env::set_var("CARGO_MANIFEST_DIR", val); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - } - } - - // ========== Tests for process_export_app ========== - - #[test] - fn test_process_export_app_folder_not_found() { - let name: syn::Ident = syn::parse_quote!(TestApp); - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let result = process_export_app( - &name, - "nonexistent_folder_xyz", - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("route folder") && err.contains("not found")); - } - - #[test] - fn test_process_export_app_with_empty_folder() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an empty file - create_temp_file(&temp_dir, "empty.rs", "// empty\n"); - - let name: syn::Ident = syn::parse_quote!(TestApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - // This exercises collect_metadata and other paths - let result = process_export_app( - &name, - &folder_path, - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - // We only care about exercising the code path - let _ = result; - } - - #[test] - fn test_process_export_app_with_schema_storage() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an empty but valid Rust file - create_temp_file(&temp_dir, "mod.rs", "// module file\n"); - - let schema_storage = HashMap::from([( - "AppSchema".to_string(), - StructMetadata::new( - "AppSchema".to_string(), - "struct AppSchema { name: String }".to_string(), - ), - )]); - - let name: syn::Ident = syn::parse_quote!(MyExportedApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - let result = process_export_app( - &name, - &folder_path, - &schema_storage, - &temp_dir.path().to_string_lossy(), - &[], - ); - // Exercises the schema_storage.extend path - let _ = result; - } - - // ========== Tests for generate_and_write_openapi with merge ========== - - #[test] - fn test_generate_and_write_openapi_with_merge_no_manifest_dir() { - // When CARGO_MANIFEST_DIR is not set or merge is empty, it should work normally - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: Some("Test".to_string()), - version: None, - docs_url: Some("/docs".to_string()), - redoc_url: None, - servers: None, - merge: vec![syn::parse_quote!(app::TestApp)], // Has merge but no valid manifest dir - }; - let metadata = CollectedMetadata::new(); - // This should still work - merge logic is skipped when CARGO_MANIFEST_DIR lookup fails - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_ok()); - } - - #[test] - fn test_generate_and_write_openapi_with_merge_and_valid_spec() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create the vespera directory with a spec file - let target_dir = temp_dir.path().join("target").join("vespera"); - fs::create_dir_all(&target_dir).expect("Failed to create target/vespera dir"); - - // Write a valid OpenAPI spec file - let spec_content = - r#"{"openapi":"3.1.0","info":{"title":"Child API","version":"1.0.0"},"paths":{}}"#; - fs::write(target_dir.join("ChildApp.openapi.json"), spec_content) - .expect("Failed to write spec file"); - - // Save and set CARGO_MANIFEST_DIR - let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: We're in a single-threaded test context - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: Some("Parent API".to_string()), - version: Some("2.0.0".to_string()), - docs_url: Some("/docs".to_string()), - redoc_url: None, - servers: None, - merge: vec![syn::parse_quote!(child::ChildApp)], - }; - let metadata = CollectedMetadata::new(); - - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - - // Restore CARGO_MANIFEST_DIR - if let Some(old_value) = old_manifest_dir { - // SAFETY: We're in a single-threaded test context - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) }; - } - - assert!(result.is_ok()); - } - - // ========== Tests for find_folder_path ========== - - #[test] - fn test_find_folder_path_absolute_path() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let absolute_path = temp_dir.path().to_string_lossy().to_string(); - - // When given an absolute path that exists, it should return it - let result = find_folder_path(&absolute_path).unwrap(); - // The function tries src/{folder_name} first, then falls back to the folder_name directly - assert!( - result.to_string_lossy().contains(&absolute_path) - || result == Path::new(&absolute_path) - ); - } - - #[test] - fn test_find_folder_path_with_src_folder() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create src/routes directory - let src_routes = temp_dir.path().join("src").join("routes"); - fs::create_dir_all(&src_routes).expect("Failed to create src/routes dir"); - - // Save and set CARGO_MANIFEST_DIR - let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); - // SAFETY: We're in a single-threaded test context - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let result = find_folder_path("routes").unwrap(); - - // Restore CARGO_MANIFEST_DIR - if let Some(old_value) = old_manifest_dir { - // SAFETY: We're in a single-threaded test context - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) }; - } - - // Should return the src/routes path since it exists - assert!( - result.to_string_lossy().contains("src") && result.to_string_lossy().contains("routes") - ); - } - - // ========== Error path coverage tests ========== - - #[test] - fn test_generate_and_write_openapi_file_write_error() { - // Line 95: fs::write failure when output path is a directory - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create a directory where the output file should be - let output_path = temp_dir.path().join("openapi.json"); - fs::create_dir(&output_path).expect("Failed to create directory"); - - let processed = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![output_path.to_string_lossy().to_string()], - title: Some("Test API".to_string()), - version: Some("1.0.0".to_string()), - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - let metadata = CollectedMetadata::new(); - - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("failed to write file")); - } - - #[test] - fn test_process_export_app_collect_metadata_error() { - // Lines 210-212: collect_metadata returns error for invalid Rust syntax - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create a file with invalid Rust syntax that will cause parse error - create_temp_file(&temp_dir, "invalid.rs", "fn broken( { syntax error"); - - let name: syn::Ident = syn::parse_quote!(TestApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - let result = process_export_app( - &name, - &folder_path, - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("failed to scan route folder")); - } - - #[test] - fn test_process_export_app_create_dir_error() { - // Lines 232-234: create_dir_all failure when path contains a file - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an empty valid Rust file - create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); - - // Create target directory but make 'vespera' a file instead of directory - let target_dir = temp_dir.path().join("target"); - fs::create_dir(&target_dir).expect("Failed to create target dir"); - fs::write(target_dir.join("vespera"), "blocking file").expect("Failed to write file"); - - let name: syn::Ident = syn::parse_quote!(TestApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - let result = process_export_app( - &name, - &folder_path, - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("failed to create build cache directory")); - } - - #[test] - fn test_process_export_app_write_spec_error() { - // Lines 239-241: fs::write failure when spec file path is a directory - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - // Create an empty valid Rust file - create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); - - // Create target/vespera directory and make spec file name a directory - let vespera_dir = temp_dir.path().join("target").join("vespera"); - fs::create_dir_all(&vespera_dir).expect("Failed to create vespera dir"); - // Create a directory where the spec file should be written - fs::create_dir(vespera_dir.join("TestApp.openapi.json")) - .expect("Failed to create blocking dir"); - - let name: syn::Ident = syn::parse_quote!(TestApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - let result = process_export_app( - &name, - &folder_path, - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("failed to write OpenAPI spec file")); - } - #[test] - fn test_process_vespera_macro_no_openapi_output() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - create_temp_file(&temp_dir, "empty.rs", "// empty route file\n"); - - let processed = ProcessedVesperaInput { - folder_name: temp_dir.path().to_string_lossy().to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - let result = process_vespera_macro(&processed, &HashMap::new(), &[]); - assert!( - result.is_ok(), - "Should succeed with no openapi output configured" - ); - } - - #[test] - #[serial_test::serial] - fn test_process_vespera_macro_with_profiling() { - let old_profile = std::env::var("VESPERA_PROFILE").ok(); - unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; - - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - create_temp_file(&temp_dir, "empty.rs", "// empty\n"); - - let processed = ProcessedVesperaInput { - folder_name: temp_dir.path().to_string_lossy().to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - let result = process_vespera_macro(&processed, &HashMap::new(), &[]); - - // Restore - unsafe { - if let Some(val) = old_profile { - std::env::set_var("VESPERA_PROFILE", val); - } else { - std::env::remove_var("VESPERA_PROFILE"); - } - }; - - assert!(result.is_ok()); - } - - #[test] - #[serial_test::serial] - fn test_process_export_app_with_profiling() { - let old_profile = std::env::var("VESPERA_PROFILE").ok(); - unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; - - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - create_temp_file(&temp_dir, "empty.rs", "// empty\n"); - - let name: syn::Ident = syn::parse_quote!(TestProfileApp); - let folder_path = temp_dir.path().to_string_lossy().to_string(); - - let result = process_export_app( - &name, - &folder_path, - &HashMap::new(), - &temp_dir.path().to_string_lossy(), - &[], - ); - - // Restore - unsafe { - if let Some(val) = old_profile { - std::env::set_var("VESPERA_PROFILE", val); - } else { - std::env::remove_var("VESPERA_PROFILE"); - } - }; - - // Exercise the code path - let _ = result; - } - - // ========== Tests for merge_route_storage_data ========== - - #[test] - fn test_merge_route_storage_empty_storage() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: "pub async fn get_users() -> Json>".to_string(), - error_status: None, - tags: None, - description: None, - }); - - merge_route_storage_data(&mut metadata, &[]); - // No changes when storage is empty - assert!(metadata.routes[0].tags.is_none()); - assert!(metadata.routes[0].description.is_none()); - assert!(metadata.routes[0].error_status.is_none()); - } - - #[test] - fn test_merge_route_storage_matching_route() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: "pub async fn get_users() -> Json>".to_string(), - error_status: None, - tags: None, - description: None, - }); - - let storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: Some(vec![400, 404]), - tags: Some(vec!["users".to_string()]), - description: Some("List all users".to_string()), - fn_item_str: String::new(), - file_path: None, - }]; - - merge_route_storage_data(&mut metadata, &storage); - assert_eq!(metadata.routes[0].tags, Some(vec!["users".to_string()])); - assert_eq!( - metadata.routes[0].description, - Some("List all users".to_string()) - ); - assert_eq!(metadata.routes[0].error_status, Some(vec![400, 404])); - } - - #[test] - fn test_merge_route_storage_no_match() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: String::new(), - error_status: None, - tags: None, - description: None, - }); - - let storage = vec![StoredRouteInfo { - fn_name: "create_user".to_string(), - method: Some("post".to_string()), - custom_path: None, - error_status: Some(vec![400]), - tags: Some(vec!["users".to_string()]), - description: None, - fn_item_str: String::new(), - file_path: None, - }]; - - merge_route_storage_data(&mut metadata, &storage); - // No match — fields unchanged - assert!(metadata.routes[0].tags.is_none()); - assert!(metadata.routes[0].error_status.is_none()); - } - - #[test] - fn test_merge_route_storage_ambiguous_skipped() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "handler".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: String::new(), - error_status: None, - tags: None, - description: None, - }); - - // Two StoredRouteInfo with same fn_name — ambiguous - let storage = vec![ - StoredRouteInfo { - fn_name: "handler".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: Some(vec!["file-a".to_string()]), - description: None, - fn_item_str: String::new(), - file_path: None, - }, - StoredRouteInfo { - fn_name: "handler".to_string(), - method: Some("post".to_string()), - custom_path: None, - error_status: None, - tags: Some(vec!["file-b".to_string()]), - description: None, - fn_item_str: String::new(), - file_path: None, - }, - ]; - - merge_route_storage_data(&mut metadata, &storage); - // Ambiguous match — no merge - assert!(metadata.routes[0].tags.is_none()); - } - - #[test] - fn test_merge_route_storage_preserves_existing() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: String::new(), - error_status: Some(vec![500]), - tags: Some(vec!["existing-tag".to_string()]), - description: Some("Existing description".to_string()), - }); - - let storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: Some(vec![400, 404]), - tags: Some(vec!["new-tag".to_string()]), - description: Some("New description".to_string()), - fn_item_str: String::new(), - file_path: None, - }]; - - merge_route_storage_data(&mut metadata, &storage); - // ROUTE_STORAGE values override when they have explicit values - assert_eq!(metadata.routes[0].tags, Some(vec!["new-tag".to_string()])); - assert_eq!( - metadata.routes[0].description, - Some("New description".to_string()) - ); - assert_eq!(metadata.routes[0].error_status, Some(vec![400, 404])); - } - - #[test] - fn test_merge_route_storage_partial_fields() { - let mut metadata = CollectedMetadata::new(); - metadata.routes.push(RouteMetadata { - method: "get".to_string(), - path: "/users".to_string(), - function_name: "get_users".to_string(), - module_path: "routes".to_string(), - file_path: "routes/users.rs".to_string(), - signature: String::new(), - error_status: None, - tags: Some(vec!["from-collector".to_string()]), - description: Some("From doc comment".to_string()), - }); - - // StoredRouteInfo with only error_status (tags/description are None) - let storage = vec![StoredRouteInfo { - fn_name: "get_users".to_string(), - method: Some("get".to_string()), - custom_path: None, - error_status: Some(vec![400]), - tags: None, - description: None, - fn_item_str: String::new(), - file_path: None, - }]; - - merge_route_storage_data(&mut metadata, &storage); - // Only error_status should be set; tags and description preserved from collector - assert_eq!( - metadata.routes[0].tags, - Some(vec!["from-collector".to_string()]) - ); - assert_eq!( - metadata.routes[0].description, - Some("From doc comment".to_string()) - ); - assert_eq!(metadata.routes[0].error_status, Some(vec![400])); - } - - #[test] - fn test_compute_config_hash_with_servers() { - // Exercises lines 92-96: servers loop in compute_config_hash - let processed_no_servers = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - let processed_with_servers = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: Some(vec![ - vespera_core::openapi::Server { - url: "https://api.example.com".to_string(), - description: None, - variables: None, - }, - vespera_core::openapi::Server { - url: "http://localhost:3000".to_string(), - description: None, - variables: None, - }, - ]), - merge: vec![], - }; - - let hash_no_servers = compute_config_hash(&processed_no_servers); - let hash_with_servers = compute_config_hash(&processed_with_servers); - - // Different servers should produce different hashes - assert_ne!( - hash_no_servers, hash_with_servers, - "Servers should affect config hash" - ); - } - - #[test] - fn test_compute_config_hash_with_merge() { - // Exercises lines 97-99: merge loop in compute_config_hash - let processed_no_merge = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![], - }; - - let processed_with_merge = ProcessedVesperaInput { - folder_name: "routes".to_string(), - openapi_file_names: vec![], - title: None, - version: None, - docs_url: None, - redoc_url: None, - servers: None, - merge: vec![syn::parse_quote!(app::TestApp)], - }; - - let hash_no_merge = compute_config_hash(&processed_no_merge); - let hash_with_merge = compute_config_hash(&processed_with_merge); - - assert_ne!( - hash_no_merge, hash_with_merge, - "Merge paths should affect config hash" - ); - } - - #[test] - fn test_ensure_openapi_files_from_cache_none_spec() { - // Exercises lines 266-267: early return when spec_pretty is None - let result = ensure_openapi_files_from_cache(&["dummy.json".to_string()], None); - assert!(result.is_ok()); - } - - #[test] - fn test_ensure_openapi_files_from_cache_writes_file() { - // Exercises lines 269-276: write new file - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("api.json"); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = ensure_openapi_files_from_cache( - &[output_path.to_string_lossy().to_string()], - Some(spec), - ); - assert!(result.is_ok()); - assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); - } - - #[test] - fn test_ensure_openapi_files_from_cache_skip_unchanged() { - // Exercises line 271-272: should_write is false when content matches - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("api.json"); - let spec = r#"{"openapi":"3.1.0"}"#; - - // Write file first with same content - fs::write(&output_path, spec).unwrap(); - - let result = ensure_openapi_files_from_cache( - &[output_path.to_string_lossy().to_string()], - Some(spec), - ); - assert!(result.is_ok()); - // File should still contain same content (no unnecessary write) - assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); - } - - #[test] - fn test_ensure_openapi_files_from_cache_creates_parent_dirs() { - // Exercises lines 273-274: create parent directories - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("nested").join("dir").join("api.json"); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = ensure_openapi_files_from_cache( - &[output_path.to_string_lossy().to_string()], - Some(spec), - ); - assert!(result.is_ok()); - assert!(output_path.exists()); - assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); - } - - #[test] - fn test_ensure_openapi_files_from_cache_write_error() { - // Exercises line 276: write failure - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_path = temp_dir.path().join("api.json"); - - // Create a directory where the file should be -> write will fail - fs::create_dir(&output_path).unwrap(); - - let result = ensure_openapi_files_from_cache( - &[output_path.to_string_lossy().to_string()], - Some("spec"), - ); - assert!(result.is_err()); - } - - #[test] - fn test_ensure_openapi_files_from_cache_multiple_files() { - // Exercises the loop with multiple file names (line 269) - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let path1 = temp_dir.path().join("api1.json"); - let path2 = temp_dir.path().join("api2.json"); - let spec = r#"{"openapi":"3.1.0"}"#; - - let result = ensure_openapi_files_from_cache( - &[ - path1.to_string_lossy().to_string(), - path2.to_string_lossy().to_string(), - ], - Some(spec), - ); - assert!(result.is_ok()); - assert_eq!(fs::read_to_string(&path1).unwrap(), spec); - assert_eq!(fs::read_to_string(&path2).unwrap(), spec); - } - - #[test] - #[serial_test::serial] - fn test_process_vespera_macro_cache_hit() { - // Exercises lines 320-324, 327, 329: the cache_hit branch in process_vespera_macro. - // First call populates the cache, second call hits it. - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - create_temp_file( - &temp_dir, - "users.rs", - "pub async fn list_users() -> String { \"users\".to_string() }\n", - ); - - let folder_path = temp_dir.path().to_string_lossy().to_string(); - let openapi_path = temp_dir.path().join("openapi.json"); - - // Set CARGO_MANIFEST_DIR so cache path resolves to temp_dir/target/vespera/ - let old_manifest = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; - - let processed = ProcessedVesperaInput { - folder_name: folder_path.clone(), - openapi_file_names: vec![openapi_path.to_string_lossy().to_string()], - title: Some("Test API".to_string()), - version: Some("1.0.0".to_string()), - docs_url: Some("/docs".to_string()), - redoc_url: None, - servers: None, - merge: vec![], - }; - - // First call: cache MISS — scans files, generates spec, writes cache - let result1 = process_vespera_macro(&processed, &HashMap::new(), &[]); - assert!( - result1.is_ok(), - "First call (cache miss) should succeed: {:?}", - result1.err() - ); - assert!( - openapi_path.exists(), - "openapi.json should be written on first call" - ); - - // Second call: cache HIT — exercises lines 320-324, 327, 329 - let result2 = process_vespera_macro(&processed, &HashMap::new(), &[]); - assert!( - result2.is_ok(), - "Second call (cache hit) should succeed: {:?}", - result2.err() - ); - - // Restore CARGO_MANIFEST_DIR - unsafe { - if let Some(val) = old_manifest { - std::env::set_var("CARGO_MANIFEST_DIR", val); - } else { - std::env::remove_var("CARGO_MANIFEST_DIR"); - } - }; - } -} +pub use orchestrator::{process_export_app, process_vespera_macro}; +#[allow(unused_imports)] +pub use path_utils::{find_folder_path, find_target_dir}; diff --git a/crates/vespera_macro/src/vespera_impl/cache.rs b/crates/vespera_macro/src/vespera_impl/cache.rs new file mode 100644 index 00000000..7f9525ff --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/cache.rs @@ -0,0 +1,619 @@ +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + path::{Path, PathBuf}, +}; + +use quote::quote; +use serde::{Deserialize, Serialize}; + +use crate::{ + metadata::{CollectedMetadata, StructMetadata}, + router_codegen::ProcessedVesperaInput, +}; + +use super::path_utils::{current_crate_tag, find_target_dir}; + +/// Current cache format. Bump when the on-disk layout changes — +/// old caches deserialize with `cache_format: 0` (serde default) and +/// are treated as a miss. +pub(super) const CACHE_FORMAT: u32 = 2; + +/// Cache for avoiding redundant route scanning and OpenAPI generation. +/// Persisted to `target/vespera/routes.cache` across builds. +/// +/// The spec JSON strings themselves live in **sidecar files** (the +/// `include_str!` embed file and the pretty sidecar) — the cache only +/// stores their content hashes. Embedding them inline as JSON strings +/// doubled the cache size via escaping and dominated warm-rebuild +/// `read_cache` time. +#[derive(Serialize, Deserialize)] +pub(super) struct VesperaCache { + /// On-disk layout version — see [`CACHE_FORMAT`]. + #[serde(default)] + pub(super) cache_format: u32, + /// Macro crate version — invalidates cache when macro code changes + #[serde(default)] + pub(super) macro_version: String, + /// In-repo macro source fingerprint — invalidates cache when the + /// macro source itself changes during vespera development (the + /// version alone only changes per release). `0` for downstream + /// users. See [`compute_macro_dev_fingerprint`]. + #[serde(default)] + pub(super) macro_dev_fingerprint: u64, + /// File path → modification time (secs since UNIX_EPOCH) + pub(super) file_fingerprints: HashMap, + /// Hash of SCHEMA_STORAGE contents + pub(super) schema_hash: u64, + /// Hash of OpenAPI config (title, version, servers, docs_url, etc.) + pub(super) config_hash: u64, + /// Cached route/struct metadata + pub(super) metadata: CollectedMetadata, + /// Content hash of the compact spec in the embed sidecar file + /// (`vespera_spec-.json`). `None` if docs disabled. + #[serde(default)] + pub(super) spec_json_hash: Option, + /// Content hash of the pretty spec in the pretty sidecar file + /// (`openapi_pretty-.json`). `None` if no openapi file configured. + #[serde(default)] + pub(super) spec_pretty_hash: Option, + /// Metadata fingerprint (mtime + len) of the compact spec sidecar. + #[serde(default)] + pub(super) spec_json_fingerprint: Option, + /// Metadata fingerprint (mtime + len) of the pretty spec sidecar. + #[serde(default)] + pub(super) spec_pretty_fingerprint: Option, +} + +/// Cheap metadata fingerprint for sidecar files. +pub(super) fn path_fingerprint(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + let modified = meta.modified().ok()?; + let mtime = u64::try_from( + modified + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + ) + .unwrap_or(u64::MAX); + Some( + mtime.rotate_left(1).wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ meta.len().wrapping_mul(0xD1B5_4A32_D192_ED03), + ) +} + +/// Validate a sidecar by cheap metadata first, falling back to content hash when +/// the metadata fingerprint changed. +pub(super) fn sidecar_matches( + path: &Path, + expected_hash: Option, + expected_fingerprint: Option, +) -> bool { + let Some(hash) = expected_hash else { + return false; + }; + if expected_fingerprint.is_some_and(|fingerprint| path_fingerprint(path) == Some(fingerprint)) { + return true; + } + std::fs::read_to_string(path).is_ok_and(|content| hash_str(&content) == hash) +} + +/// Deterministic content hash for sidecar spec validation. +pub(super) fn hash_str(s: &str) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() +} + +#[derive(Clone)] +pub enum MergeSpecRead { + Present(String), + Error(String), +} + +pub struct MergeSpecCache { + dir: Option, + reads: HashMap, +} + +impl MergeSpecCache { + pub(super) fn new() -> Self { + Self { + dir: merge_spec_dir(), + reads: HashMap::new(), + } + } + + pub(super) fn spec_file_for(&self, merge_path: &syn::Path) -> Option<(String, PathBuf)> { + let dir = self.dir.as_ref()?; + let struct_name = merge_path.segments.last()?.ident.to_string(); + Some(( + struct_name.clone(), + dir.join(format!("{struct_name}.openapi.json")), + )) + } + + pub(super) fn read(&mut self, path: &Path) -> MergeSpecRead { + if let Some(cached) = self.reads.get(path) { + return cached.clone(); + } + let read = match std::fs::read_to_string(path) { + Ok(content) => MergeSpecRead::Present(content), + Err(err) => MergeSpecRead::Error(err.to_string()), + }; + self.reads.insert(path.to_path_buf(), read.clone()); + read + } +} + +/// Compute a deterministic hash of SCHEMA_STORAGE contents. +pub(super) fn compute_schema_hash(schema_storage: &HashMap) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let mut keys: Vec<&String> = schema_storage.keys().collect(); + keys.sort(); + for key in keys { + key.hash(&mut hasher); + let meta = &schema_storage[key]; + meta.name.hash(&mut hasher); + meta.definition.hash(&mut hasher); + meta.include_in_openapi.hash(&mut hasher); + // Field defaults (`#[serde(default = "fn")]`) feed the generated + // OpenAPI `default` values but are NOT part of `definition`, so a + // changed default would otherwise hit a STALE route cache and reuse + // outdated spec defaults. `BTreeMap` iterates in sorted key order + // (deterministic); hash each field name + its serialized JSON value. + for (field, value) in &meta.field_defaults { + field.hash(&mut hasher); + value.to_string().hash(&mut hasher); + } + } + hasher.finish() +} + +/// Compute a deterministic hash of OpenAPI config fields. +#[cfg(test)] +pub(super) fn compute_config_hash(processed: &ProcessedVesperaInput) -> u64 { + let mut merge_specs = MergeSpecCache::new(); + compute_config_hash_with_merge_cache(processed, &mut merge_specs) +} + +/// Compute a deterministic hash of OpenAPI config fields, sharing merge-sidecar reads. +pub(super) fn compute_config_hash_with_merge_cache( + processed: &ProcessedVesperaInput, + merge_specs: &mut MergeSpecCache, +) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + processed.title.hash(&mut hasher); + processed.version.hash(&mut hasher); + processed.docs_url.hash(&mut hasher); + processed.redoc_url.hash(&mut hasher); + processed.openapi_file_names.hash(&mut hasher); + match &processed.servers { + None => "servers:none".hash(&mut hasher), + Some(servers) => { + "servers:some".hash(&mut hasher); + servers.len().hash(&mut hasher); + for s in servers { + s.url.hash(&mut hasher); + s.description.hash(&mut hasher); + } + } + } + if let Some(ref schemes) = processed.security_schemes { + for (name, scheme) in schemes { + name.hash(&mut hasher); + // Hash a STABLE serialized representation of the whole scheme + // rather than a hand-picked field subset. The previous list + // omitted `flows` and `open_id_connect_url`, so changing only an + // OIDC discovery URL hit the warm route cache and reused stale + // OpenAPI output. `serde_json` renders struct fields in + // declaration order (deterministic) and `skip_serializing_if` + // only drops `None`s, so the digest is faithful AND future-proof: + // any field added to `SecurityScheme` is covered automatically, + // closing this class of stale-cache bug for good. Serialization + // is infallible for this plain struct; a hypothetical failure + // falls back to a stable marker so the hash still differs. + match serde_json::to_string(scheme) { + Ok(json) => json.hash(&mut hasher), + Err(_) => "scheme:unserializable".hash(&mut hasher), + } + } + } + match &processed.security { + None => "security:none".hash(&mut hasher), + Some(security) => { + "security:some".hash(&mut hasher); + security.len().hash(&mut hasher); + for requirement in security { + let mut names: Vec<_> = requirement.keys().collect(); + names.sort_unstable(); + for name in names { + name.hash(&mut hasher); + } + } + } + } + if let Some(ref descriptions) = processed.tag_descriptions { + let mut names: Vec<_> = descriptions.keys().collect(); + names.sort_unstable(); + for name in names { + name.hash(&mut hasher); + descriptions[name].hash(&mut hasher); + } + } + // Merge children: hash each child app's NAME *and* its exported + // OpenAPI sidecar content, so a change to a child's spec invalidates + // the parent's cached merged document — the path name alone cannot + // detect a child whose routes / schemas changed between builds. + // Mirrors the sidecar resolution in `generate_and_write_openapi` + // (`vespera_dir / .openapi.json`). + for merge_path in &processed.merge { + quote!(#merge_path).to_string().hash(&mut hasher); + if let Some((_struct_name, spec_file)) = merge_specs.spec_file_for(merge_path) { + match merge_specs.read(&spec_file) { + MergeSpecRead::Present(content) => content.hash(&mut hasher), + // Absent / unreadable child sidecar → stable marker so the + // hashed state still differs from a present spec. + MergeSpecRead::Error(_) => "child-spec:absent".hash(&mut hasher), + } + } + } + hasher.finish() +} + +/// Compute a deterministic hash for `export_app!` inputs. +pub(super) fn compute_export_config_hash(app_name: &str, folder_name: &str) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + "export_app:v1".hash(&mut hasher); + app_name.hash(&mut hasher); + folder_name.hash(&mut hasher); + hasher.finish() +} + +/// Directory holding child apps' exported OpenAPI sidecars +/// (`.openapi.json`), used by [`compute_config_hash`] to fold a +/// merged child's spec content into the parent cache key. Mirrors the +/// resolution `generate_and_write_openapi` uses when merging child specs. +fn merge_spec_dir() -> Option { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?; + Some(find_target_dir(Path::new(&manifest_dir)).join("vespera")) +} + +/// Get the path to this crate's routes cache file. +pub(super) fn get_cache_path() -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let manifest_path = Path::new(&manifest_dir); + find_target_dir(manifest_path) + .join("vespera") + .join(format!("routes-{}.cache", current_crate_tag())) +} + +/// Get the path to this crate/app/folder's `export_app!` route cache file. +pub(super) fn get_export_cache_path(app_name: &str, folder_name: &str) -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let manifest_path = Path::new(&manifest_dir); + find_target_dir(manifest_path).join("vespera").join(format!( + "export-routes-{}-{}-{:016x}.cache", + current_crate_tag(), + app_name, + compute_export_config_hash(app_name, folder_name) + )) +} + +/// Fingerprint of the vespera_macro **source tree itself**, for cache +/// invalidation while developing the macro in this repository. +/// +/// `macro_version` only changes per release, so editing macro code +/// in-repo would otherwise keep serving the previous build's cached +/// spec. When `{workspace_root}/crates/vespera_macro/src` exists +/// (i.e. the consuming crate lives inside the vespera repo), hash +/// every `.rs` mtime in it; for downstream users the directory is +/// absent and this is a single failed `stat` (returns 0). +pub(super) fn compute_macro_dev_fingerprint() -> u64 { + // Memoized per proc-macro process: macro source mtimes cannot change + // the dll that is currently executing, so one scan per process is + // exactly as precise as one scan per invocation. (A fresh cargo + // build of vespera_macro loads a fresh dll → fresh process state.) + static MEMO: std::sync::OnceLock = std::sync::OnceLock::new(); + *MEMO.get_or_init(compute_macro_dev_fingerprint_uncached) +} + +fn compute_macro_dev_fingerprint_uncached() -> u64 { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let target_dir = find_target_dir(Path::new(&manifest_dir)); + let Some(workspace_root) = target_dir.parent() else { + return 0; + }; + let macro_src = workspace_root + .join("crates") + .join("vespera_macro") + .join("src"); + if !macro_src.is_dir() { + return 0; + } + let mut entries: Vec<(String, u64)> = Vec::new(); + collect_rs_mtimes(¯o_src, &mut entries); + entries.sort(); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for (path, mtime) in &entries { + path.hash(&mut hasher); + mtime.hash(&mut hasher); + } + hasher.finish() +} + +/// Recursively collect `(path, mtime)` pairs for `.rs` files. +/// +/// Uses `DirEntry::file_type()` / `DirEntry::metadata()` rather than +/// `Path::is_dir()` / `fs::metadata(&path)`: both `DirEntry` accessors +/// are carried by the directory scan (free on Windows + most Unix), so +/// the dir/file split costs no extra `stat` syscall per entry — only +/// the `.rs` files we actually fingerprint pay for their mtime. +fn collect_rs_mtimes(dir: &Path, out: &mut Vec<(String, u64)>) { + let Ok(read_dir) = std::fs::read_dir(dir) else { + return; + }; + for entry in read_dir.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + if file_type.is_dir() { + collect_rs_mtimes(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + // Nanosecond resolution (matching `file_utils::mtime_fingerprint`): + // whole-second granularity let two edits to the same file within one + // wall-clock second collide on the same fingerprint, so the route + // cache could serve a stale router / OpenAPI spec under fast + // incremental rebuilds. Truncating the u128 nanos-since-epoch to u64 + // keeps every sub-second bit (only overflows past ~year 2554) and the + // fingerprint is only ever compared for equality. + let mtime = entry.metadata().and_then(|m| m.modified()).map_or(0, |t| { + u64::try_from( + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + ) + .unwrap_or(u64::MAX) + }); + out.push((path.display().to_string(), mtime)); + } + } +} + +/// Try to read and deserialize a cache file. Returns None on any failure. +pub(super) fn read_cache(cache_path: &Path) -> Option { + let content = std::fs::read_to_string(cache_path).ok()?; + serde_json::from_str(&content).ok() +} + +/// Write cache to disk. Failures are silently ignored (cache is best-effort). +pub(super) fn write_cache(cache_path: &Path, cache: &VesperaCache) { + if let Some(parent) = cache_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string(cache) { + let _ = std::fs::write(cache_path, json); + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use vespera_core::schema::{SecurityScheme, SecuritySchemeType}; + + use super::*; + + fn base_processed() -> ProcessedVesperaInput { + ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + } + } + + #[test] + fn test_compute_config_hash_with_servers() { + // Exercises lines 92-96: servers loop in compute_config_hash + let processed_no_servers = base_processed(); + + let processed_with_servers = ProcessedVesperaInput { + servers: Some(vec![ + vespera_core::openapi::Server { + url: "https://api.example.com".to_string(), + description: None, + variables: None, + }, + vespera_core::openapi::Server { + url: "http://localhost:3000".to_string(), + description: None, + variables: None, + }, + ]), + ..base_processed() + }; + + let hash_no_servers = compute_config_hash(&processed_no_servers); + let hash_with_servers = compute_config_hash(&processed_with_servers); + + // Different servers should produce different hashes + assert_ne!( + hash_no_servers, hash_with_servers, + "Servers should affect config hash" + ); + } + + #[test] + fn test_compute_config_hash_with_merge() { + // Exercises lines 97-99: merge loop in compute_config_hash + let processed_no_merge = base_processed(); + + let processed_with_merge = ProcessedVesperaInput { + merge: vec![syn::parse_quote!(app::TestApp)], + ..base_processed() + }; + + let hash_no_merge = compute_config_hash(&processed_no_merge); + let hash_with_merge = compute_config_hash(&processed_with_merge); + + assert_ne!( + hash_no_merge, hash_with_merge, + "Merge paths should affect config hash" + ); + } + + #[test] + fn test_read_cache_corrupt_file_returns_none() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("routes.cache"); + std::fs::write(&path, "{not valid json").unwrap(); + assert!(read_cache(&path).is_none(), "corrupt cache must be a miss"); + } + + #[test] + fn test_read_cache_missing_file_returns_none() { + let dir = tempfile::TempDir::new().unwrap(); + assert!(read_cache(&dir.path().join("nope.cache")).is_none()); + } + + #[test] + fn test_old_format_cache_deserializes_with_format_zero() { + // A pre-sidecar cache (inline spec strings, no cache_format + // field) must still parse — with cache_format defaulting to 0 + // so the orchestrator's `== CACHE_FORMAT` check misses. + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("routes.cache"); + let old_format = serde_json::json!({ + "macro_version": "0.1.0", + "macro_dev_fingerprint": 1u64, + "file_fingerprints": {}, + "schema_hash": 2u64, + "config_hash": 3u64, + "metadata": { "routes": [], "structs": [] }, + "spec_json": "{\"openapi\":\"3.1.0\"}", + "spec_pretty": "{\n \"openapi\": \"3.1.0\"\n}" + }); + std::fs::write(&path, old_format.to_string()).unwrap(); + let cache = read_cache(&path).expect("old format must still deserialize"); + assert_eq!(cache.cache_format, 0, "missing field defaults to 0"); + assert_ne!(cache.cache_format, CACHE_FORMAT, "format check must miss"); + assert!(cache.spec_json_hash.is_none()); + assert!(cache.spec_pretty_hash.is_none()); + } + + #[test] + fn test_hash_str_deterministic_and_content_sensitive() { + assert_eq!(hash_str("abc"), hash_str("abc")); + assert_ne!(hash_str("abc"), hash_str("abd")); + } + + #[test] + fn sidecar_matches_accepts_matching_fingerprint_without_hash_miss() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("spec.json"); + std::fs::write(&path, "{\"openapi\":\"3.1.0\"}").unwrap(); + + let fingerprint = path_fingerprint(&path); + assert!(sidecar_matches(&path, Some(0), fingerprint)); + } + + #[test] + fn sidecar_matches_falls_back_to_hash_when_fingerprint_differs() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("spec.json"); + let content = "{\"openapi\":\"3.1.0\"}"; + std::fs::write(&path, content).unwrap(); + + assert!(sidecar_matches(&path, Some(hash_str(content)), Some(1))); + assert!(!sidecar_matches(&path, Some(hash_str("corrupt")), Some(1))); + } + + #[test] + fn export_config_hash_is_namespaced_by_app_and_folder() { + let base = compute_export_config_hash("ThirdApp", "routes"); + + assert_ne!(base, compute_export_config_hash("AdminApp", "routes")); + assert_ne!(base, compute_export_config_hash("ThirdApp", "api")); + } + + #[test] + fn security_scheme_field_changes_affect_config_hash() { + fn scheme(http_scheme: &str) -> SecurityScheme { + SecurityScheme { + r#type: SecuritySchemeType::Http, + description: Some("Auth".to_string()), + name: None, + r#in: None, + scheme: Some(http_scheme.to_string()), + bearer_format: Some("JWT".to_string()), + flows: None, + open_id_connect_url: None, + } + } + + let bearer = ProcessedVesperaInput { + security_schemes: Some(BTreeMap::from([( + "bearerAuth".to_string(), + scheme("bearer"), + )])), + ..base_processed() + }; + let basic = ProcessedVesperaInput { + security_schemes: Some(BTreeMap::from([( + "bearerAuth".to_string(), + scheme("basic"), + )])), + ..base_processed() + }; + + assert_ne!(compute_config_hash(&bearer), compute_config_hash(&basic)); + } + + #[test] + fn security_none_and_empty_some_have_distinct_config_hashes() { + let omitted = base_processed(); + let explicit_empty = ProcessedVesperaInput { + security: Some(Vec::new()), + ..base_processed() + }; + + assert_ne!( + compute_config_hash(&omitted), + compute_config_hash(&explicit_empty) + ); + } + + #[test] + fn server_description_changes_affect_config_hash() { + let production = ProcessedVesperaInput { + servers: Some(vec![vespera_core::openapi::Server { + url: "https://api.example.com".to_string(), + description: Some("Production".to_string()), + variables: None, + }]), + ..base_processed() + }; + let staging = ProcessedVesperaInput { + servers: Some(vec![vespera_core::openapi::Server { + url: "https://api.example.com".to_string(), + description: Some("Staging".to_string()), + variables: None, + }]), + ..base_processed() + }; + + assert_ne!( + compute_config_hash(&production), + compute_config_hash(&staging) + ); + } +} diff --git a/crates/vespera_macro/src/vespera_impl/openapi_io.rs b/crates/vespera_macro/src/vespera_impl/openapi_io.rs new file mode 100644 index 00000000..f57260ec --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/openapi_io.rs @@ -0,0 +1,741 @@ +use std::{collections::HashMap, path::Path}; + +use crate::{ + error::{MacroResult, err_call_site}, + metadata::CollectedMetadata, + openapi_generator::{OpenApiSecurity, try_generate_openapi_doc_with_metadata}, + route_impl::StoredRouteInfo, + router_codegen::ProcessedVesperaInput, +}; +use proc_macro2::Span; + +use super::{ + cache::{MergeSpecCache, MergeSpecRead, path_fingerprint}, + path_utils::{current_crate_tag, find_target_dir}, +}; + +/// OpenAPI write result consumed by router/doc codegen and incremental cache sidecars. +#[derive(Debug)] +#[allow(dead_code)] +pub struct OpenApiWriteResult { + pub docs_url: Option, + pub redoc_url: Option, + pub spec_json: Option, + pub spec_pretty: Option, +} + +pub(super) struct EmbeddedSpecWrite { + pub(super) tokens: proc_macro2::TokenStream, + pub(super) fingerprint: Option, +} + +/// Whether `path` already holds exactly `content`. +/// +/// A cheap `metadata().len()` pre-check skips the full `read_to_string` +/// whenever the byte length alone proves the content changed (the common +/// case when a regenerated spec differs) — only an exact length match +/// falls back to the full read + compare. Missing or unreadable files +/// count as "changed", so the caller writes — exactly like the previous +/// `read_to_string(...).map_or(true, |e| e != content)` this replaces. +pub(super) fn content_unchanged(path: &Path, content: &str) -> bool { + std::fs::metadata(path).is_ok_and(|m| m.len() == content.len() as u64) + && std::fs::read_to_string(path).is_ok_and(|existing| existing == content) +} + +pub(super) fn write_if_changed(path: &Path, content: &str) -> std::io::Result> { + if !content_unchanged(path, content) { + std::fs::write(path, content)?; + } + Ok(path_fingerprint(path)) +} + +/// Generate `OpenAPI` JSON and write to files, returning docs info +pub fn generate_and_write_openapi( + input: &ProcessedVesperaInput, + metadata: &CollectedMetadata, + file_asts: HashMap, + route_storage: &[StoredRouteInfo], + merge_specs: &mut MergeSpecCache, +) -> MacroResult { + if input.openapi_file_names.is_empty() && input.docs_url.is_none() && input.redoc_url.is_none() + { + return Ok(OpenApiWriteResult { + docs_url: None, + redoc_url: None, + spec_json: None, + spec_pretty: None, + }); + } + + let mut openapi_doc = try_generate_openapi_doc_with_metadata( + input.title.clone(), + input.version.clone(), + input.servers.clone(), + Some(OpenApiSecurity { + security_schemes: input.security_schemes.clone(), + security: input.security.clone(), + tag_descriptions: input.tag_descriptions.clone(), + }), + metadata, + Some(file_asts), + route_storage, + )?; + + // Merge specs from child apps at compile time + if !input.merge.is_empty() { + for merge_path in &input.merge { + // Extract the struct name (last segment, e.g., "ThirdApp" from "third::ThirdApp") + if let Some((struct_name, spec_file)) = merge_specs.spec_file_for(merge_path) { + let spec_content = match merge_specs.read(&spec_file) { + MergeSpecRead::Present(content) => content, + MergeSpecRead::Error(e) => { + return Err(err_call_site(format!( + "OpenAPI merge: failed to read child spec for `{struct_name}` at '{}'. Error: {e}. Ensure the child crate containing `export_app!({struct_name})` is built before the parent app.", + spec_file.display() + ))); + } + }; + let child_spec = serde_json::from_str::( + &spec_content, + ) + .map_err(|e| { + err_call_site(format!( + "OpenAPI merge: failed to parse child spec for `{struct_name}` at '{}'. Error: {e}.", + spec_file.display() + )) + })?; + openapi_doc.merge(child_spec); + } + } + } + + // NOTE on F-01: an earlier audit suggested serialising the + // `OpenApi` document once into `serde_json::Value` and emitting + // pretty + compact from the cached `Value`. We deliberately do + // **not** do that here. Going through `Value` re-orders every + // object's keys alphabetically (because the default + // `serde_json::Map` is `BTreeMap`-backed), which silently changes + // the field order in every user-visible `openapi.json` file. The + // marginal build-time saving is not worth churning the output of a + // file users diff in CI. Keep two direct serialisations. + // + // Pretty-print for user-visible files. + let spec_pretty = if input.openapi_file_names.is_empty() { + None + } else { + let json_pretty = serde_json::to_string_pretty(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?; + for openapi_file_name in &input.openapi_file_names { + let file_path = Path::new(openapi_file_name); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| err_call_site(format!("OpenAPI output: failed to create directory '{}'. Error: {}. Ensure the path is valid and writable.", parent.display(), e)))?; + } + let should_write = !content_unchanged(file_path, &json_pretty); + if should_write { + std::fs::write(file_path, &json_pretty).map_err(|e| err_call_site(format!("OpenAPI output: failed to write file '{openapi_file_name}'. Error: {e}. Ensure the file path is writable.")))?; + } + } + Some(json_pretty) + }; + + // Compact JSON for embedding (smaller binary, faster downstream compilation). + let spec_json = if input.docs_url.is_some() || input.redoc_url.is_some() { + Some(serde_json::to_string(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?) + } else { + None + }; + + Ok(OpenApiWriteResult { + docs_url: input.docs_url.clone(), + redoc_url: input.redoc_url.clone(), + spec_json, + spec_pretty, + }) +} + +/// Write cached OpenAPI spec to output files if they are stale or missing. +pub fn ensure_openapi_files_from_cache( + openapi_file_names: &[String], + spec_pretty: Option<&str>, +) -> syn::Result<()> { + let Some(pretty) = spec_pretty else { + return Ok(()); + }; + for openapi_file_name in openapi_file_names { + let file_path = Path::new(openapi_file_name); + let should_write = !content_unchanged(file_path, pretty); + if should_write { + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + syn::Error::new( + Span::call_site(), + format!( + "OpenAPI output: failed to create directory '{}': {}", + parent.display(), + e + ), + ) + })?; + } + std::fs::write(file_path, pretty).map_err(|e| { + syn::Error::new( + Span::call_site(), + format!("OpenAPI output: failed to write file '{openapi_file_name}': {e}"), + ) + })?; + } + } + Ok(()) +} + +/// Path of the compact-spec embed sidecar (`include_str!` target). +/// +/// The file name is **namespaced per crate**: two workspace members +/// both using `vespera!` compile in parallel under the same shared +/// `target/vespera/` directory — with a single shared file name, crate +/// A's `include_str!` could read the spec crate B just wrote. +pub(super) fn embed_spec_path() -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + find_target_dir(Path::new(&manifest_dir)) + .join("vespera") + .join(format!("vespera_spec-{}.json", current_crate_tag())) +} + +/// Path of the pretty-spec sidecar (warm-rebuild source for +/// `openapi.json` recovery — see `ensure_openapi_files_from_cache`). +pub(super) fn pretty_sidecar_path() -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + find_target_dir(Path::new(&manifest_dir)) + .join("vespera") + .join(format!("openapi_pretty-{}.json", current_crate_tag())) +} + +/// Build the `include_str!` tokens pointing at the embed sidecar. +fn embed_tokens(spec_file: &Path) -> proc_macro2::TokenStream { + let path_str = crate::file_utils::path_to_include_str_literal(spec_file); + quote::quote! { include_str!(#path_str) } +} + +/// Hash-validated sidecar specs loaded on a warm cache hit. +pub(super) struct SidecarSpecs { + /// Pretty spec content (for `openapi.json` recovery); `None` when + /// no openapi file is configured. + pub(super) pretty: Option, + /// `include_str!` tokens for the embed sidecar; `None` when docs + /// are disabled. + pub(super) spec_tokens: Option, +} + +/// Load and hash-validate the sidecar spec files on a warm cache hit. +/// +/// Returns `None` when any expected sidecar is missing or fails its +/// content-hash check — the caller must then treat the cache as a miss +/// (a full regeneration rewrites both sidecars, so corruption +/// self-heals on the next build). +pub(super) fn load_validated_sidecar_specs( + spec_json_hash: Option, + spec_pretty_hash: Option, + spec_json_fingerprint: Option, + spec_pretty_fingerprint: Option, +) -> Option { + let spec_tokens = match (spec_json_hash, spec_json_fingerprint) { + (None, _) => None, + (Some(expected_hash), Some(expected_fingerprint)) => { + let path = embed_spec_path(); + if !super::cache::sidecar_matches( + &path, + Some(expected_hash), + Some(expected_fingerprint), + ) { + return None; + } + Some(embed_tokens(&path)) + } + (Some(_), None) => return None, + }; + let pretty = match (spec_pretty_hash, spec_pretty_fingerprint) { + (None, _) => None, + (Some(expected_hash), Some(expected_fingerprint)) => { + let path = pretty_sidecar_path(); + let content = std::fs::read_to_string(&path).ok()?; + if super::cache::path_fingerprint(&path) != Some(expected_fingerprint) + && super::cache::hash_str(&content) != expected_hash + { + return None; + } + Some(content) + } + (Some(_), None) => return None, + }; + Some(SidecarSpecs { + pretty, + spec_tokens, + }) +} + +/// Write the pretty-spec sidecar (write-if-differs). Best-effort like +/// the cache itself: failures only cost a future cache miss. +pub(super) fn write_pretty_sidecar(spec_pretty: Option<&str>) -> Option { + let pretty = spec_pretty?; + let path = pretty_sidecar_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + write_if_changed(&path, pretty).ok().flatten() +} + +/// Write compact spec JSON to target dir for `include_str!` embedding. +pub(super) fn write_spec_for_embedding( + spec_json: Option, +) -> syn::Result> { + let Some(json) = spec_json else { + return Ok(None); + }; + let spec_file = embed_spec_path(); + if let Some(parent) = spec_file.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + syn::Error::new( + Span::call_site(), + format!( + "vespera! macro: failed to create directory '{}': {}", + parent.display(), + e + ), + ) + })?; + } + let fingerprint = write_if_changed(&spec_file, &json).map_err(|e| { + syn::Error::new( + Span::call_site(), + format!( + "vespera! macro: failed to write spec file '{}': {}", + spec_file.display(), + e + ), + ) + })?; + Ok(Some(EmbeddedSpecWrite { + tokens: embed_tokens(&spec_file), + fingerprint, + })) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_generate_and_write_openapi_no_output() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi( + &processed, + &metadata, + HashMap::new(), + &[], + &mut MergeSpecCache::new(), + ); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.docs_url.is_none()); + assert!(result.redoc_url.is_none()); + assert!(result.spec_json.is_none()); + } + + #[test] + fn test_generate_and_write_openapi_docs_only() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: Some("Test API".to_string()), + version: Some("1.0.0".to_string()), + docs_url: Some("/docs".to_string()), + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi( + &processed, + &metadata, + HashMap::new(), + &[], + &mut MergeSpecCache::new(), + ); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.docs_url.is_some()); + assert_eq!(result.docs_url.unwrap(), "/docs"); + assert!(result.spec_json.is_some()); + let json = result.spec_json.unwrap(); + assert!(json.contains("\"openapi\"")); + assert!(json.contains("Test API")); + assert!(result.redoc_url.is_none()); + } + + #[test] + fn test_generate_and_write_openapi_redoc_only() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: Some("/redoc".to_string()), + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi( + &processed, + &metadata, + HashMap::new(), + &[], + &mut MergeSpecCache::new(), + ); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.docs_url.is_none()); + assert!(result.redoc_url.is_some()); + assert_eq!(result.redoc_url.unwrap(), "/redoc"); + assert!(result.spec_json.is_some()); + } + + #[test] + fn test_generate_and_write_openapi_both_docs() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: Some("/docs".to_string()), + redoc_url: Some("/redoc".to_string()), + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi( + &processed, + &metadata, + HashMap::new(), + &[], + &mut MergeSpecCache::new(), + ); + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.docs_url.is_some()); + assert!(result.redoc_url.is_some()); + assert!(result.spec_json.is_some()); + } + + #[test] + fn test_generate_and_write_openapi_file_output() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("test-openapi.json"); + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![output_path.to_string_lossy().to_string()], + title: Some("File Test".to_string()), + version: Some("2.0.0".to_string()), + docs_url: None, + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi( + &processed, + &metadata, + HashMap::new(), + &[], + &mut MergeSpecCache::new(), + ); + assert!(result.is_ok()); + + // Verify file was written + assert!(output_path.exists()); + let content = fs::read_to_string(&output_path).unwrap(); + assert!(content.contains("\"openapi\"")); + assert!(content.contains("File Test")); + assert!(content.contains("2.0.0")); + } + + #[test] + fn test_generate_and_write_openapi_creates_directories() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("nested/dir/openapi.json"); + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![output_path.to_string_lossy().to_string()], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi( + &processed, + &metadata, + HashMap::new(), + &[], + &mut MergeSpecCache::new(), + ); + assert!(result.is_ok()); + + // Verify nested directories and file were created + assert!(output_path.exists()); + } + + #[serial_test::serial] + #[test] + fn test_generate_and_write_openapi_with_merge_no_manifest_dir() { + // When CARGO_MANIFEST_DIR is not set or merge is empty, it should work normally + let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: This serial test temporarily removes process environment to + // exercise the no-manifest fallback branch. + unsafe { std::env::remove_var("CARGO_MANIFEST_DIR") }; + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: Some("Test".to_string()), + version: None, + docs_url: Some("/docs".to_string()), + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![syn::parse_quote!(app::TestApp)], // Has merge but no valid manifest dir + }; + let metadata = CollectedMetadata::new(); + // This should still work - merge logic is skipped when CARGO_MANIFEST_DIR lookup fails + let result = generate_and_write_openapi( + &processed, + &metadata, + HashMap::new(), + &[], + &mut MergeSpecCache::new(), + ); + if let Some(value) = old_manifest_dir { + // SAFETY: This serial test restores the process environment it changed. + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", value) }; + } + assert!(result.is_ok()); + } + + #[serial_test::serial] + #[test] + fn test_generate_and_write_openapi_with_merge_and_valid_spec() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create the vespera directory with a spec file + let target_dir = temp_dir.path().join("target").join("vespera"); + fs::create_dir_all(&target_dir).expect("Failed to create target/vespera dir"); + + // Write a valid OpenAPI spec file + let spec_content = + r#"{"openapi":"3.1.0","info":{"title":"Child API","version":"1.0.0"},"paths":{}}"#; + fs::write(target_dir.join("ChildApp.openapi.json"), spec_content) + .expect("Failed to write spec file"); + + // Save and set CARGO_MANIFEST_DIR + let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: We're in a single-threaded test context + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: Some("Parent API".to_string()), + version: Some("2.0.0".to_string()), + docs_url: Some("/docs".to_string()), + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![syn::parse_quote!(child::ChildApp)], + }; + let metadata = CollectedMetadata::new(); + + let result = generate_and_write_openapi( + &processed, + &metadata, + HashMap::new(), + &[], + &mut MergeSpecCache::new(), + ); + + // Restore CARGO_MANIFEST_DIR + if let Some(old_value) = old_manifest_dir { + // SAFETY: We're in a single-threaded test context + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) }; + } + + assert!(result.is_ok()); + } + + #[test] + fn test_generate_and_write_openapi_file_write_error() { + // Line 95: fs::write failure when output path is a directory + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create a directory where the output file should be + let output_path = temp_dir.path().join("openapi.json"); + fs::create_dir(&output_path).expect("Failed to create directory"); + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![output_path.to_string_lossy().to_string()], + title: Some("Test API".to_string()), + version: Some("1.0.0".to_string()), + docs_url: None, + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + let metadata = CollectedMetadata::new(); + + let result = generate_and_write_openapi( + &processed, + &metadata, + HashMap::new(), + &[], + &mut MergeSpecCache::new(), + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("failed to write file")); + } + + #[test] + fn test_ensure_openapi_files_from_cache_none_spec() { + // Exercises lines 266-267: early return when spec_pretty is None + let result = ensure_openapi_files_from_cache(&["dummy.json".to_string()], None); + assert!(result.is_ok()); + } + + #[test] + fn test_ensure_openapi_files_from_cache_writes_file() { + // Exercises lines 269-276: write new file + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("api.json"); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = ensure_openapi_files_from_cache( + &[output_path.to_string_lossy().to_string()], + Some(spec), + ); + assert!(result.is_ok()); + assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); + } + + #[test] + fn test_ensure_openapi_files_from_cache_skip_unchanged() { + // Exercises line 271-272: should_write is false when content matches + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("api.json"); + let spec = r#"{"openapi":"3.1.0"}"#; + + // Write file first with same content + fs::write(&output_path, spec).unwrap(); + + let result = ensure_openapi_files_from_cache( + &[output_path.to_string_lossy().to_string()], + Some(spec), + ); + assert!(result.is_ok()); + // File should still contain same content (no unnecessary write) + assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); + } + + #[test] + fn test_ensure_openapi_files_from_cache_creates_parent_dirs() { + // Exercises lines 273-274: create parent directories + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("nested").join("dir").join("api.json"); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = ensure_openapi_files_from_cache( + &[output_path.to_string_lossy().to_string()], + Some(spec), + ); + assert!(result.is_ok()); + assert!(output_path.exists()); + assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); + } + + #[test] + fn test_ensure_openapi_files_from_cache_write_error() { + // Exercises line 276: write failure + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("api.json"); + + // Create a directory where the file should be -> write will fail + fs::create_dir(&output_path).unwrap(); + + let result = ensure_openapi_files_from_cache( + &[output_path.to_string_lossy().to_string()], + Some("spec"), + ); + assert!(result.is_err()); + } + + #[test] + fn test_ensure_openapi_files_from_cache_multiple_files() { + // Exercises the loop with multiple file names (line 269) + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let path1 = temp_dir.path().join("api1.json"); + let path2 = temp_dir.path().join("api2.json"); + let spec = r#"{"openapi":"3.1.0"}"#; + + let result = ensure_openapi_files_from_cache( + &[ + path1.to_string_lossy().to_string(), + path2.to_string_lossy().to_string(), + ], + Some(spec), + ); + assert!(result.is_ok()); + assert_eq!(fs::read_to_string(&path1).unwrap(), spec); + assert_eq!(fs::read_to_string(&path2).unwrap(), spec); + } +} diff --git a/crates/vespera_macro/src/vespera_impl/orchestrator.rs b/crates/vespera_macro/src/vespera_impl/orchestrator.rs new file mode 100644 index 00000000..b86bd0a2 --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/orchestrator.rs @@ -0,0 +1,395 @@ +use std::{collections::HashMap, path::Path}; + +use proc_macro2::Span; +use quote::quote; + +use crate::{ + metadata::StructMetadata, + route_impl::StoredRouteInfo, + router_codegen::{ProcessedVesperaInput, generate_router_code}, +}; + +use super::{ + cache::{ + CACHE_FORMAT, MergeSpecCache, VesperaCache, compute_config_hash_with_merge_cache, + compute_export_config_hash, compute_macro_dev_fingerprint, compute_schema_hash, + get_cache_path, get_export_cache_path, hash_str, read_cache, sidecar_matches, write_cache, + }, + openapi_io::{ + ensure_openapi_files_from_cache, generate_and_write_openapi, load_validated_sidecar_specs, + write_pretty_sidecar, write_spec_for_embedding, + }, + path_utils::{find_folder_path, find_target_dir}, + route_merge::merge_route_storage_data, +}; + +/// Process vespera macro - extracted for testability +#[allow(clippy::too_many_lines)] +pub fn process_vespera_macro( + processed: &ProcessedVesperaInput, + schema_storage: &HashMap, + route_storage: &[StoredRouteInfo], + folder_span: Span, +) -> syn::Result { + let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { + eprintln!( + "[vespera-profile] storage at expansion: {} routes, {} schemas", + route_storage.len(), + schema_storage.len() + ); + Some(std::time::Instant::now()) + } else { + None + }; + + // Stage timer for `VESPERA_PROFILE=1` — prints per-stage elapsed + // times so regressions can be attributed (scan vs openapi vs + // serialization vs codegen). + let mut stage_start = std::time::Instant::now(); + let mut stage = |name: &str| { + if profile_start.is_some() { + eprintln!("[vespera-profile] {name}: {:?}", stage_start.elapsed()); + stage_start = std::time::Instant::now(); + } + }; + + let folder_path = find_folder_path(&processed.folder_name)?; + if !folder_path.exists() { + return Err(syn::Error::new( + folder_span, + format!( + "vespera! macro: route folder '{}' not found. Create src/{} or specify a different folder with `dir = \"your_folder\"`.", + processed.folder_name, processed.folder_name + ), + )); + } + + // --- Incremental cache check --- + // One directory walk serves both the fingerprint map and (on a + // cache miss) route collection below. + let cache_path = get_cache_path(); + let scanned = crate::collector::scan_route_folder(&folder_path) + .map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: {e}")))?; + let fingerprints = crate::collector::fingerprints_from_scan(&scanned); + let schema_hash = compute_schema_hash(schema_storage); + let mut merge_specs = MergeSpecCache::new(); + let config_hash = compute_config_hash_with_merge_cache(processed, &mut merge_specs); + stage("fingerprints + hashes"); + + let macro_version = env!("CARGO_PKG_VERSION").to_string(); + let macro_dev_fingerprint = compute_macro_dev_fingerprint(); + stage("macro_dev_fingerprint"); + let cached = read_cache(&cache_path); + stage("read_cache"); + let cache_hit = cached.as_ref().is_some_and(|c| { + c.cache_format == CACHE_FORMAT + && c.macro_version == macro_version + && c.macro_dev_fingerprint == macro_dev_fingerprint + && c.file_fingerprints == fingerprints + && c.schema_hash == schema_hash + && c.config_hash == config_hash + }); + // Hash-validate the sidecar spec files (the cache only stores + // hashes — content lives in `target/vespera/`). Validation + // failure downgrades to a full regeneration, which rewrites the + // sidecars: corruption self-heals on the next build. + let sidecars = if cache_hit { + let c = cached.as_ref().unwrap(); + load_validated_sidecar_specs( + c.spec_json_hash, + c.spec_pretty_hash, + c.spec_json_fingerprint, + c.spec_pretty_fingerprint, + ) + } else { + None + }; + stage("validate_sidecar_specs"); + + let (metadata, spec_tokens) = if let Some(sidecars) = sidecars { + let cache = cached.unwrap(); + let mut metadata = cache.metadata; + metadata.structs.extend(schema_storage.values().cloned()); + merge_route_storage_data(&mut metadata, route_storage); + metadata + .check_duplicate_schema_names() + .map_err(|msg| syn::Error::new(Span::call_site(), format!("vespera! macro: {msg}")))?; + stage("cache_branch_metadata_merge"); + + // Ensure openapi.json files exist and are up-to-date from cache + ensure_openapi_files_from_cache(&processed.openapi_file_names, sidecars.pretty.as_deref())?; + stage("ensure_openapi_files_from_cache"); + + (metadata, sidecars.spec_tokens) + } else { + // Borrow the pre-scanned `(path, mtime)` pairs as `&Path` — no + // PathBuf clone of the whole file list per cache-miss expansion. + let (mut metadata, file_asts) = crate::collector::collect_metadata_from_files(scanned.iter().map(|(path, _)| path.as_path()), &folder_path, &processed.folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: failed to scan route folder '{}'. Error: {}. Check that all .rs files have valid Rust syntax.", processed.folder_name, e)))?; + stage("collect_metadata"); + + // Clone metadata before extending (cache stores file-only structs) + let cache_metadata = metadata.clone(); + metadata.structs.extend(schema_storage.values().cloned()); + merge_route_storage_data(&mut metadata, route_storage); + metadata + .check_duplicate_schema_names() + .map_err(|msg| syn::Error::new(Span::call_site(), format!("vespera! macro: {msg}")))?; + stage("metadata merge"); + + // B2: reject same-file extractor structs that lack `#[derive(Schema)]` + // before they silently vanish from the generated spec. Runs only here + // (cache miss) — a cache hit is byte-identical source that already + // passed, so the check would be redundant. + crate::parser::validate_schema_backed_extractors_with_cache(&metadata, &file_asts)?; + stage("validate_schema_backed_extractors"); + + let openapi = generate_and_write_openapi( + processed, + &metadata, + file_asts, + route_storage, + &mut merge_specs, + )?; + stage("generate_and_write_openapi"); + + let spec_json_hash = openapi.spec_json.as_deref().map(hash_str); + let spec_pretty_hash = openapi.spec_pretty.as_deref().map(hash_str); + let spec_pretty_fingerprint = write_pretty_sidecar(openapi.spec_pretty.as_deref()); + let embed_spec = write_spec_for_embedding(openapi.spec_json)?; + let (spec_tokens, spec_json_fingerprint) = + embed_spec.map_or((None, None), |spec| (Some(spec.tokens), spec.fingerprint)); + stage("write_spec_for_embedding"); + + // Persist cache (best-effort, failures are silent) — spec + // contents live in the sidecar files; only hashes are cached. + write_cache( + &cache_path, + &VesperaCache { + cache_format: CACHE_FORMAT, + macro_version: macro_version.clone(), + macro_dev_fingerprint, + file_fingerprints: fingerprints, + schema_hash, + config_hash, + metadata: cache_metadata, + spec_json_hash, + spec_pretty_hash, + spec_json_fingerprint: spec_json_hash.and(spec_json_fingerprint), + spec_pretty_fingerprint: spec_pretty_hash.and(spec_pretty_fingerprint), + }, + ); + stage("write_cache"); + + (metadata, spec_tokens) + }; + + // --- Cron job discovery from CRON_STORAGE --- + // #[cron("...")] attribute already registers metadata at expansion time. + // No folder scanning needed — just read the storage. + let cron_jobs: Vec = { + // Per-crate snapshot (see `cron_impl::current_crate_crons`): in a + // shared rust-analyzer proc-macro server this never picks up another + // crate's `#[cron]` jobs. + let storage = crate::cron_impl::current_crate_crons(); + let src_dir = std::env::var("CARGO_MANIFEST_DIR") + .map(|d| { + let p = std::path::PathBuf::from(d).join("src"); + // Canonicalize for reliable prefix stripping + let canonical = p.canonicalize().unwrap_or(p); + crate::file_utils::normalize_display_path(canonical) + }) + .unwrap_or_default(); + storage + .iter() + .map(|s| { + // Derive module path from file_path relative to src/ + let module_path = s + .file_path + .as_ref() + .map(|fp| { + let canonical = std::path::Path::new(fp) + .canonicalize() + .map_or_else(|_| fp.clone(), |p| p.display().to_string()); + let normalized = canonical.replace('\\', "/"); + let relative = normalized + .strip_prefix(&src_dir) + .map_or(&*normalized, |rest| rest.trim_start_matches('/')); + // Convert path to module path: strip .rs, replace / with ::, strip mod + // Replace hyphens with underscores (Rust module convention) + relative + .trim_end_matches(".rs") + .replace('/', "::") + .replace('-', "_") + .trim_end_matches("::mod") + .to_string() + }) + .unwrap_or_default(); + crate::metadata::CronMetadata { + expression: s.expression.clone(), + function_name: s.fn_name.clone(), + module_path, + file_path: s.file_path.clone().unwrap_or_default(), + } + }) + .collect() + }; + + let result = Ok(generate_router_code( + &metadata, + processed.docs_url.as_deref(), + processed.redoc_url.as_deref(), + spec_tokens, + &processed.merge, + &cron_jobs, + )); + stage("generate_router_code"); + + if let Some(start) = profile_start { + eprintln!( + "[vespera-profile] vespera! macro total: {:?}", + start.elapsed() + ); + crate::schema_macro::print_profile_summary(); + } + + result +} + +/// Process `export_app` macro - extracted for testability +#[allow(clippy::too_many_lines)] +pub fn process_export_app( + name: &syn::Ident, + folder_name: &str, + schema_storage: &HashMap, + manifest_dir: &str, + route_storage: &[StoredRouteInfo], + folder_span: Span, +) -> syn::Result { + let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { + Some(std::time::Instant::now()) + } else { + None + }; + + let folder_path = find_folder_path(folder_name)?; + if !folder_path.exists() { + return Err(syn::Error::new( + folder_span, + format!( + "export_app! macro: route folder '{folder_name}' not found. Create src/{folder_name} or specify a different folder with `dir = \"your_folder\"`.", + ), + )); + } + + let app_name = name.to_string(); + let manifest_path = Path::new(manifest_dir); + let target_dir = find_target_dir(manifest_path); + let vespera_dir = target_dir.join("vespera"); + let spec_file = vespera_dir.join(format!("{app_name}.openapi.json")); + let cache_path = get_export_cache_path(&app_name, folder_name); + let scanned = crate::collector::scan_route_folder(&folder_path) + .map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: {e}")))?; + let fingerprints = crate::collector::fingerprints_from_scan(&scanned); + let schema_hash = compute_schema_hash(schema_storage); + let config_hash = compute_export_config_hash(&app_name, folder_name); + let macro_version = env!("CARGO_PKG_VERSION").to_string(); + let macro_dev_fingerprint = compute_macro_dev_fingerprint(); + let cached = read_cache(&cache_path); + let cache_hit = cached.as_ref().is_some_and(|c| { + c.cache_format == CACHE_FORMAT + && c.macro_version == macro_version + && c.macro_dev_fingerprint == macro_dev_fingerprint + && c.file_fingerprints == fingerprints + && c.schema_hash == schema_hash + && c.config_hash == config_hash + && sidecar_matches(&spec_file, c.spec_json_hash, c.spec_json_fingerprint) + }); + + let mut metadata = if let (true, Some(cache)) = (cache_hit, cached) { + cache.metadata + } else { + let (mut metadata, file_asts) = crate::collector::collect_metadata_from_files(scanned.iter().map(|(path, _)| path.as_path()), &folder_path, folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to scan route folder '{folder_name}'. Error: {e}. Check that all .rs files have valid Rust syntax.")))?; + let cache_metadata = metadata.clone(); + metadata.structs.extend(schema_storage.values().cloned()); + merge_route_storage_data(&mut metadata, route_storage); + metadata.check_duplicate_schema_names().map_err(|msg| { + syn::Error::new(Span::call_site(), format!("export_app! macro: {msg}")) + })?; + + // B2: same-file extractor structs without `#[derive(Schema)]` would be + // silently dropped from the spec — reject them at compile time. + crate::parser::validate_schema_backed_extractors_with_cache(&metadata, &file_asts)?; + + // Generate OpenAPI spec JSON string + let openapi_doc = crate::openapi_generator::try_generate_openapi_doc_with_metadata( + None, + None, + None, + None, + &metadata, + Some(file_asts), + route_storage, + )?; + let spec_json = serde_json::to_string(&openapi_doc).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to serialize OpenAPI spec to JSON. Error: {e}. Check that all schema types are serializable.")))?; + + // Write spec to temp file for compile-time merging by parent apps + std::fs::create_dir_all(&vespera_dir).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to create build cache directory '{}'. Error: {}. Ensure the target directory is writable.", vespera_dir.display(), e)))?; + let spec_json_fingerprint = super::openapi_io::write_if_changed(&spec_file, &spec_json).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to write OpenAPI spec file '{}'. Error: {}. Ensure the file path is writable.", spec_file.display(), e)))?; + let spec_json_hash = Some(hash_str(&spec_json)); + write_cache( + &cache_path, + &VesperaCache { + cache_format: CACHE_FORMAT, + macro_version: macro_version.clone(), + macro_dev_fingerprint, + file_fingerprints: fingerprints, + schema_hash, + config_hash, + metadata: cache_metadata.clone(), + spec_json_hash, + spec_pretty_hash: None, + spec_json_fingerprint: spec_json_hash.and(spec_json_fingerprint), + spec_pretty_fingerprint: None, + }, + ); + cache_metadata + }; + metadata.structs.extend(schema_storage.values().cloned()); + merge_route_storage_data(&mut metadata, route_storage); + metadata + .check_duplicate_schema_names() + .map_err(|msg| syn::Error::new(Span::call_site(), format!("export_app! macro: {msg}")))?; + let spec_path_str = crate::file_utils::path_to_include_str_literal(&spec_file); + + // Generate router code (without docs routes, no merge) + let router_code = generate_router_code(&metadata, None, None, None, &[], &[]); + + let result = Ok(quote! { + /// Auto-generated vespera app struct + pub struct #name; + + impl #name { + /// OpenAPI specification as JSON string + pub const OPENAPI_SPEC: &'static str = include_str!(#spec_path_str); + + /// Create the router for this app. + /// Returns `Router<()>` which can be merged into any other router. + pub fn router() -> vespera::axum::Router<()> { + #router_code + } + } + }); + + if let Some(start) = profile_start { + eprintln!( + "[vespera-profile] export_app! macro total: {:?}", + start.elapsed() + ); + crate::schema_macro::print_profile_summary(); + } + + result +} + +#[cfg(test)] +mod tests; diff --git a/crates/vespera_macro/src/vespera_impl/orchestrator/tests.rs b/crates/vespera_macro/src/vespera_impl/orchestrator/tests.rs new file mode 100644 index 00000000..37ba3d79 --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/orchestrator/tests.rs @@ -0,0 +1,492 @@ +use std::fs; + +use tempfile::TempDir; + +use super::*; + +fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = dir.path().join(filename); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path +} + +// ========== Tests for process_vespera_macro ========== + +#[test] +fn test_process_vespera_macro_folder_not_found() { + let processed = ProcessedVesperaInput { + folder_name: "nonexistent_folder_xyz_123".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + let result = process_vespera_macro(&processed, &HashMap::new(), &[], Span::call_site()); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("route folder") && err.contains("not found")); +} + +#[test] +fn test_process_vespera_macro_collect_metadata_error() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an invalid route file (will cause parse error but collect_metadata handles it) + create_temp_file(&temp_dir, "invalid.rs", "not valid rust code {{{"); + + let processed = ProcessedVesperaInput { + folder_name: temp_dir.path().to_string_lossy().to_string(), + openapi_file_names: vec![], + title: Some("Test API".to_string()), + version: Some("1.0.0".to_string()), + docs_url: None, + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + + // This exercises the collect_metadata path (which handles parse errors gracefully) + let result = process_vespera_macro(&processed, &HashMap::new(), &[], Span::call_site()); + // Result may succeed or fail depending on how collect_metadata handles invalid files + let _ = result; +} + +#[test] +fn test_process_vespera_macro_with_schema_storage() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an empty file (valid but no routes) + create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); + + let schema_storage = HashMap::from([( + "TestSchema".to_string(), + StructMetadata::new( + "TestSchema".to_string(), + "struct TestSchema { id: i32 }".to_string(), + ), + )]); + + let processed = ProcessedVesperaInput { + folder_name: temp_dir.path().to_string_lossy().to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: Some("/docs".to_string()), + redoc_url: Some("/redoc".to_string()), + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + + // This exercises the schema_storage extend path + let result = process_vespera_macro(&processed, &schema_storage, &[], Span::call_site()); + // We only care about exercising the code path + let _ = result; +} + +#[test] +#[serial_test::serial] +fn test_process_vespera_macro_with_cron_storage() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create src/ subfolder structure to simulate a real project + let src_dir = temp_dir.path().join("src"); + std::fs::create_dir_all(src_dir.join("routes")).expect("create routes dir"); + std::fs::write(src_dir.join("routes").join("health.rs"), "// empty\n") + .expect("write health.rs"); + + // Set CARGO_MANIFEST_DIR so module path derivation works + let old_manifest = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { + std::env::set_var( + "CARGO_MANIFEST_DIR", + temp_dir.path().to_string_lossy().as_ref(), + ); + } + + // Populate CRON_STORAGE with a fake cron entry under the CURRENT crate + // key (CARGO_MANIFEST_DIR was set to temp_dir above, so this lands in the + // same bucket `process_vespera_macro` reads back via `current_crate_crons`). + crate::cron_impl::register_cron(crate::cron_impl::StoredCronInfo { + fn_name: "test_cron_job".to_string(), + expression: "0 */5 * * * *".to_string(), + file_path: Some( + src_dir + .join("routes") + .join("health.rs") + .display() + .to_string(), + ), + }); + + let processed = ProcessedVesperaInput { + folder_name: src_dir.join("routes").to_string_lossy().to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + + // This exercises the CRON_STORAGE → CronMetadata derivation path + let result = process_vespera_macro(&processed, &HashMap::new(), &[], Span::call_site()); + assert!( + result.is_ok(), + "Should succeed with cron storage: {result:?}" + ); + + // Clean up: drop this crate-key's cron bucket (temp_dir key is unique to + // this test, and CARGO_MANIFEST_DIR is still temp_dir at this point). + { + let mut storage = crate::cron_impl::CRON_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + storage.remove(&crate::schema_impl::current_crate_key()); + } + + // Restore CARGO_MANIFEST_DIR + unsafe { + if let Some(val) = old_manifest { + std::env::set_var("CARGO_MANIFEST_DIR", val); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } +} + +// ========== Tests for process_export_app ========== + +#[test] +fn test_process_export_app_folder_not_found() { + let name: syn::Ident = syn::parse_quote!(TestApp); + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let result = process_export_app( + &name, + "nonexistent_folder_xyz", + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + Span::call_site(), + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("route folder") && err.contains("not found")); +} + +#[test] +fn test_process_export_app_with_empty_folder() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an empty file + create_temp_file(&temp_dir, "empty.rs", "// empty\n"); + + let name: syn::Ident = syn::parse_quote!(TestApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + // This exercises collect_metadata and other paths + let result = process_export_app( + &name, + &folder_path, + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + Span::call_site(), + ); + // We only care about exercising the code path + let _ = result; +} + +#[test] +fn test_process_export_app_with_schema_storage() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an empty but valid Rust file + create_temp_file(&temp_dir, "mod.rs", "// module file\n"); + + let schema_storage = HashMap::from([( + "AppSchema".to_string(), + StructMetadata::new( + "AppSchema".to_string(), + "struct AppSchema { name: String }".to_string(), + ), + )]); + + let name: syn::Ident = syn::parse_quote!(MyExportedApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + let result = process_export_app( + &name, + &folder_path, + &schema_storage, + &temp_dir.path().to_string_lossy(), + &[], + Span::call_site(), + ); + // Exercises the schema_storage.extend path + let _ = result; +} + +#[test] +fn test_process_export_app_collect_metadata_error() { + // Lines 210-212: collect_metadata returns error for invalid Rust syntax + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create a file with invalid Rust syntax that will cause parse error + create_temp_file(&temp_dir, "invalid.rs", "fn broken( { syntax error"); + + let name: syn::Ident = syn::parse_quote!(TestApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + let result = process_export_app( + &name, + &folder_path, + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + Span::call_site(), + ); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("failed to scan route folder")); +} + +#[test] +fn test_process_export_app_create_dir_error() { + // Lines 232-234: create_dir_all failure when path contains a file + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an empty valid Rust file + create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); + + // Create target directory but make 'vespera' a file instead of directory + let target_dir = temp_dir.path().join("target"); + fs::create_dir(&target_dir).expect("Failed to create target dir"); + fs::write(target_dir.join("vespera"), "blocking file").expect("Failed to write file"); + + let name: syn::Ident = syn::parse_quote!(TestApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + let result = process_export_app( + &name, + &folder_path, + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + Span::call_site(), + ); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("failed to create build cache directory")); +} + +#[test] +fn test_process_export_app_write_spec_error() { + // Lines 239-241: fs::write failure when spec file path is a directory + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create an empty valid Rust file + create_temp_file(&temp_dir, "empty.rs", "// empty file\n"); + + // Create target/vespera directory and make spec file name a directory + let vespera_dir = temp_dir.path().join("target").join("vespera"); + fs::create_dir_all(&vespera_dir).expect("Failed to create vespera dir"); + // Create a directory where the spec file should be written + fs::create_dir(vespera_dir.join("TestApp.openapi.json")) + .expect("Failed to create blocking dir"); + + let name: syn::Ident = syn::parse_quote!(TestApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + let result = process_export_app( + &name, + &folder_path, + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + Span::call_site(), + ); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("failed to write OpenAPI spec file")); +} +#[test] +fn test_process_vespera_macro_no_openapi_output() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_temp_file(&temp_dir, "empty.rs", "// empty route file\n"); + + let processed = ProcessedVesperaInput { + folder_name: temp_dir.path().to_string_lossy().to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + + let result = process_vespera_macro(&processed, &HashMap::new(), &[], Span::call_site()); + assert!( + result.is_ok(), + "Should succeed with no openapi output configured" + ); +} + +#[test] +#[serial_test::serial] +fn test_process_vespera_macro_with_profiling() { + let old_profile = std::env::var("VESPERA_PROFILE").ok(); + unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_temp_file(&temp_dir, "empty.rs", "// empty\n"); + + let processed = ProcessedVesperaInput { + folder_name: temp_dir.path().to_string_lossy().to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + + let result = process_vespera_macro(&processed, &HashMap::new(), &[], Span::call_site()); + + // Restore + unsafe { + if let Some(val) = old_profile { + std::env::set_var("VESPERA_PROFILE", val); + } else { + std::env::remove_var("VESPERA_PROFILE"); + } + }; + + assert!(result.is_ok()); +} + +#[test] +#[serial_test::serial] +fn test_process_export_app_with_profiling() { + let old_profile = std::env::var("VESPERA_PROFILE").ok(); + unsafe { std::env::set_var("VESPERA_PROFILE", "1") }; + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_temp_file(&temp_dir, "empty.rs", "// empty\n"); + + let name: syn::Ident = syn::parse_quote!(TestProfileApp); + let folder_path = temp_dir.path().to_string_lossy().to_string(); + + let result = process_export_app( + &name, + &folder_path, + &HashMap::new(), + &temp_dir.path().to_string_lossy(), + &[], + Span::call_site(), + ); + + // Restore + unsafe { + if let Some(val) = old_profile { + std::env::set_var("VESPERA_PROFILE", val); + } else { + std::env::remove_var("VESPERA_PROFILE"); + } + }; + + // Exercise the code path + let _ = result; +} + +#[test] +#[serial_test::serial] +fn test_process_vespera_macro_cache_hit() { + // Exercises lines 320-324, 327, 329: the cache_hit branch in process_vespera_macro. + // First call populates the cache, second call hits it. + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + create_temp_file( + &temp_dir, + "users.rs", + "pub async fn list_users() -> String { \"users\".to_string() }\n", + ); + + let folder_path = temp_dir.path().to_string_lossy().to_string(); + let openapi_path = temp_dir.path().join("openapi.json"); + + // Set CARGO_MANIFEST_DIR so cache path resolves to temp_dir/target/vespera/ + let old_manifest = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + let processed = ProcessedVesperaInput { + folder_name: folder_path.clone(), + openapi_file_names: vec![openapi_path.to_string_lossy().to_string()], + title: Some("Test API".to_string()), + version: Some("1.0.0".to_string()), + docs_url: Some("/docs".to_string()), + redoc_url: None, + servers: None, + security_schemes: None, + security: None, + tag_descriptions: None, + merge: vec![], + }; + + // First call: cache MISS — scans files, generates spec, writes cache + let result1 = process_vespera_macro(&processed, &HashMap::new(), &[], Span::call_site()); + assert!( + result1.is_ok(), + "First call (cache miss) should succeed: {:?}", + result1.err() + ); + assert!( + openapi_path.exists(), + "openapi.json should be written on first call" + ); + + // Second call: cache HIT — exercises lines 320-324, 327, 329 + let result2 = process_vespera_macro(&processed, &HashMap::new(), &[], Span::call_site()); + assert!( + result2.is_ok(), + "Second call (cache hit) should succeed: {:?}", + result2.err() + ); + + // Restore CARGO_MANIFEST_DIR + unsafe { + if let Some(val) = old_manifest { + std::env::set_var("CARGO_MANIFEST_DIR", val); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + }; +} diff --git a/crates/vespera_macro/src/vespera_impl/path_utils.rs b/crates/vespera_macro/src/vespera_impl/path_utils.rs new file mode 100644 index 00000000..6f104fe0 --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/path_utils.rs @@ -0,0 +1,241 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::error::{MacroResult, err_call_site}; + +/// Name of the crate currently being expanded, for namespacing files +/// under the (workspace-shared) `target/vespera/` directory. Two +/// workspace members both using `vespera!` would otherwise overwrite +/// each other's cache (permanent miss ping-pong) and — worse — race on +/// the shared spec file that the generated code `include_str!`s. +pub(super) fn current_crate_tag() -> String { + std::env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "default".to_string()) +} + +/// Find the folder path for route scanning +pub fn find_folder_path(folder_name: &str) -> MacroResult { + let root = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| { + err_call_site( + "CARGO_MANIFEST_DIR is not set. vespera macros must be used within a cargo build.", + ) + })?; + let path = format!("{root}/src/{folder_name}"); + let path = Path::new(&path); + if path.exists() && path.is_dir() { + return Ok(path.to_path_buf()); + } + + Ok(Path::new(folder_name).to_path_buf()) +} + +thread_local! { + /// Resolved target dirs keyed by the manifest path that started the + /// walk. The workspace layout is fixed within a build (and across + /// invocations in a long-lived proc-macro server), so a target dir + /// resolved once stays valid — this avoids re-walking ancestors and + /// re-reading each `Cargo.toml` on every `vespera!` / sidecar-path + /// call. Mirrors `file_cache`'s process-lifetime `manifest_dir` cache. + static TARGET_DIR_CACHE: RefCell> = + RefCell::new(HashMap::new()); +} + +/// Find the workspace root's target directory (cached per manifest path). +pub fn find_target_dir(manifest_path: &Path) -> PathBuf { + if let Some(cached) = TARGET_DIR_CACHE.with(|c| c.borrow().get(manifest_path).cloned()) { + return cached; + } + let resolved = find_target_dir_uncached(manifest_path); + TARGET_DIR_CACHE.with(|c| { + c.borrow_mut() + .insert(manifest_path.to_path_buf(), resolved.clone()); + }); + resolved +} + +fn find_target_dir_uncached(manifest_path: &Path) -> PathBuf { + // Look for workspace root by finding a Cargo.toml with [workspace] section + let mut current = Some(manifest_path); + let mut last_with_lock = None; + + while let Some(dir) = current { + // Check if this directory has Cargo.lock + if dir.join("Cargo.lock").exists() { + last_with_lock = Some(dir.to_path_buf()); + } + + // Check if this is a workspace root (has Cargo.toml with [workspace]). + // `read_to_string` already fails when the file does not exist, so the + // previous `.exists()` pre-flight is redundant — drop it to save one + // stat per iteration of the walk. + if let Ok(contents) = std::fs::read_to_string(dir.join("Cargo.toml")) + && contents.contains("[workspace]") + { + return dir.join("target"); + } + + current = dir.parent(); + } + + // If we found a Cargo.lock but no [workspace], use the topmost one + if let Some(lock_dir) = last_with_lock { + return lock_dir.join("target"); + } + + // Fallback: use manifest dir's target + manifest_path.join("target") +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_find_folder_path_nonexistent_returns_path() { + // When the constructed path doesn't exist, it falls back to using folder_name directly + let result = find_folder_path("nonexistent_folder_xyz").unwrap(); + // It should return a PathBuf (either from src/nonexistent... or just the folder name) + assert!(result.to_string_lossy().contains("nonexistent_folder_xyz")); + } + + // ========== Tests for find_target_dir ========== + + #[test] + fn test_find_target_dir_no_workspace() { + // Test fallback to manifest dir's target + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let manifest_path = temp_dir.path(); + let result = find_target_dir(manifest_path); + assert_eq!(result, manifest_path.join("target")); + } + + #[test] + fn test_find_target_dir_with_cargo_lock() { + // Test finding target dir with Cargo.lock present + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let manifest_path = temp_dir.path(); + + // Create Cargo.lock (but no [workspace] in Cargo.toml) + fs::write(manifest_path.join("Cargo.lock"), "").expect("Failed to write Cargo.lock"); + + let result = find_target_dir(manifest_path); + // Should use the directory with Cargo.lock + assert_eq!(result, manifest_path.join("target")); + } + + #[test] + fn test_find_target_dir_with_workspace() { + // Test finding workspace root + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let workspace_root = temp_dir.path(); + + // Create a workspace Cargo.toml + fs::write( + workspace_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crate1\"]", + ) + .expect("Failed to write Cargo.toml"); + + // Create nested crate directory + let crate_dir = workspace_root.join("crate1"); + fs::create_dir(&crate_dir).expect("Failed to create crate dir"); + fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"crate1\"") + .expect("Failed to write Cargo.toml"); + + let result = find_target_dir(&crate_dir); + // Should return workspace root's target + assert_eq!(result, workspace_root.join("target")); + } + + #[test] + fn test_find_target_dir_workspace_with_cargo_lock() { + // Test that [workspace] takes priority over Cargo.lock + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let workspace_root = temp_dir.path(); + + // Create workspace Cargo.toml and Cargo.lock + fs::write( + workspace_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crate1\"]", + ) + .expect("Failed to write Cargo.toml"); + fs::write(workspace_root.join("Cargo.lock"), "").expect("Failed to write Cargo.lock"); + + // Create nested crate + let crate_dir = workspace_root.join("crate1"); + fs::create_dir(&crate_dir).expect("Failed to create crate dir"); + fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"crate1\"") + .expect("Failed to write Cargo.toml"); + + let result = find_target_dir(&crate_dir); + assert_eq!(result, workspace_root.join("target")); + } + + #[test] + fn test_find_target_dir_deeply_nested() { + // Test deeply nested crate structure + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let workspace_root = temp_dir.path(); + + // Create workspace + fs::write( + workspace_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]", + ) + .expect("Failed to write Cargo.toml"); + + // Create deeply nested crate + let deep_crate = workspace_root.join("crates/group/my-crate"); + fs::create_dir_all(&deep_crate).expect("Failed to create nested dirs"); + fs::write(deep_crate.join("Cargo.toml"), "[package]").expect("Failed to write Cargo.toml"); + + let result = find_target_dir(&deep_crate); + assert_eq!(result, workspace_root.join("target")); + } + + #[test] + fn test_find_folder_path_absolute_path() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let absolute_path = temp_dir.path().to_string_lossy().to_string(); + + // When given an absolute path that exists, it should return it + let result = find_folder_path(&absolute_path).unwrap(); + // The function tries src/{folder_name} first, then falls back to the folder_name directly + assert!( + result.to_string_lossy().contains(&absolute_path) + || result == Path::new(&absolute_path) + ); + } + + #[serial_test::serial] + #[test] + fn test_find_folder_path_with_src_folder() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create src/routes directory + let src_routes = temp_dir.path().join("src").join("routes"); + fs::create_dir_all(&src_routes).expect("Failed to create src/routes dir"); + + // Save and set CARGO_MANIFEST_DIR + let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok(); + // SAFETY: We're in a single-threaded test context + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + + let result = find_folder_path("routes").unwrap(); + + // Restore CARGO_MANIFEST_DIR + if let Some(old_value) = old_manifest_dir { + // SAFETY: We're in a single-threaded test context + unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) }; + } + + // Should return the src/routes path since it exists + assert!( + result.to_string_lossy().contains("src") && result.to_string_lossy().contains("routes") + ); + } +} diff --git a/crates/vespera_macro/src/vespera_impl/route_merge.rs b/crates/vespera_macro/src/vespera_impl/route_merge.rs new file mode 100644 index 00000000..8a838ce8 --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/route_merge.rs @@ -0,0 +1,500 @@ +use std::collections::HashMap; + +use crate::{ + collector::normalize_path_key, metadata::CollectedMetadata, route_impl::StoredRouteInfo, +}; + +/// Supplement collector's `RouteMetadata` with data from `ROUTE_STORAGE`. +/// +/// `#[route]` stores metadata at attribute expansion time. +/// `collector.rs` re-parses the same data from file ASTs. +/// This function merges ROUTE_STORAGE data into collector's output, +/// preferring ROUTE_STORAGE values when they provide richer info. +/// +/// Matching is by normalized `(file_path, function_name)`. Legacy storage entries +/// without a file path only match when their function name is unambiguous. +pub(super) fn merge_route_storage_data( + metadata: &mut CollectedMetadata, + route_storage: &[StoredRouteInfo], +) { + if route_storage.is_empty() { + return; + } + + let cwd = std::env::current_dir().unwrap_or_default(); + let mut stored_by_path: HashMap<(String, &str), &StoredRouteInfo> = + HashMap::with_capacity(route_storage.len()); + let mut fallback_by_name: HashMap<&str, Option<&StoredRouteInfo>> = + HashMap::with_capacity(route_storage.len()); + for stored in route_storage { + if let Some(file_path) = &stored.file_path { + stored_by_path.insert( + (normalize_path_key(file_path, &cwd), stored.fn_name.as_str()), + stored, + ); + } + fallback_by_name + .entry(stored.fn_name.as_str()) + .and_modify(|slot| *slot = None) + .or_insert(Some(stored)); + } + + for route in &mut metadata.routes { + let route_key = ( + normalize_path_key(&route.file_path, &cwd), + route.function_name.as_str(), + ); + let stored = stored_by_path.get(&route_key).copied().or_else(|| { + fallback_by_name + .get(route.function_name.as_str()) + .copied() + .flatten() + }); + + let Some(stored) = stored else { + continue; + }; + + apply_stored_route(route, stored); + } +} + +fn apply_stored_route(route: &mut crate::metadata::RouteMetadata, stored: &StoredRouteInfo) { + // Supplement with ROUTE_STORAGE data — only override when an explicit value is present. + if let Some(ref tags) = stored.tags { + route.tags = Some(tags.clone()); + } + if let Some(ref security) = stored.security { + route.security = Some(security.clone()); + } + if let Some(ref operation_id) = stored.operation_id { + route.operation_id = Some(operation_id.clone()); + } + if let Some(ref summary) = stored.summary { + route.summary = Some(summary.clone()); + } + if stored.deprecated { + route.deprecated = true; + } + if let Some(ref desc) = stored.description { + route.description = Some(desc.clone()); + } + if let Some(status) = stored.success_status { + route.success_status = Some(status); + } + if let Some(ref status) = stored.error_status { + route.error_status = Some(status.clone()); + } + if let Some(ref typed_responses) = stored.typed_responses { + route.typed_responses = Some(typed_responses.clone()); + } + if !stored.headers.is_empty() { + route.headers.clone_from(&stored.headers); + } + if let Some(ref example) = stored.request_example { + route.request_example = Some(example.clone()); + } + if let Some(ref example) = stored.response_example { + route.response_example = Some(example.clone()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metadata::RouteMetadata; + + fn stored_route(fn_name: &str, file_path: Option<&str>, tags: &[&str]) -> StoredRouteInfo { + StoredRouteInfo { + fn_name: fn_name.to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: Some(tags.iter().map(|tag| (*tag).to_string()).collect()), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: String::new(), + file_path: file_path.map(str::to_string), + } + } + + // ========== Tests for merge_route_storage_data ========== + + #[test] + fn test_merge_route_storage_empty_storage() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + }); + + merge_route_storage_data(&mut metadata, &[]); + // No changes when storage is empty + assert!(metadata.routes[0].tags.is_none()); + assert!(metadata.routes[0].description.is_none()); + assert!(metadata.routes[0].error_status.is_none()); + } + + #[test] + fn test_merge_route_storage_matching_route() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + }); + + let storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: Some(vec![400, 404]), + typed_responses: None, + tags: Some(vec!["users".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: Some("List all users".to_string()), + fn_sig_str: String::new(), + file_path: None, + }]; + + merge_route_storage_data(&mut metadata, &storage); + assert_eq!(metadata.routes[0].tags, Some(vec!["users".to_string()])); + assert_eq!( + metadata.routes[0].description, + Some("List all users".to_string()) + ); + assert_eq!(metadata.routes[0].error_status, Some(vec![400, 404])); + } + + #[test] + fn test_merge_route_storage_no_match() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + }); + + let storage = vec![StoredRouteInfo { + fn_name: "create_user".to_string(), + method: Some("post".to_string()), + custom_path: None, + error_status: Some(vec![400]), + typed_responses: None, + tags: Some(vec!["users".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: String::new(), + file_path: None, + }]; + + merge_route_storage_data(&mut metadata, &storage); + // No match — fields unchanged + assert!(metadata.routes[0].tags.is_none()); + assert!(metadata.routes[0].error_status.is_none()); + } + + #[test] + fn test_merge_route_storage_ambiguous_skipped() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "handler".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + }); + + // Two StoredRouteInfo with same fn_name — ambiguous + let storage = vec![ + StoredRouteInfo { + fn_name: "handler".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: Some(vec!["file-a".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: String::new(), + file_path: None, + }, + StoredRouteInfo { + fn_name: "handler".to_string(), + method: Some("post".to_string()), + custom_path: None, + error_status: None, + typed_responses: None, + tags: Some(vec!["file-b".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: String::new(), + file_path: None, + }, + ]; + + merge_route_storage_data(&mut metadata, &storage); + // Ambiguous match — no merge + assert!(metadata.routes[0].tags.is_none()); + } + + #[test] + fn test_merge_route_storage_disambiguates_same_fn_name_by_file_path() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "handler".to_string(), + module_path: "routes::users".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + }); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/posts".to_string(), + function_name: "handler".to_string(), + module_path: "routes::posts".to_string(), + file_path: "routes/posts.rs".to_string(), + error_status: None, + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + }); + + let storage = vec![ + stored_route("handler", Some("routes/users.rs"), &["users-file"]), + stored_route("handler", Some("routes/posts.rs"), &["posts-file"]), + ]; + + merge_route_storage_data(&mut metadata, &storage); + + assert_eq!( + metadata.routes[0].tags, + Some(vec!["users-file".to_string()]) + ); + assert_eq!( + metadata.routes[1].tags, + Some(vec!["posts-file".to_string()]) + ); + } + + #[test] + fn test_merge_route_storage_preserves_existing() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: Some(vec![500]), + typed_responses: None, + tags: Some(vec!["existing-tag".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: Some("Existing description".to_string()), + }); + + let storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: Some(vec![400, 404]), + typed_responses: None, + tags: Some(vec!["new-tag".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: Some("New description".to_string()), + fn_sig_str: String::new(), + file_path: None, + }]; + + merge_route_storage_data(&mut metadata, &storage); + // ROUTE_STORAGE values override when they have explicit values + assert_eq!(metadata.routes[0].tags, Some(vec!["new-tag".to_string()])); + assert_eq!( + metadata.routes[0].description, + Some("New description".to_string()) + ); + assert_eq!(metadata.routes[0].error_status, Some(vec![400, 404])); + } + + #[test] + fn test_merge_route_storage_partial_fields() { + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "get".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "routes".to_string(), + file_path: "routes/users.rs".to_string(), + error_status: None, + typed_responses: None, + tags: Some(vec!["from-collector".to_string()]), + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: Some("From doc comment".to_string()), + }); + + // StoredRouteInfo with only error_status (tags/description are None) + let storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: Some(vec![400]), + typed_responses: None, + tags: None, + security: None, + headers: Vec::new(), + success_status: None, + operation_id: None, + summary: None, + request_example: None, + response_example: None, + deprecated: false, + description: None, + fn_sig_str: String::new(), + file_path: None, + }]; + + merge_route_storage_data(&mut metadata, &storage); + // Only error_status should be set; tags and description preserved from collector + assert_eq!( + metadata.routes[0].tags, + Some(vec!["from-collector".to_string()]) + ); + assert_eq!( + metadata.routes[0].description, + Some("From doc comment".to_string()) + ); + assert_eq!(metadata.routes[0].error_status, Some(vec![400])); + } +} diff --git a/crates/vespera_macro/tests/trybuild_diagnostics.rs b/crates/vespera_macro/tests/trybuild_diagnostics.rs new file mode 100644 index 00000000..58d0f139 --- /dev/null +++ b/crates/vespera_macro/tests/trybuild_diagnostics.rs @@ -0,0 +1,5 @@ +#[test] +fn ui_diagnostics() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/route_duplicate_args.rs"); +} diff --git a/crates/vespera_macro/tests/ui/route_duplicate_args.rs b/crates/vespera_macro/tests/ui/route_duplicate_args.rs new file mode 100644 index 00000000..ebfbe90e --- /dev/null +++ b/crates/vespera_macro/tests/ui/route_duplicate_args.rs @@ -0,0 +1,15 @@ +use vespera_macro::route; + +#[route(get, post)] +pub async fn duplicate_method() {} + +#[route(get, path = "/one", path = "/two")] +pub async fn duplicate_path() {} + +#[route(post, status = 201, status = 202)] +pub async fn duplicate_status() {} + +#[route(get, responses = [(404, Missing)], responses = [(500, Broken)])] +pub async fn duplicate_responses() {} + +fn main() {} diff --git a/crates/vespera_macro/tests/ui/route_duplicate_args.stderr b/crates/vespera_macro/tests/ui/route_duplicate_args.stderr new file mode 100644 index 00000000..f8acce88 --- /dev/null +++ b/crates/vespera_macro/tests/ui/route_duplicate_args.stderr @@ -0,0 +1,23 @@ +error: #[route] `HTTP method` specified more than once + --> tests/ui/route_duplicate_args.rs:3:14 + | +3 | #[route(get, post)] + | ^^^^ + +error: #[route] `path` specified more than once + --> tests/ui/route_duplicate_args.rs:6:29 + | +6 | #[route(get, path = "/one", path = "/two")] + | ^^^^ + +error: #[route] `status` specified more than once + --> tests/ui/route_duplicate_args.rs:9:29 + | +9 | #[route(post, status = 201, status = 202)] + | ^^^^^^ + +error: #[route] `responses` specified more than once + --> tests/ui/route_duplicate_args.rs:12:44 + | +12 | #[route(get, responses = [(404, Missing)], responses = [(500, Broken)])] + | ^^^^^^^^^ diff --git a/examples/axum-example/Cargo.lock b/examples/axum-example/Cargo.lock deleted file mode 100644 index eeace9b2..00000000 --- a/examples/axum-example/Cargo.lock +++ /dev/null @@ -1,1591 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-example" -version = "0.1.0" -dependencies = [ - "axum", - "axum-test", - "serde", - "serde_json", - "tokio", - "vespera", -] - -[[package]] -name = "axum-test" -version = "18.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0388808c0617a886601385c0024b9d0162480a763ba371f803d87b775115400" -dependencies = [ - "anyhow", - "axum", - "bytes", - "bytesize", - "cookie", - "expect-json", - "http", - "http-body-util", - "hyper", - "hyper-util", - "mime", - "pretty_assertions", - "reserve-port", - "rust-multipart-rfc7578_2", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "tokio", - "tower", - "url", -] - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "bytesize" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f4369ba008f82b968b1acbe31715ec37bd45236fa0726605a36cc3060ea256" - -[[package]] -name = "cc" -version = "1.2.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" -dependencies = [ - "serde", -] - -[[package]] -name = "erased-serde" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "expect-json" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7519e78573c950576b89eb4f4fe82aedf3a80639245afa07e3ee3d199dcdb29e" -dependencies = [ - "chrono", - "email_address", - "expect-json-macros", - "num", - "serde", - "serde_json", - "thiserror", - "typetag", - "uuid", -] - -[[package]] -name = "expect-json-macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bf7f5979e98460a0eb412665514594f68f366a32b85fa8d7ffb65bb1edee6a0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-core", - "futures-io", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-util" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "inventory" -version = "0.3.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" -dependencies = [ - "rustversion", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.177" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "reserve-port" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" -dependencies = [ - "thiserror", -] - -[[package]] -name = "rust-multipart-rfc7578_2" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" -dependencies = [ - "bytes", - "futures-core", - "futures-util", - "http", - "mime", - "rand", - "thiserror", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" -dependencies = [ - "libc", -] - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "syn" -version = "2.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "time" -version = "0.3.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - -[[package]] -name = "time-macros" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typetag" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" -dependencies = [ - "erased-serde", - "inventory", - "once_cell", - "serde", - "typetag-impl", -] - -[[package]] -name = "typetag-impl" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "url" -version = "2.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vespera" -version = "0.1.0" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn", - "vespera_core", -] - -[[package]] -name = "vespera_core" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/examples/axum-example/Cargo.toml b/examples/axum-example/Cargo.toml index 38c6e44c..e0806345 100644 --- a/examples/axum-example/Cargo.toml +++ b/examples/axum-example/Cargo.toml @@ -10,7 +10,7 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower-http = { version = "0.6", features = ["cors"] } -sea-orm = { version = "^2.0.0-rc.38", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros", "with-uuid"] } +sea-orm = { version = "^2.0.0-rc.40", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros", "with-uuid"] } uuid = { version = "1", features = ["v4", "serde"] } tempfile = "3" @@ -20,6 +20,6 @@ third = { path = "../third" } vespera = { path = "../../crates/vespera" } [dev-dependencies] -axum-test = "20.0" -insta = "1.47" +axum-test = "20.1" +insta = "1.48" diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 4ed91e17..c416cb5e 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -18,7 +18,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -45,7 +45,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -72,7 +72,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -281,14 +281,14 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } } } }, - "400": { + "500": { "description": "Error response", "content": { "application/json": { @@ -322,7 +322,7 @@ "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -339,14 +339,14 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } } } }, - "400": { + "500": { "description": "Error response", "content": { "application/json": { @@ -366,33 +366,13 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } } } }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse2" - } - } - } - }, - "404": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse2" - } - } - } - }, "500": { "description": "Error response", "content": { @@ -413,14 +393,14 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } } } }, - "400": { + "500": { "description": "Error response", "content": { "application/json": { @@ -441,7 +421,7 @@ "description": "Successful response", "headers": {}, "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -469,7 +449,7 @@ "description": "Successful response", "headers": {}, "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -508,13 +488,13 @@ } ], "responses": { - "200": { + "204": { "description": "Successful response" }, - "400": { + "404": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -613,7 +593,7 @@ "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -893,7 +873,7 @@ "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -910,7 +890,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -931,7 +911,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -967,9 +947,11 @@ "in": "query", "required": false, "schema": { - "type": "integer", - "format": "uint32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint32" } } ], @@ -977,7 +959,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1049,7 +1031,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1144,16 +1126,28 @@ } } }, - "/no-schema-query": { + "/memos/{id}/summary": { "get": { - "operationId": "mod_file_with_no_schema_query", + "operationId": "get_memo_summary", + "description": "Get a memo summary (same-file relation adapter with a custom schema name)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/MemoSummaryResponse" } } } @@ -1194,7 +1188,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1206,7 +1200,7 @@ }, "/path/multi-path/{var1}": { "get": { - "operationId": "mod_file_with_test_struct", + "operationId": "mod_file_with_multi_path_single", "parameters": [ { "name": "var1", @@ -1215,32 +1209,15 @@ "schema": { "type": "string" } - }, - { - "name": "name", - "in": "query", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "age", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "format": "uint32" - } } ], "responses": { "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { - "$ref": "#/components/schemas/TestStruct" + "type": "string" } } } @@ -1281,7 +1258,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1308,7 +1285,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1335,7 +1312,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1362,7 +1339,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1398,7 +1375,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1482,7 +1459,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1503,7 +1480,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1542,9 +1519,11 @@ "in": "query", "required": false, "schema": { - "type": "integer", - "format": "uint32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint32" } } ], @@ -1552,7 +1531,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1615,7 +1594,7 @@ "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1667,7 +1646,7 @@ "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1717,7 +1696,7 @@ "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1744,7 +1723,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1777,7 +1756,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -2018,6 +1997,16 @@ "validated" ], "description": "Echo back the validated input. If the request body fails\nvalidation, this handler never runs — the `Validated` extractor\nreturns a `422` before it is reached.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatedUserRequest" + } + } + } + }, "responses": { "200": { "description": "Successful response", @@ -2028,6 +2017,39 @@ } } } + }, + "422": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "message" + ] + } + } + }, + "required": [ + "errors" + ] + } + } + } } } } @@ -2155,8 +2177,6 @@ }, "map": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "type": "string" } @@ -2172,8 +2192,6 @@ }, "nested_map": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } @@ -2191,8 +2209,6 @@ "type": "array", "items": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } @@ -2200,21 +2216,17 @@ }, "nested_struct_map": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } }, "nested_struct_map_array": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { + "type": "array", "items": { "$ref": "#/components/schemas/StructBodyWithOptional" - }, - "type": "array" + } } } }, @@ -2247,8 +2259,6 @@ }, "map": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "type": "string" } @@ -2264,8 +2274,6 @@ }, "nestedMap": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } @@ -2283,8 +2291,6 @@ "type": "array", "items": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } @@ -2292,21 +2298,17 @@ }, "nestedStructMap": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } }, "nestedStructMapArray": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { + "type": "array", "items": { "$ref": "#/components/schemas/StructBodyWithOptional" - }, - "type": "array" + } } } }, @@ -2328,25 +2330,29 @@ "type": "object", "properties": { "delimiter": { - "type": "string", - "format": "char", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "char" }, "discountRate": { - "type": "number", - "format": "decimal", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "decimal" }, "maxItems": { "type": "integer", "minimum": 0 }, "maxPrice": { - "type": "number", + "type": "string", "format": "decimal" }, "minPrice": { - "type": "number", + "type": "string", "format": "decimal" }, "priority": { @@ -2362,7 +2368,7 @@ "format": "char" }, "taxRate": { - "type": "number", + "type": "string", "format": "decimal" } }, @@ -2385,9 +2391,9 @@ "default": 0 }, "temperature": { - "type": "number", + "type": "string", "format": "decimal", - "default": 0.7 + "default": "0.7" } }, "required": [ @@ -2408,8 +2414,10 @@ "type": "string" }, "subject": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } }, "required": [ @@ -2437,12 +2445,16 @@ "type": "object", "properties": { "adminReply": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "category": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "content": { "type": "string" @@ -2455,15 +2467,19 @@ "format": "int64" }, "repliedAt": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "title": { "type": "string" }, "updatedAt": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "userId": { "type": "integer", @@ -2482,21 +2498,27 @@ "type": "object", "properties": { "document": { - "type": "string", - "format": "binary", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "binary" }, "name": { "type": "string" }, "tags": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "thumbnail": { - "type": "string", - "format": "binary", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "binary" } }, "required": [ @@ -2539,8 +2561,10 @@ "description": "Full user model with all fields", "properties": { "createdAt": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "email": { "type": "string" @@ -2563,10 +2587,12 @@ "description": "UUID item model for testing UUID format in OpenAPI", "properties": { "externalRef": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "uuid", - "description": "External reference UUID", - "nullable": true + "description": "External reference UUID" }, "name": { "type": "string", @@ -2695,8 +2721,6 @@ "properties": { "G": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "type": "string" } @@ -2711,8 +2735,6 @@ "properties": { "H": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "type": "string" } @@ -2772,8 +2794,10 @@ "type": "object", "properties": { "L": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } }, "required": [ @@ -2786,8 +2810,10 @@ "M": { "type": "array", "items": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } } }, @@ -2800,11 +2826,11 @@ "properties": { "N": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { - "nullable": true, - "type": "string" + "type": [ + "string", + "null" + ] } } }, @@ -2917,8 +2943,10 @@ "type": "string" }, "documentUrl": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "id": { "type": "integer", @@ -2937,8 +2965,10 @@ } }, "thumbnailUrl": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } }, "required": [ @@ -3030,8 +3060,10 @@ "format": "int32" }, "result": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "type": { "type": "string", @@ -3076,9 +3108,11 @@ "type": "string" }, "optional_age": { - "type": "integer", - "format": "uint32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint32" } }, "required": [ @@ -3188,8 +3222,14 @@ "default": "1970-01-01T00:00:00+00:00" }, "user": { - "$ref": "#/components/schemas/UserSchema", - "nullable": true + "anyOf": [ + { + "$ref": "#/components/schemas/UserInMemoDetail" + }, + { + "type": "null" + } + ] }, "userId": { "type": "integer", @@ -3412,6 +3452,73 @@ "archived" ] }, + "MemoSummaryResponse": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" + }, + "id": { + "type": "integer", + "format": "int32", + "default": 0 + }, + "note": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, + "title": { + "type": "string" + }, + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemoSummaryUser" + }, + { + "type": "null" + } + ] + }, + "userId": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "status", + "createdAt", + "user", + "note" + ] + }, + "MemoSummaryUser": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, "PaginatedResponse": { "type": "object", "properties": { @@ -3467,21 +3574,29 @@ "type": "object", "properties": { "isActive": { - "type": "boolean", - "nullable": true + "type": [ + "boolean", + "null" + ] }, "name": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "tags": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "thumbnail": { - "type": "string", - "format": "binary", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "binary" } } }, @@ -3551,8 +3666,10 @@ "type": "object", "properties": { "birthday": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "createdAt": { "type": "string" @@ -3561,23 +3678,29 @@ "type": "string" }, "gender": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "id": { "type": "integer", "format": "int32" }, "job": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "name": { "type": "string" }, "nickname": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "phoneNumber23": { "type": "string" @@ -3622,8 +3745,14 @@ "type": "object", "properties": { "singleRel": { - "$ref": "#/components/schemas/SingleSchema_SingleRel", - "nullable": true + "anyOf": [ + { + "$ref": "#/components/schemas/SingleSchema_SingleRel" + }, + { + "type": "null" + } + ] }, "username": { "type": "string", @@ -3648,13 +3777,11 @@ "SkipResponse": { "type": "object", "properties": { - "email2": { - "type": "string", - "nullable": true - }, "email4": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "email5": { "type": "string", @@ -3668,8 +3795,14 @@ "$ref": "#/components/schemas/InSkipResponse" }, "in_skip2": { - "$ref": "#/components/schemas/InSkipResponse", - "nullable": true + "anyOf": [ + { + "$ref": "#/components/schemas/InSkipResponse" + }, + { + "type": "null" + } + ] }, "in_skip3": { "type": "array", @@ -3678,29 +3811,31 @@ } }, "in_skip4": { - "type": "array", + "type": [ + "array", + "null" + ], "items": { "$ref": "#/components/schemas/InSkipResponse" - }, - "nullable": true + } }, "in_skip5": { - "type": "object", - "properties": {}, - "required": [], + "type": [ + "object", + "null" + ], "additionalProperties": { "$ref": "#/components/schemas/InSkipResponse" - }, - "nullable": true + } }, "in_skip6": { - "type": "object", - "properties": {}, - "required": [], + "type": [ + "object", + "null" + ], "additionalProperties": { "$ref": "#/components/schemas/InSkipResponse" - }, - "nullable": true + } }, "name": { "type": "string" @@ -3740,13 +3875,17 @@ "type": "object", "properties": { "age": { - "type": "integer", - "format": "uint32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint32" }, "name": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } } }, @@ -3850,9 +3989,11 @@ "type": "string" }, "optional_age": { - "type": "integer", - "format": "uint32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint32" } }, "required": [ @@ -3898,49 +4039,67 @@ "type": "object", "properties": { "delimiter": { - "type": "string", - "format": "char", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "char" }, "discountRate": { - "type": "number", - "format": "decimal", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "decimal" }, "maxItems": { - "type": "integer", - "minimum": 0, - "nullable": true + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "maxPrice": { - "type": "number", - "format": "decimal", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "decimal" }, "minPrice": { - "type": "number", - "format": "decimal", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "decimal" }, "priority": { - "type": "integer", - "format": "int32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "int32" }, "retryCount": { - "type": "integer", - "format": "uint8", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint8" }, "separator": { - "type": "string", - "format": "char", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "char" }, "taxRate": { - "type": "number", - "format": "decimal", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "decimal" } } }, @@ -3948,26 +4107,36 @@ "type": "object", "properties": { "document": { - "type": "string", - "format": "binary", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "binary" }, "isActive": { - "type": "boolean", - "nullable": true + "type": [ + "boolean", + "null" + ] }, "name": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "tags": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "thumbnail": { - "type": "string", - "format": "binary", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "binary" } } }, @@ -4003,10 +4172,12 @@ "format": "uint32" }, "internal_score": { - "type": "integer", + "type": [ + "integer", + "null" + ], "format": "int32", - "description": "Internal field - should be omitted in public APIs", - "nullable": true + "description": "Internal field - should be omitted in public APIs" }, "name": { "type": "string" @@ -4083,13 +4254,16 @@ "type": "object", "properties": { "filter": { - "type": "string", - "description": "Filter users by name (optional)", - "nullable": true + "type": [ + "string", + "null" + ], + "description": "Filter users by name (optional)" }, "sort": { "type": "string", - "description": "Sort order: \"asc\" or \"desc\"" + "description": "Sort order: \"asc\" or \"desc\"", + "default": "asc" } }, "required": [ @@ -4206,10 +4380,12 @@ "description": "UUID item model for testing UUID format in OpenAPI", "properties": { "externalRef": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "uuid", - "description": "External reference UUID", - "nullable": true + "description": "External reference UUID" }, "id": { "type": "string", @@ -4246,10 +4422,12 @@ "default": "1970-01-01T00:00:00+00:00" }, "externalRef": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "uuid", - "description": "External reference UUID", - "nullable": true + "description": "External reference UUID" }, "id": { "type": "string", diff --git a/examples/axum-example/src/routes/error.rs b/examples/axum-example/src/routes/error.rs index fdf480c0..7ab465e4 100644 --- a/examples/axum-example/src/routes/error.rs +++ b/examples/axum-example/src/routes/error.rs @@ -23,15 +23,18 @@ impl IntoResponse for ErrorResponse2 { } } -#[vespera::route()] -pub async fn error_endpoint() -> Result<&'static str, Json> { - Err(Json(ErrorResponse { - error: "Internal server error".to_string(), - code: 500, - })) +#[vespera::route(responses = [(500, ErrorResponse)])] +pub async fn error_endpoint() -> Result<&'static str, (StatusCode, Json)> { + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "Internal server error".to_string(), + code: 500, + }), + )) } -#[vespera::route(path = "/error-with-status")] +#[vespera::route(path = "/error-with-status", responses = [(500, ErrorResponse)])] pub async fn error_endpoint_with_status_code() -> Result<&'static str, (StatusCode, Json)> { Err(( @@ -43,7 +46,7 @@ pub async fn error_endpoint_with_status_code() )) } -#[vespera::route(path = "/error2")] +#[vespera::route(path = "/error2", responses = [(500, ErrorResponse2)])] pub async fn error_endpoint2() -> Result<&'static str, ErrorResponse2> { Err(ErrorResponse2 { error: "Internal server error".to_string(), @@ -51,7 +54,7 @@ pub async fn error_endpoint2() -> Result<&'static str, ErrorResponse2> { }) } -#[vespera::route(path = "/error-with-status2", error_status = [500, 400, 404])] +#[vespera::route(path = "/error-with-status2", error_status = [500])] pub async fn error_endpoint_with_status_code2() -> Result<&'static str, (StatusCode, ErrorResponse2)> { Err(( @@ -75,11 +78,13 @@ pub async fn header_map_endpoint2() -> Result<(StatusCode, HeaderMap, &'static s { let headers = HeaderMap::new(); println!("headers: {:?}", headers); - Ok((StatusCode::INTERNAL_SERVER_ERROR, headers, "ok")) + // Success branch returns 200 (the generated spec infers 200 for the Ok + // arm); returning 500 here was a fixture quirk that contradicted the spec. + Ok((StatusCode::OK, headers, "ok")) } /// Delete endpoint that returns just a StatusCode -#[vespera::route(delete, path = "/status-code/{id}", tags = ["error"])] +#[vespera::route(delete, path = "/status-code/{id}", tags = ["error"], status = 204, error_status = [404])] pub async fn status_code_endpoint( vespera::axum::extract::Path(id): vespera::axum::extract::Path, ) -> Result { diff --git a/examples/axum-example/src/routes/memos.rs b/examples/axum-example/src/routes/memos.rs index d8205636..747c2b91 100644 --- a/examples/axum-example/src/routes/memos.rs +++ b/examples/axum-example/src/routes/memos.rs @@ -65,6 +65,24 @@ schema_type!( add = [("memo_comments": Vec)] ); +// Same-file relation adapter whose OpenAPI component name is overridden via +// `#[schema(name = "...")]`. The generated relation `$ref` must point at that +// schema NAME (`MemoSummaryUser`), not the Rust struct name +// (`UserInMemoSummary`) — otherwise it dangles. +#[derive(serde::Serialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +#[schema(name = "MemoSummaryUser")] +pub struct UserInMemoSummary { + pub id: i32, + pub name: String, +} + +schema_type!( + MemoSummaryResponse from crate::models::memo::Model, + omit = ["updated_at", "memo_comments"], + add = [("note": String)] +); + /// Create a new memo #[vespera::route(post)] pub async fn create_memo(Json(req): Json) -> Json { @@ -170,6 +188,39 @@ pub async fn get_memo_detail(Path(id): Path) -> Json { }) } +/// Get a memo summary (same-file relation adapter with a custom schema name) +#[vespera::route(get, path = "/{id}/summary")] +pub async fn get_memo_summary(Path(id): Path) -> Json { + let now: vespera::chrono::DateTime = + vespera::chrono::Utc::now().fixed_offset(); + let memo = crate::models::memo::Model { + id, + user_id: 9, + title: "Summary Memo".to_string(), + content: "Summary content".to_string(), + status: crate::models::memo::MemoStatus::Published, + created_at: now, + updated_at: now, + }; + let user = Some(crate::models::user::Model { + id: 9, + email: "summary@example.com".to_string(), + name: "Summary User".to_string(), + created_at: now, + updated_at: now, + }); + Json(MemoSummaryResponse { + id: memo.id, + user_id: memo.user_id, + title: memo.title, + content: memo.content, + status: memo.status, + created_at: memo.created_at, + user: user.into(), + note: "summary".to_string(), + }) +} + /// Get memo response format #[vespera::route(get, path = "/format")] pub async fn get_memo_format() -> &'static str { diff --git a/examples/axum-example/src/routes/mod.rs b/examples/axum-example/src/routes/mod.rs index c3d08df1..dfdda2a1 100644 --- a/examples/axum-example/src/routes/mod.rs +++ b/examples/axum-example/src/routes/mod.rs @@ -51,21 +51,6 @@ pub async fn mod_file_with_map_query(Query(query): Query) -> &'static "mod file endpoint" } -#[derive(Deserialize, Debug)] -pub struct NoSchemaQuery { - pub name: String, - pub age: u32, - pub optional_age: Option, -} - -#[vespera::route(get, path = "/no-schema-query")] -pub async fn mod_file_with_no_schema_query(Query(query): Query) -> &'static str { - println!("no schema query: {:?}", query.age); - println!("no schema query: {:?}", query.name); - println!("no schema query: {:?}", query.optional_age); - "mod file endpoint" -} - #[derive(Deserialize, Schema)] pub struct StructQuery { pub name: String, diff --git a/examples/axum-example/src/routes/path/mod.rs b/examples/axum-example/src/routes/path/mod.rs index d8e86ada..5d7591a8 100644 --- a/examples/axum-example/src/routes/path/mod.rs +++ b/examples/axum-example/src/routes/path/mod.rs @@ -1,7 +1,7 @@ pub mod prefix; #[vespera::route(get, path = "/multi-path/{var1}")] -pub async fn mod_file_with_test_struct( +pub async fn mod_file_with_multi_path_single( vespera::axum::extract::Path(var1): vespera::axum::extract::Path, ) -> &'static str { println!("var1: {}", var1); diff --git a/examples/axum-example/tests/integration_test.rs b/examples/axum-example/tests/integration_test.rs index 358c8ca5..b90d470d 100644 --- a/examples/axum-example/tests/integration_test.rs +++ b/examples/axum-example/tests/integration_test.rs @@ -394,18 +394,47 @@ async fn test_openapi_contains_third_app_schemas() { // Test VesperaRouter::layer functionality #[tokio::test] async fn test_app_with_layer() { + use axum::http::header::{ACCESS_CONTROL_ALLOW_ORIGIN, ORIGIN}; + let app = create_app_with_layer().await; let server = TestServer::new(app); - // Test that routes still work with the layer applied - let response = server.get("/health").await; + // Base route works AND the CORS layer is applied (sanity). + let response = server + .get("/health") + .add_header(ORIGIN, "https://example.test") + .await; response.assert_status_ok(); response.assert_text("ok"); + assert_eq!( + response + .headers() + .get(ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()), + Some("*"), + "CORS layer should be applied to base routes" + ); - // Test merged routes also work with layer - let response = server.get("/third").await; + // VESPERA-01 regression lock: the layer must ALSO wrap MERGED child + // routes. The original bug applied `layer()` only to the base + // router, so `/third` (merged from `ThirdApp`) still WORKED but had + // NO CORS header — a status/text-only test would pass even with the + // bug. Asserting the CORS response header on the merged route is + // what actually proves the fix. + let response = server + .get("/third") + .add_header(ORIGIN, "https://example.test") + .await; response.assert_status_ok(); response.assert_text("third app root endpoint"); + assert_eq!( + response + .headers() + .get(ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()), + Some("*"), + "CORS layer must apply to MERGED routes too (VESPERA-01)" + ); } #[tokio::test] @@ -543,8 +572,11 @@ fn test_schema_macro_with_optional_fields() { let properties = user_schema.properties.unwrap(); assert_eq!(properties.len(), 4); - // Only 'id' and 'name' should be required - // 'email' is Option and 'bio' has #[serde(default)] + // Required is nullability-only, matching the OpenAPI component schema: + // 'id'/'name' are non-Option, and 'bio' is non-Option too — its + // `#[serde(default)]` does NOT exclude it from `required`. Only 'email' + // (Option) is optional. (`schema!` now shares the OpenAPI generation + // path, so it no longer diverges by dropping defaulted fields.) let required = user_schema.required.unwrap(); assert!(required.contains(&"id".to_string())); assert!(required.contains(&"name".to_string())); @@ -553,8 +585,9 @@ fn test_schema_macro_with_optional_fields() { "'email' is Option, should not be required" ); assert!( - !required.contains(&"bio".to_string()), - "'bio' has default, should not be required" + required.contains(&"bio".to_string()), + "'bio' is non-Option; #[serde(default)] does not affect required \ + status (required is nullability-only, matching OpenAPI)" ); } @@ -1018,9 +1051,27 @@ async fn test_openapi_memo_detail_same_file_relation_adapter_schema() { ); let memo_detail = &schemas["MemoDetailResponse"]; + // B6: the same-file relation adapter exposes its OWN schema, so the spec + // matches what the handler actually serializes (UserInMemoDetail's 3 fields) + // instead of over-promising the base UserSchema's 5 fields. + // Nullable single-value relation (`BelongsTo` → `Option<..>`) renders as the + // OpenAPI 3.1 `anyOf: [{$ref}, {type: null}]` form (not the 3.0 `$ref + + // nullable` keyword), so the adapter $ref lives under `anyOf[0]`. assert_eq!( - memo_detail["properties"]["user"]["$ref"], - "#/components/schemas/UserSchema" + memo_detail["properties"]["user"]["anyOf"][0]["$ref"], + "#/components/schemas/UserInMemoDetail" + ); + // The referenced adapter schema must carry exactly the adapter's fields — + // not the base model's createdAt/updatedAt, which never reach the wire. + let user_props = schemas["UserInMemoDetail"]["properties"] + .as_object() + .expect("UserInMemoDetail schema present"); + assert!(user_props.contains_key("id")); + assert!(user_props.contains_key("email")); + assert!(user_props.contains_key("name")); + assert!( + !user_props.contains_key("createdAt") && !user_props.contains_key("updatedAt"), + "adapter schema must not over-promise base-model timestamp fields" ); assert_eq!( memo_detail["properties"]["memoComments"]["items"]["$ref"], @@ -1701,7 +1752,7 @@ async fn test_numeric_field_invalid_value() { .add_text("initial", "A"); let response = server.post("/numeric-char-test").multipart(form).await; - response.assert_status(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE); + response.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } #[tokio::test] @@ -1716,7 +1767,7 @@ async fn test_float_field_invalid_value() { .add_text("initial", "A"); let response = server.post("/numeric-char-test").multipart(form).await; - response.assert_status(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE); + response.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } #[tokio::test] @@ -1731,7 +1782,7 @@ async fn test_char_field_multiple_chars() { .add_text("initial", "AB"); let response = server.post("/numeric-char-test").multipart(form).await; - response.assert_status(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE); + response.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } #[tokio::test] @@ -1746,7 +1797,7 @@ async fn test_char_field_empty_string() { .add_text("initial", ""); let response = server.post("/numeric-char-test").multipart(form).await; - response.assert_status(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE); + response.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } // ─── serde(default) struct-level tests ────────────────────────────────────── @@ -1858,7 +1909,7 @@ async fn test_numeric_field_non_utf8_bytes() { .add_text("initial", "A"); let response = server.post("/numeric-char-test").multipart(form).await; - response.assert_status(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE); + response.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } #[tokio::test] @@ -1927,3 +1978,97 @@ async fn test_missing_multiple_required_fields() { "Expected MissingField error, got: {body}" ); } + +/// Recursively collect every `$ref` string value in a JSON document. +fn collect_schema_refs(value: &serde_json::Value, out: &mut Vec) { + match value { + serde_json::Value::Object(map) => { + for (key, child) in map { + if key == "$ref" { + if let Some(reference) = child.as_str() { + out.push(reference.to_string()); + } + } else { + collect_schema_refs(child, out); + } + } + } + serde_json::Value::Array(items) => { + for item in items { + collect_schema_refs(item, out); + } + } + _ => {} + } +} + +/// Structural-integrity guard for the generated spec — a regression net for the +/// "wrong data" hunt. Asserts: (1) no dangling component `$ref`, (2) unique +/// `operationId`s, (3) every operation carries a non-empty `responses` object. +/// Locks these invariants so future macro changes cannot silently corrupt the +/// spec the way the original audit findings did. +#[test] +fn test_openapi_structural_integrity() { + use std::collections::{HashMap, HashSet}; + + let openapi: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string("openapi.json").unwrap()).unwrap(); + + let schema_names: HashSet<&str> = openapi["components"]["schemas"] + .as_object() + .expect("components.schemas object") + .keys() + .map(String::as_str) + .collect(); + + // 1. No dangling component `$ref`. + let mut refs = Vec::new(); + collect_schema_refs(&openapi, &mut refs); + for reference in &refs { + if let Some(name) = reference.strip_prefix("#/components/schemas/") { + assert!( + schema_names.contains(name), + "dangling $ref to undefined schema: {reference}" + ); + } + } + + // 2. Unique operationIds + 3. every operation has a non-empty `responses`. + const METHODS: [&str; 7] = ["get", "post", "put", "patch", "delete", "head", "options"]; + let mut operation_ids: HashMap = HashMap::new(); + for (path, item) in openapi["paths"].as_object().expect("paths object") { + let item = item.as_object().expect("path item object"); + for method in METHODS { + let Some(op) = item.get(method) else { + continue; + }; + let here = format!("{} {path}", method.to_uppercase()); + + let responses = op.get("responses").and_then(serde_json::Value::as_object); + assert!( + responses.is_some_and(|r| !r.is_empty()), + "operation {here} has no responses" + ); + + if let Some(op_id) = op.get("operationId").and_then(serde_json::Value::as_str) + && let Some(prev) = operation_ids.insert(op_id.to_string(), here.clone()) + { + panic!("duplicate operationId '{op_id}': {prev} and {here}"); + } + } + } +} + +#[test] +fn decimal_serializes_as_string_at_runtime() { + // `rust_decimal`'s serde serializes `Decimal` as a JSON STRING (to preserve + // precision), so the OpenAPI mapping for `Decimal` must be + // `{type:string, format:decimal}`, not `number`. Locks that assumption so + // the spec cannot silently regress to lying about the wire type. + let value = serde_json::to_value(sea_orm::prelude::Decimal::new(1050, 2)).unwrap(); + assert!( + value.is_string(), + "Decimal serialized as {value:?}, expected a JSON string" + ); + assert_eq!(value, serde_json::json!("10.50")); +} diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index d3be57bf..4c91d8f7 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -1,6 +1,6 @@ --- source: examples/axum-example/tests/integration_test.rs -assertion_line: 413 +assertion_line: 442 expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" --- { @@ -23,7 +23,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -50,7 +50,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -77,7 +77,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -286,14 +286,14 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } } } }, - "400": { + "500": { "description": "Error response", "content": { "application/json": { @@ -327,7 +327,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -344,14 +344,14 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } } } }, - "400": { + "500": { "description": "Error response", "content": { "application/json": { @@ -371,33 +371,13 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } } } }, - "400": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse2" - } - } - } - }, - "404": { - "description": "Error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse2" - } - } - } - }, "500": { "description": "Error response", "content": { @@ -418,14 +398,14 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } } } }, - "400": { + "500": { "description": "Error response", "content": { "application/json": { @@ -446,7 +426,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "Successful response", "headers": {}, "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -474,7 +454,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "Successful response", "headers": {}, "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -513,13 +493,13 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } ], "responses": { - "200": { + "204": { "description": "Successful response" }, - "400": { + "404": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -618,7 +598,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -898,7 +878,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -915,7 +895,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -936,7 +916,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -972,9 +952,11 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "query", "required": false, "schema": { - "type": "integer", - "format": "uint32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint32" } } ], @@ -982,7 +964,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1054,7 +1036,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1149,16 +1131,28 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, - "/no-schema-query": { + "/memos/{id}/summary": { "get": { - "operationId": "mod_file_with_no_schema_query", + "operationId": "get_memo_summary", + "description": "Get a memo summary (same-file relation adapter with a custom schema name)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/MemoSummaryResponse" } } } @@ -1199,7 +1193,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1211,7 +1205,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "/path/multi-path/{var1}": { "get": { - "operationId": "mod_file_with_test_struct", + "operationId": "mod_file_with_multi_path_single", "parameters": [ { "name": "var1", @@ -1220,32 +1214,15 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "schema": { "type": "string" } - }, - { - "name": "name", - "in": "query", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "age", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "format": "uint32" - } } ], "responses": { "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { - "$ref": "#/components/schemas/TestStruct" + "type": "string" } } } @@ -1286,7 +1263,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1313,7 +1290,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1340,7 +1317,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1367,7 +1344,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1403,7 +1380,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1487,7 +1464,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1508,7 +1485,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1547,9 +1524,11 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "in": "query", "required": false, "schema": { - "type": "integer", - "format": "uint32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint32" } } ], @@ -1557,7 +1536,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1620,7 +1599,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1672,7 +1651,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1722,7 +1701,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1749,7 +1728,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1782,7 +1761,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -2023,6 +2002,16 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "validated" ], "description": "Echo back the validated input. If the request body fails\nvalidation, this handler never runs — the `Validated` extractor\nreturns a `422` before it is reached.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatedUserRequest" + } + } + } + }, "responses": { "200": { "description": "Successful response", @@ -2033,6 +2022,39 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } } + }, + "422": { + "description": "Validation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "message" + ] + } + } + }, + "required": [ + "errors" + ] + } + } + } } } } @@ -2160,8 +2182,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "map": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "type": "string" } @@ -2177,8 +2197,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "nested_map": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } @@ -2196,8 +2214,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "array", "items": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } @@ -2205,21 +2221,17 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "nested_struct_map": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } }, "nested_struct_map_array": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { + "type": "array", "items": { "$ref": "#/components/schemas/StructBodyWithOptional" - }, - "type": "array" + } } } }, @@ -2252,8 +2264,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "map": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "type": "string" } @@ -2269,8 +2279,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "nestedMap": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } @@ -2288,8 +2296,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "array", "items": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } @@ -2297,21 +2303,17 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "nestedStructMap": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "$ref": "#/components/schemas/StructBodyWithOptional" } }, "nestedStructMapArray": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { + "type": "array", "items": { "$ref": "#/components/schemas/StructBodyWithOptional" - }, - "type": "array" + } } } }, @@ -2333,25 +2335,29 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "delimiter": { - "type": "string", - "format": "char", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "char" }, "discountRate": { - "type": "number", - "format": "decimal", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "decimal" }, "maxItems": { "type": "integer", "minimum": 0 }, "maxPrice": { - "type": "number", + "type": "string", "format": "decimal" }, "minPrice": { - "type": "number", + "type": "string", "format": "decimal" }, "priority": { @@ -2367,7 +2373,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "char" }, "taxRate": { - "type": "number", + "type": "string", "format": "decimal" } }, @@ -2390,9 +2396,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "default": 0 }, "temperature": { - "type": "number", + "type": "string", "format": "decimal", - "default": 0.7 + "default": "0.7" } }, "required": [ @@ -2413,8 +2419,10 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "subject": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } }, "required": [ @@ -2442,12 +2450,16 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "adminReply": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "category": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "content": { "type": "string" @@ -2460,15 +2472,19 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "int64" }, "repliedAt": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "title": { "type": "string" }, "updatedAt": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "userId": { "type": "integer", @@ -2487,21 +2503,27 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "document": { - "type": "string", - "format": "binary", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "binary" }, "name": { "type": "string" }, "tags": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "thumbnail": { - "type": "string", - "format": "binary", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "binary" } }, "required": [ @@ -2544,8 +2566,10 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "Full user model with all fields", "properties": { "createdAt": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "email": { "type": "string" @@ -2568,10 +2592,12 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "UUID item model for testing UUID format in OpenAPI", "properties": { "externalRef": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "uuid", - "description": "External reference UUID", - "nullable": true + "description": "External reference UUID" }, "name": { "type": "string", @@ -2700,8 +2726,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "properties": { "G": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "type": "string" } @@ -2716,8 +2740,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "properties": { "H": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { "type": "string" } @@ -2777,8 +2799,10 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "L": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } }, "required": [ @@ -2791,8 +2815,10 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "M": { "type": "array", "items": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } } }, @@ -2805,11 +2831,11 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "properties": { "N": { "type": "object", - "properties": {}, - "required": [], "additionalProperties": { - "nullable": true, - "type": "string" + "type": [ + "string", + "null" + ] } } }, @@ -2922,8 +2948,10 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "documentUrl": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "id": { "type": "integer", @@ -2942,8 +2970,10 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } }, "thumbnailUrl": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } }, "required": [ @@ -3035,8 +3065,10 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "int32" }, "result": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "type": { "type": "string", @@ -3081,9 +3113,11 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "optional_age": { - "type": "integer", - "format": "uint32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint32" } }, "required": [ @@ -3193,8 +3227,14 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "default": "1970-01-01T00:00:00+00:00" }, "user": { - "$ref": "#/components/schemas/UserSchema", - "nullable": true + "anyOf": [ + { + "$ref": "#/components/schemas/UserInMemoDetail" + }, + { + "type": "null" + } + ] }, "userId": { "type": "integer", @@ -3417,6 +3457,73 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "archived" ] }, + "MemoSummaryResponse": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" + }, + "id": { + "type": "integer", + "format": "int32", + "default": 0 + }, + "note": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, + "title": { + "type": "string" + }, + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemoSummaryUser" + }, + { + "type": "null" + } + ] + }, + "userId": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "status", + "createdAt", + "user", + "note" + ] + }, + "MemoSummaryUser": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, "PaginatedResponse": { "type": "object", "properties": { @@ -3472,21 +3579,29 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "isActive": { - "type": "boolean", - "nullable": true + "type": [ + "boolean", + "null" + ] }, "name": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "tags": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "thumbnail": { - "type": "string", - "format": "binary", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "binary" } } }, @@ -3556,8 +3671,10 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "birthday": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "createdAt": { "type": "string" @@ -3566,23 +3683,29 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "gender": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "id": { "type": "integer", "format": "int32" }, "job": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "name": { "type": "string" }, "nickname": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "phoneNumber23": { "type": "string" @@ -3627,8 +3750,14 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "singleRel": { - "$ref": "#/components/schemas/SingleSchema_SingleRel", - "nullable": true + "anyOf": [ + { + "$ref": "#/components/schemas/SingleSchema_SingleRel" + }, + { + "type": "null" + } + ] }, "username": { "type": "string", @@ -3653,13 +3782,11 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "SkipResponse": { "type": "object", "properties": { - "email2": { - "type": "string", - "nullable": true - }, "email4": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "email5": { "type": "string", @@ -3673,8 +3800,14 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "$ref": "#/components/schemas/InSkipResponse" }, "in_skip2": { - "$ref": "#/components/schemas/InSkipResponse", - "nullable": true + "anyOf": [ + { + "$ref": "#/components/schemas/InSkipResponse" + }, + { + "type": "null" + } + ] }, "in_skip3": { "type": "array", @@ -3683,29 +3816,31 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } }, "in_skip4": { - "type": "array", + "type": [ + "array", + "null" + ], "items": { "$ref": "#/components/schemas/InSkipResponse" - }, - "nullable": true + } }, "in_skip5": { - "type": "object", - "properties": {}, - "required": [], + "type": [ + "object", + "null" + ], "additionalProperties": { "$ref": "#/components/schemas/InSkipResponse" - }, - "nullable": true + } }, "in_skip6": { - "type": "object", - "properties": {}, - "required": [], + "type": [ + "object", + "null" + ], "additionalProperties": { "$ref": "#/components/schemas/InSkipResponse" - }, - "nullable": true + } }, "name": { "type": "string" @@ -3745,13 +3880,17 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "age": { - "type": "integer", - "format": "uint32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint32" }, "name": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } } }, @@ -3855,9 +3994,11 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string" }, "optional_age": { - "type": "integer", - "format": "uint32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint32" } }, "required": [ @@ -3903,49 +4044,67 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "delimiter": { - "type": "string", - "format": "char", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "char" }, "discountRate": { - "type": "number", - "format": "decimal", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "decimal" }, "maxItems": { - "type": "integer", - "minimum": 0, - "nullable": true + "type": [ + "integer", + "null" + ], + "minimum": 0 }, "maxPrice": { - "type": "number", - "format": "decimal", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "decimal" }, "minPrice": { - "type": "number", - "format": "decimal", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "decimal" }, "priority": { - "type": "integer", - "format": "int32", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "int32" }, "retryCount": { - "type": "integer", - "format": "uint8", - "nullable": true + "type": [ + "integer", + "null" + ], + "format": "uint8" }, "separator": { - "type": "string", - "format": "char", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "char" }, "taxRate": { - "type": "number", - "format": "decimal", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "decimal" } } }, @@ -3953,26 +4112,36 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "document": { - "type": "string", - "format": "binary", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "binary" }, "isActive": { - "type": "boolean", - "nullable": true + "type": [ + "boolean", + "null" + ] }, "name": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "tags": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "thumbnail": { - "type": "string", - "format": "binary", - "nullable": true + "type": [ + "string", + "null" + ], + "format": "binary" } } }, @@ -4008,10 +4177,12 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "uint32" }, "internal_score": { - "type": "integer", + "type": [ + "integer", + "null" + ], "format": "int32", - "description": "Internal field - should be omitted in public APIs", - "nullable": true + "description": "Internal field - should be omitted in public APIs" }, "name": { "type": "string" @@ -4088,13 +4259,16 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "object", "properties": { "filter": { - "type": "string", - "description": "Filter users by name (optional)", - "nullable": true + "type": [ + "string", + "null" + ], + "description": "Filter users by name (optional)" }, "sort": { "type": "string", - "description": "Sort order: \"asc\" or \"desc\"" + "description": "Sort order: \"asc\" or \"desc\"", + "default": "asc" } }, "required": [ @@ -4211,10 +4385,12 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "UUID item model for testing UUID format in OpenAPI", "properties": { "externalRef": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "uuid", - "description": "External reference UUID", - "nullable": true + "description": "External reference UUID" }, "id": { "type": "string", @@ -4251,10 +4427,12 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "default": "1970-01-01T00:00:00+00:00" }, "externalRef": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "uuid", - "description": "External reference UUID", - "nullable": true + "description": "External reference UUID" }, "id": { "type": "string", diff --git a/examples/rust-jni-demo/Cargo.lock b/examples/rust-jni-demo/Cargo.lock deleted file mode 100644 index fd84f935..00000000 --- a/examples/rust-jni-demo/Cargo.lock +++ /dev/null @@ -1,1454 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "multer", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-extra" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" -dependencies = [ - "axum", - "axum-core", - "bytes", - "cookie", - "fastrand", - "form_urlencoded", - "futures-core", - "futures-util", - "headers", - "http", - "http-body", - "http-body-util", - "mime", - "multer", - "pin-project-lite", - "serde_core", - "serde_html_form", - "serde_path_to_error", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "js-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.183" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rust-jni-demo" -version = "0.1.0" -dependencies = [ - "axum", - "jni", - "serde", - "serde_json", - "tokio", - "vespera", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_html_form" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" -dependencies = [ - "form_urlencoded", - "indexmap", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tokio" -version = "1.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" -dependencies = [ - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vespera" -version = "0.1.44" -dependencies = [ - "axum", - "axum-extra", - "chrono", - "http", - "http-body-util", - "jni", - "serde", - "serde_json", - "tempfile", - "tokio", - "tower", - "tower-layer", - "tower-service", - "vespera_core", - "vespera_macro", -] - -[[package]] -name = "vespera_core" -version = "0.1.44" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "vespera_macro" -version = "0.1.44" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn", - "vespera_core", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/examples/rust-jni-demo/Cargo.toml b/examples/rust-jni-demo/Cargo.toml index a4f2c955..b259311a 100644 --- a/examples/rust-jni-demo/Cargo.toml +++ b/examples/rust-jni-demo/Cargo.toml @@ -12,6 +12,9 @@ name = "rust-jni-demo" path = "src/main.rs" [dependencies] +# mimalloc is default-on for JNI cdylibs (it rides vespera's default +# features), so `["jni"]` alone already wires up the faster global +# allocator — pass `default-features = false` to bring your own. vespera = { path = "../../crates/vespera", features = ["jni"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/examples/rust-jni-demo/README.md b/examples/rust-jni-demo/README.md index b4709e90..6d77d1fc 100644 --- a/examples/rust-jni-demo/README.md +++ b/examples/rust-jni-demo/README.md @@ -161,7 +161,7 @@ public class DemoApplication { 3. `VesperaProxyController` catches all HTTP requests → encodes them into the **binary wire format** via `VesperaBridge.encodeRequest(...)` → calls `VesperaBridge.dispatchBytes(byte[])` 4. JNI symbol delegates to `vespera::inprocess::dispatch_from_bytes()` 5. `dispatch_from_bytes` parses the wire header, looks up the cached `Router`, and runs `router.oneshot(request)` with the raw body bytes -6. Response wire bytes flow back the same way; `VesperaBridge.decodeResponse(byte[])` produces a `DecodedResponse` and the controller returns either `ResponseEntity` (text-like Content-Type) or `ResponseEntity` (binary) +6. Response wire bytes flow back the same way; the controller parses status + headers straight from the wire via `WireHeaderReader` and returns `ResponseEntity` for every content type (the wire header carries the exact `Content-Type`, written verbatim — no UTF-8 round-trip) 7. No TCP between Java and Rust; **no base64** — multipart uploads, PDFs, images travel as raw bytes #### Wire format @@ -188,9 +188,9 @@ All failure paths (malformed wire, Rust panic, no app registered) return a lengt ```kotlin // build.gradle.kts repositories { - maven { url = uri("https://maven.pkg.github.com/dev-five-git/vespera") } + mavenCentral() } dependencies { - implementation("com.devfive.vespera:vespera-bridge:0.1.0") + implementation("kr.devfive:vespera-bridge:0.2.0") } ``` diff --git a/examples/rust-jni-demo/java/demo-app/build.gradle.kts b/examples/rust-jni-demo/java/demo-app/build.gradle.kts index 12aeda20..8c5affce 100644 --- a/examples/rust-jni-demo/java/demo-app/build.gradle.kts +++ b/examples/rust-jni-demo/java/demo-app/build.gradle.kts @@ -12,7 +12,7 @@ plugins { // detection helpers, library-name mapping, processResources wiring). // After: the 5-line `vespera { ... }` block below. // ─────────────────────────────────────────────────────────────────── - id("kr.devfive.vespera-bridge") version "0.0.15" + id("kr.devfive.vespera-bridge") version "0.1.1" } group = "kr.go.demo" @@ -21,7 +21,9 @@ version = "0.1.0" vespera { crateName.set("rust_jni_demo") cargoRoot.set(rootProject.layout.projectDirectory.dir("../../..")) - bridgeVersion.set("0.0.15") + // Dogfoods the locally published bridge (./gradlew publishToMavenLocal + // in libs/vespera-bridge) — required for the dispatchDirect E2E tests. + bridgeVersion.set("0.1.1") } dependencies { @@ -32,4 +34,19 @@ dependencies { tasks.test { useJUnitPlatform() + // Propagate streaming bench knobs from the Gradle CLI into the + // forked test JVM (chunk size is process-fixed, so each value + // needs its own `gradlew test -D...` run). + listOf( + "vespera.bench", + "vespera.streaming.chunkBytes", + "vespera.streaming.channelCapacity", + "vespera.runtime.workerThreads", + "vespera.direct.maxRetainedBytes", + "vespera.direct.maxBufferBytes", + ).forEach { key -> + System.getProperty(key)?.let { systemProperty(key, it) } + } + // Bench output is read from stdout. + testLogging.showStandardStreams = true } diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AllocationBenchTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AllocationBenchTest.java new file mode 100644 index 00000000..3cb24ca4 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AllocationBenchTest.java @@ -0,0 +1,234 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import com.sun.management.ThreadMXBean; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.management.ManagementFactory; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * E2E JNI allocation benchmark gated behind + * {@code -Dvespera.bench=true} — companion to {@link SmallRequestLatencyBenchTest}. + * + *

Measures JVM bytes allocated per dispatch on the calling + * thread for each of the five dispatch modes, using + * {@link com.sun.management.ThreadMXBean#getThreadAllocatedBytes(long)}. + * Quantifies the memory dimension that the recently-landed streaming + * chunk-buffer TLS pooling targets. + * + *

Why calling-thread measurement captures the pooling win

+ * + *

The streaming JNI entries + * ({@code Java_..._dispatchFullStreamingWithHeader} and friends in + * {@code crates/vespera_jni/src/jni_impl.rs}) allocate the Java byte[] chunk + * buffers via {@code env.new_byte_array(...)} on the JNI entry thread + * — i.e. the calling thread. Same for {@code set_region} / {@code get_region} + * on those arrays. Before TLS pooling those landed as fresh JVM allocations + * per dispatch; after pooling the same {@code GlobalRef} is + * reused across calls, so the calling-thread allocation count drops to + * effectively the request/response wire bytes plus a few small Java objects. + * + *

Async caveat (honest)

+ * + *

For {@code async_completable_future}, the {@code CompletableFuture} + * completion happens on a Rust Tokio worker thread (a daemon-attached + * cached worker), not the calling thread. This measurement therefore + * captures only what the caller pays: encoding the request, + * constructing the future, and {@code future.get()}-side allocations. + * Completion-side allocations on the daemon thread are not visible here + * and would require per-thread {@code getThreadAllocatedBytes} on the + * worker, which we don't observe by design. + * + *

Protocol

+ * + *
    + *
  • {@code WARMUP=5_000} iterations to stabilize JIT / inlining / + * TLS-pool fill. + *
  • {@code MEASURE=20_000} iterations; bytes/op = + * {@code (allocAfter - allocBefore) / MEASURE}. + *
  • Single-threaded loop, pinned to one calling thread. + *
  • Loop body keeps no per-iteration objects in Java besides what the + * dispatch helpers themselves create — the measurement-harness's own + * per-op allocation is intentionally zero (a {@code long} blackhole + * accumulator only). + *
+ * + *

Output

+ * + *

One line per mode (parseable, same style as {@code VESPERA_BENCH}): + *

VESPERA_ALLOC <mode> bytes_per_op=<N>
+ * + *

Assertion: weak sanity only ({@code bytes_per_op >= 0}). This is a + * measurement tool, not a pass/fail gate — exact numbers are + * machine/JDK-dependent. + */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class AllocationBenchTest { + + private static final int WARMUP = 5_000; + private static final int MEASURE = 20_000; + private static final Map HEADERS = Map.of("accept", "application/json"); + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + } + + private static final class CountingOutputStream extends OutputStream { + long count; + + @Override + public void write(int b) { + count++; + } + + @Override + public void write(byte[] b, int off, int len) { + count += len; + } + } + + // --- Mode implementations: kept byte-for-byte equivalent to + // SmallRequestLatencyBenchTest so the latency and allocation + // numbers describe the same code path. --- + + private static int syncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status(); + } + + private static int directOnce() { + ByteBuffer resp = + VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true); + byte[] out = new byte[resp.remaining()]; + resp.get(out); + return VesperaBridge.decodeResponse(out).status(); + } + + private static int streamingOnce() throws IOException { + byte[] wireHeader = VesperaBridge.encodeRequestHeader("GET", "/health", null, HEADERS); + CountingOutputStream sink = new CountingOutputStream(); + int[] status = new int[1]; + VesperaBridge.dispatchFullStreamingWithHeader( + wireHeader, + headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(), + new ByteArrayInputStream(new byte[0]), + sink); + return status[0]; + } + + private static int asyncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + CompletableFuture future = new CompletableFuture<>(); + VesperaBridge.dispatchAsync(future, wire); + try { + byte[] resp = future.get(30, TimeUnit.SECONDS); + return VesperaBridge.decodeResponse(resp).status(); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new RuntimeException(e); + } + } + + private static int responseStreamingOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + CountingOutputStream sink = new CountingOutputStream(); + int[] status = new int[1]; + VesperaBridge.dispatchStreamingWithHeader( + wire, + headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(), + sink); + return status[0]; + } + + private interface Op { + int run() throws IOException; + } + + /** + * Measure bytes allocated by the calling thread across MEASURE + * iterations. Returns bytes/op (integer). The loop body contains no + * Java allocations besides the {@code long} blackhole and what the + * dispatch helpers themselves do — so the per-op number describes the + * dispatch path's calling-thread allocation footprint. + */ + private static long measureAlloc(String mode, Op op, ThreadMXBean tmx) throws IOException { + long tid = Thread.currentThread().getId(); + + // Warmup — let JIT settle, TLS pools fill, classes load. + for (int i = 0; i < WARMUP; i++) { + if (op.run() != 200) { + throw new IllegalStateException(mode + " warmup non-200"); + } + } + + long blackhole = 0; + long allocBefore = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < MEASURE; i++) { + blackhole += op.run(); + } + long allocAfter = tmx.getThreadAllocatedBytes(tid); + + long delta = allocAfter - allocBefore; + long bytesPerOp = delta / MEASURE; + + System.out.printf( + "VESPERA_ALLOC %s bytes_per_op=%d (total_delta=%d iters=%d blackhole=%d)%n", + mode, bytesPerOp, delta, MEASURE, blackhole); + + if (bytesPerOp < 0) { + throw new AssertionError( + mode + " bytes_per_op<0 (delta=" + delta + " iters=" + MEASURE + ")"); + } + return bytesPerOp; + } + + @Test + void allocationPerDispatchByMode() throws IOException { + java.lang.management.ThreadMXBean base = ManagementFactory.getThreadMXBean(); + Assumptions.assumeTrue( + base instanceof ThreadMXBean, + "platform ThreadMXBean is not com.sun.management.ThreadMXBean — non-HotSpot JVM?"); + ThreadMXBean tmx = (ThreadMXBean) base; + Assumptions.assumeTrue( + tmx.isThreadAllocatedMemorySupported(), + "ThreadMXBean.isThreadAllocatedMemorySupported()==false on this JVM"); + if (!tmx.isThreadAllocatedMemoryEnabled()) { + tmx.setThreadAllocatedMemoryEnabled(true); + } + + long sync = measureAlloc("sync_dispatch_bytes", AllocationBenchTest::syncOnce, tmx); + long direct = measureAlloc("direct_pooled", AllocationBenchTest::directOnce, tmx); + long respStreaming = + measureAlloc( + "response_streaming_only", + AllocationBenchTest::responseStreamingOnce, + tmx); + long streaming = + measureAlloc( + "bidirectional_streaming", + AllocationBenchTest::streamingOnce, + tmx); + long async = + measureAlloc( + "async_completable_future", + AllocationBenchTest::asyncOnce, + tmx); + + System.out.printf( + "VESPERA_ALLOC summary sync=%d direct=%d resp_streaming=%d bidi_streaming=%d" + + " async_caller_side=%d (async completion lands on a Rust Tokio worker" + + " thread — not measured here)%n", + sync, direct, respStreaming, streaming, async); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AsyncDispatchExceptionHygieneTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AsyncDispatchExceptionHygieneTest.java new file mode 100644 index 00000000..2757ea31 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/AsyncDispatchExceptionHygieneTest.java @@ -0,0 +1,59 @@ +package kr.go.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.devfive.vespera.bridge.VesperaBridge; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class AsyncDispatchExceptionHygieneTest { + private static final Map HEADERS = Map.of("accept", "application/json"); + private static final int TIMEOUT_SECONDS = 10; + + @BeforeAll + static void setUp() { + System.setProperty("vespera.runtime.workerThreads", "1"); + VesperaBridge.init("rust_jni_demo"); + } + + @Test + void throwingFutureCompleteDoesNotPoisonNextAsyncCompletion() throws Exception { + poisonAsyncCompletion(); + + CompletableFuture healthy = new CompletableFuture<>(); + VesperaBridge.dispatchAsync(healthy, healthRequest()); + + byte[] wireResponse = healthy.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(200, VesperaBridge.decodeResponse(wireResponse).status()); + } + + private static void poisonAsyncCompletion() throws InterruptedException { + CountDownLatch completeCalled = new CountDownLatch(1); + AtomicInteger completeCalls = new AtomicInteger(); + CompletableFuture throwingFuture = new CompletableFuture<>() { + @Override + public boolean complete(byte[] value) { + completeCalls.incrementAndGet(); + completeCalled.countDown(); + throw new RuntimeException("intentional complete() failure"); + } + }; + + VesperaBridge.dispatchAsync(throwingFuture, healthRequest()); + + assertTrue( + completeCalled.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), + "poison future complete() must be invoked"); + assertEquals(1, completeCalls.get(), "poison future complete() call count"); + } + + private static byte[] healthRequest() { + return VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/ConcurrencyBenchTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/ConcurrencyBenchTest.java new file mode 100644 index 00000000..02d6c5b2 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/ConcurrencyBenchTest.java @@ -0,0 +1,147 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** E2E JNI concurrency throughput benchmark gated behind {@code -Dvespera.bench=true}. */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class ConcurrencyBenchTest { + + private static final int[] THREAD_COUNTS = {1, 2, 4, 8, 16}; + private static final int WARMUP_SECONDS = 1; + private static final int MEASURE_SECONDS = 3; + private static final Map HEADERS = Map.of("accept", "application/json"); + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + } + + // Mode implementations: intentionally equivalent to SmallRequestLatencyBenchTest + // so latency, allocation, and concurrency numbers describe the same code path. + private static int syncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status(); + } + + private static int directOnce() { + ByteBuffer resp = + VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true); + // Consume like the controller does: header region must be parsed. + byte[] out = new byte[resp.remaining()]; + resp.get(out); + return VesperaBridge.decodeResponse(out).status(); + } + + private interface Op { + int run() throws IOException; + } + + private record Result(long totalOps, double opsPerSecond) {} + + private static Result measureConcurrency(String mode, Op op, int threads) throws Exception { + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + AtomicReference failure = new AtomicReference<>(); + long[] counts = new long[threads]; + + for (int i = 0; i < threads; i++) { + int threadIndex = i; + Thread worker = + new Thread( + () -> { + try { + ready.countDown(); + start.await(); + + long warmupUntil = + System.nanoTime() + + TimeUnit.SECONDS.toNanos(WARMUP_SECONDS); + while (System.nanoTime() < warmupUntil) { + if (op.run() != 200) { + throw new IllegalStateException(mode + " warmup non-200"); + } + } + + long measured = 0; + long measureUntil = + System.nanoTime() + + TimeUnit.SECONDS.toNanos(MEASURE_SECONDS); + while (System.nanoTime() < measureUntil) { + if (op.run() != 200) { + throw new IllegalStateException(mode + " measure non-200"); + } + measured++; + } + counts[threadIndex] = measured; + } catch (Throwable t) { + failure.compareAndSet(null, t); + } finally { + done.countDown(); + } + }, + "vespera-conc-" + mode + "-" + threads + "-" + i); + worker.start(); + } + + if (!ready.await(30, TimeUnit.SECONDS)) { + throw new AssertionError(mode + " workers did not become ready"); + } + start.countDown(); + long timeout = WARMUP_SECONDS + MEASURE_SECONDS + 30L; + if (!done.await(timeout, TimeUnit.SECONDS)) { + throw new AssertionError(mode + " workers did not finish within timeout"); + } + + Throwable t = failure.get(); + if (t instanceof Exception) { + throw (Exception) t; + } + if (t instanceof Error) { + throw (Error) t; + } + if (t != null) { + throw new RuntimeException(t); + } + + long totalOps = 0; + for (long count : counts) { + totalOps += count; + } + double opsPerSecond = totalOps / (double) MEASURE_SECONDS; + return new Result(totalOps, opsPerSecond); + } + + private static void measureMode(String mode, Op op) throws Exception { + double baseline = 0.0; + for (int threads : THREAD_COUNTS) { + Result result = measureConcurrency(mode, op, threads); + if (threads == 1) { + baseline = result.opsPerSecond(); + } + double scalingEfficiency = result.opsPerSecond() / (threads * baseline) * 100.0; + System.out.printf( + "VESPERA_CONC %s threads=%d ops_per_sec=%.0f scaling_eff=%.1f total_ops=%d%n", + mode, threads, result.opsPerSecond(), scalingEfficiency, result.totalOps()); + } + } + + @Test + void concurrencyThroughputByMode() throws Exception { + int logicalCpus = Runtime.getRuntime().availableProcessors(); + System.out.printf( + "VESPERA_CONC cpus logical=%d warmup_seconds=%d measure_seconds=%d%n", + logicalCpus, WARMUP_SECONDS, MEASURE_SECONDS); + measureMode("sync_dispatch_bytes", ConcurrencyBenchTest::syncOnce); + measureMode("direct_pooled", ConcurrencyBenchTest::directOnce); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/DirectGateSweepBenchTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/DirectGateSweepBenchTest.java new file mode 100644 index 00000000..c0a24524 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/DirectGateSweepBenchTest.java @@ -0,0 +1,155 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * DIRECT-gate sweep — measures {@code DIRECT} vs {@code SYNC} vs + * {@code BIDIRECTIONAL_STREAMING} dispatch latency across request/response + * body sizes that straddle the {@link + * com.devfive.vespera.bridge.SmartDispatchModeResolver} 256 KiB gate, to + * find where DIRECT stops being the cheapest path. + * + *

{@code POST /echo} returns the request body verbatim, so each size is both + * the request and the response size. Gated behind {@code -Dvespera.bench=true}. + * + *

The crossover is coupled to {@code vespera.direct.maxRetainedBytes} (the + * pooled direct-buffer retention cap, default 256 KiB): a response larger + * than the cap makes every DIRECT dispatch shrink the buffer, overflow, grow, + * and re-run the handler. Re-run with + * {@code -Dvespera.direct.maxRetainedBytes=2097152} (and a matching + * {@code -Dvespera.direct.maxBufferBytes}) to see DIRECT without that penalty — + * which is the configuration a raised gate would need. + */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class DirectGateSweepBenchTest { + + private static final int[] SIZES_KIB = {64, 128, 256, 512, 1024, 1536}; + private static final Map HEADERS = + Map.of("content-type", "application/octet-stream"); + private static long blackhole; + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + } + + private static final class CountingOutputStream extends OutputStream { + long count; + + @Override + public void write(int b) { + count++; + } + + @Override + public void write(byte[] b, int off, int len) { + count += len; + } + } + + private interface Op { + int run() throws IOException; + } + + /** + * Read the status from a DIRECT response view by copying only the small + * wire header region (never the body) and decoding it — the controller + * parses the header straight from the buffer, so charging DIRECT a + * full-body copy here would be unrepresentative. + */ + private static int directStatus(ByteBuffer resp) { + ByteBuffer dup = resp.duplicate().order(ByteOrder.BIG_ENDIAN); + int headerLen = dup.getInt(0); + byte[] hdr = new byte[4 + headerLen]; + dup.position(0).get(hdr); + return VesperaBridge.decodeResponse(hdr).status(); + } + + /** Time-based per-op measurement; returns ns/op over a fixed window. */ + private static long measure(Op op, double warmupSec, double measureSec) throws IOException { + long warmEnd = System.nanoTime() + (long) (warmupSec * 1e9); + while (System.nanoTime() < warmEnd) { + if (op.run() != 200) { + throw new IllegalStateException("non-200 in warmup"); + } + } + long ops = 0; + long t0 = System.nanoTime(); + long mEnd = t0 + (long) (measureSec * 1e9); + long now = t0; + while ((now = System.nanoTime()) < mEnd) { + blackhole += op.run(); + ops++; + } + return (now - t0) / Math.max(ops, 1); + } + + @Test + void directGateSweep() throws IOException { + long retain = Long.getLong("vespera.direct.maxRetainedBytes", 256 * 1024L); + System.out.printf("VESPERA_BENCH gate_sweep config retain_bytes=%d%n", retain); + + for (int kib : SIZES_KIB) { + byte[] body = new byte[kib * 1024]; + Arrays.fill(body, (byte) 0xA5); + byte[] header = VesperaBridge.encodeRequestHeader("POST", "/echo", null, HEADERS); + + Op direct = + () -> + directStatus( + VesperaBridge.dispatchDirectPooled( + null, "POST", "/echo", null, HEADERS, body, true)); + Op sync = + () -> + VesperaBridge.decodeResponse( + VesperaBridge.dispatchBytes( + VesperaBridge.encodeRequest( + null, "POST", "/echo", null, HEADERS, + body))) + .status(); + Op bidi = + () -> { + CountingOutputStream sink = new CountingOutputStream(); + int[] st = new int[1]; + VesperaBridge.dispatchFullStreamingWithHeader( + header, + hb -> st[0] = VesperaBridge.decodeResponse(hb).status(), + new ByteArrayInputStream(body), + sink); + return st[0]; + }; + + // Interleaved 3 rounds (mode round-robin so drift hits all equally), + // median of the per-round ns/op. + String[] names = {"direct", "sync", "bidi"}; + Op[] ops = {direct, sync, bidi}; + long[][] roundNs = new long[3][3]; + for (int round = 0; round < 3; round++) { + for (int m = 0; m < 3; m++) { + roundNs[m][round] = measure(ops[m], 0.15, 0.35); + } + } + for (int m = 0; m < 3; m++) { + long[] sorted = roundNs[m].clone(); + Arrays.sort(sorted); + System.out.printf( + "VESPERA_BENCH gate_sweep size_kib=%d mode=%s ns_per_op=%d retain_bytes=%d%n", + kib, names[m], sorted[1], retain); + } + } + + if (blackhole == 0) { + throw new IllegalStateException("blackhole sink optimized away"); + } + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/DispatchDirectE2ETest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/DispatchDirectE2ETest.java new file mode 100644 index 00000000..c7206618 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/DispatchDirectE2ETest.java @@ -0,0 +1,244 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.Map; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end tests for the DirectByteBuffer dispatch path — loads the + * real {@code rust_jni_demo} cdylib (bundled into test resources by the + * vespera Gradle plugin) and proves {@code dispatchDirect*} produces + * byte-identical wire responses to {@code dispatchBytes}. + * + *

{@code /echo} round-trips the request body verbatim, so request + * size == response body size — convenient for exercising the pooled + * out-buffer growth (64 KiB initial) and the overflow protocol. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class DispatchDirectE2ETest { + + @BeforeAll + static void loadNative() { + VesperaBridge.init("rust_jni_demo"); + } + + private static byte[] echoWire(byte[] body) { + return VesperaBridge.encodeRequest( + "POST", "/echo", null, + Map.of("content-type", "application/octet-stream"), + body); + } + + private static byte[] randomBody(int size, long seed) { + byte[] body = new byte[size]; + new Random(seed).nextBytes(body); + return body; + } + + private static byte[] toArray(ByteBuffer view) { + byte[] out = new byte[view.remaining()]; + view.get(out); + return out; + } + + private static byte[] sha256(byte[] data) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(data); + } + + /** + * The DIRECT response must be semantically identical to the + * dispatchBytes response: same status, same headers, SHA256-equal + * body. (Raw wire bytes are NOT compared — the wire header JSON + * serialises a Rust HashMap whose key order is intentionally + * unspecified per response.) + */ + private static void assertDirectMatchesBytes(int bodySize, long seed) throws Exception { + byte[] wire = echoWire(randomBody(bodySize, seed)); + + VesperaBridge.DecodedResponse viaBytes = + VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)); + VesperaBridge.DecodedResponse viaDirect = + VesperaBridge.decodeResponse( + toArray(VesperaBridge.dispatchDirectPooled(wire, true))); + + assertEquals(200, viaDirect.status()); + assertEquals(viaBytes.status(), viaDirect.status(), "status"); + assertEquals(viaBytes.headers(), viaDirect.headers(), "headers"); + assertEquals(bodySize, viaDirect.body().remaining(), "body length"); + assertArrayEquals(sha256(viaBytes.bodyBytes()), sha256(viaDirect.bodyBytes()), + "body must be byte-identical for size " + bodySize); + } + + @Test + @Order(1) + void tinyBodyFitsInitialBuffer() throws Exception { + assertDirectMatchesBytes(1024, 1); + } + + @Test + @Order(2) + void mediumBodyTriggersOutBufferGrowth() throws Exception { + // 100 KiB response > 64 KiB initial out buffer → overflow → + // grow → re-dispatch (retryOnOverflow=true; /echo is safe). + assertDirectMatchesBytes(100 * 1024, 2); + } + + @Test + @Order(3) + void largeBodyWithinAxumLimit() throws Exception { + // 1.5 MiB — within axum's 2 MiB DefaultBodyLimit and the + // 4 MiB pool cap. + assertDirectMatchesBytes(1536 * 1024, 3); + } + + @Test + @Order(4) + void overflowWithoutRetryThrowsWithExactRequiredSize() { + byte[] body = randomBody(100 * 1024, 4); + byte[] wire = echoWire(body); + // Fresh thread → fresh 64 KiB pooled out buffer, guaranteed + // smaller than the ~100 KiB wire response. + VesperaBridge.BufferTooSmallException e = assertThrows( + VesperaBridge.BufferTooSmallException.class, + () -> runOnFreshThread(() -> + VesperaBridge.dispatchDirectPooled(wire, false))); + assertTrue(e.requiredSize() > 100 * 1024, + "required size must cover header + body, got " + e.requiredSize()); + } + + @Test + @Order(5) + void rawDispatchDirectHonoursExplicitInLen() throws Exception { + byte[] body = randomBody(512, 5); + byte[] wire = echoWire(body); + + // Oversized in buffer with garbage after the wire bytes — + // explicit inLen must make the tail invisible to Rust. + ByteBuffer in = ByteBuffer.allocateDirect(wire.length + 1024); + in.put(wire); + in.put(new byte[1024]); // garbage tail + ByteBuffer out = ByteBuffer.allocateDirect(64 * 1024); + + int n = VesperaBridge.dispatchDirect(in, wire.length, out); + assertTrue(n > 0, "expected success, got " + n); + + byte[] direct = new byte[n]; + out.get(0, direct); + + VesperaBridge.DecodedResponse viaDirect = VesperaBridge.decodeResponse(direct); + VesperaBridge.DecodedResponse viaBytes = + VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)); + assertEquals(viaBytes.status(), viaDirect.status(), "status"); + assertEquals(viaBytes.body().remaining(), viaDirect.body().remaining(), + "body length — a mismatch means the garbage tail leaked past inLen"); + assertArrayEquals(viaBytes.bodyBytes(), viaDirect.bodyBytes(), "body bytes"); + // Map equality — wire JSON key order is unspecified. + assertEquals(viaBytes.headers(), viaDirect.headers(), "headers"); + } + + @Test + @Order(6) + void encodeIntoOverloadMatchesByteArrayOverload() throws Exception { + // The encode-into overload must produce a semantically identical + // response to the byte[]-wire overload for the same request. + byte[] body = randomBody(100 * 1024, 6); + Map headers = Map.of("content-type", "application/octet-stream"); + + VesperaBridge.DecodedResponse viaWire = VesperaBridge.decodeResponse( + toArray(VesperaBridge.dispatchDirectPooled(echoWire(body), true))); + VesperaBridge.DecodedResponse viaEncodeInto = VesperaBridge.decodeResponse( + toArray(VesperaBridge.dispatchDirectPooled( + null, "POST", "/echo", null, headers, body, true))); + + assertEquals(viaWire.status(), viaEncodeInto.status(), "status"); + assertEquals(viaWire.headers(), viaEncodeInto.headers(), "headers"); + assertArrayEquals(sha256(viaWire.bodyBytes()), sha256(viaEncodeInto.bodyBytes()), "body"); + } + + @Test + @Order(7) + void microBenchmarkDirectVsBytes() throws Exception { + System.out.println( + "== dispatchBytes vs dispatchDirectPooled(wire) vs dispatchDirectPooled(encode-into) =="); + Map headers = Map.of("content-type", "application/octet-stream"); + for (int size : new int[] {1024, 64 * 1024, 1536 * 1024}) { + byte[] body = randomBody(size, size); + byte[] wire = echoWire(body); + int iterations = size >= 1024 * 1024 ? 200 : 1000; + + // Warm-up all paths (JIT + pool growth). + for (int i = 0; i < 50; i++) { + VesperaBridge.dispatchBytes(wire); + VesperaBridge.dispatchDirectPooled(wire, true); + VesperaBridge.dispatchDirectPooled(null, "POST", "/echo", null, headers, body, true); + } + + // FAIR comparison: real callers encode per request, so the + // byte[]-based paths pay encodeRequest inside the loop too. + long t0 = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + VesperaBridge.dispatchBytes( + VesperaBridge.encodeRequest(null, "POST", "/echo", null, headers, body)); + } + long bytesNs = (System.nanoTime() - t0) / iterations; + + t0 = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + VesperaBridge.dispatchDirectPooled( + VesperaBridge.encodeRequest(null, "POST", "/echo", null, headers, body), + true); + } + long directNs = (System.nanoTime() - t0) / iterations; + + t0 = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + VesperaBridge.dispatchDirectPooled(null, "POST", "/echo", null, headers, body, true); + } + long encodeIntoNs = (System.nanoTime() - t0) / iterations; + + System.out.printf( + "body=%8d B bytes=%9d ns direct(wire)=%9d ns direct(encodeInto)=%9d ns " + + "vsBytes=%.2fx vsWire=%.2fx%n", + size, bytesNs, directNs, encodeIntoNs, + (double) bytesNs / encodeIntoNs, (double) directNs / encodeIntoNs); + } + } + + /** Run on a fresh thread so the ThreadLocal pool starts at 64 KiB. */ + private static void runOnFreshThread(Runnable action) throws E { + Throwable[] thrown = new Throwable[1]; + Thread t = new Thread(() -> { + try { + action.run(); + } catch (Throwable e) { + thrown[0] = e; + } + }); + t.start(); + try { + t.join(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ie); + } + if (thrown[0] instanceof RuntimeException re) { + throw re; + } + if (thrown[0] != null) { + throw new IllegalStateException(thrown[0]); + } + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/JfrAllocationProfileLoadTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/JfrAllocationProfileLoadTest.java new file mode 100644 index 00000000..9ac003df --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/JfrAllocationProfileLoadTest.java @@ -0,0 +1,79 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** Sustained single-threaded JNI load for allocation profiling under JFR. */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class JfrAllocationProfileLoadTest { + + private static final int WARMUP_SECONDS = 1; + private static final int LOAD_SECONDS = 10; + private static final Map HEADERS = Map.of("accept", "application/json"); + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + } + + // Mode implementations: intentionally equivalent to SmallRequestLatencyBenchTest + // so JFR samples map to the same helper paths as the latency/allocation benches. + private static int syncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status(); + } + + private static int directOnce() { + ByteBuffer resp = + VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true); + byte[] out = new byte[resp.remaining()]; + resp.get(out); + return VesperaBridge.decodeResponse(out).status(); + } + + private interface Op { + int run() throws IOException; + } + + private static void warmup(String mode, Op op) throws IOException { + long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(WARMUP_SECONDS); + while (System.nanoTime() < until) { + if (op.run() != 200) { + throw new IllegalStateException(mode + " warmup non-200"); + } + } + } + + private static void load(String mode, Op op) throws IOException { + warmup(mode, op); + + long ops = 0; + long started = System.nanoTime(); + long until = started + TimeUnit.SECONDS.toNanos(LOAD_SECONDS); + while (System.nanoTime() < until) { + if (op.run() != 200) { + throw new IllegalStateException(mode + " load non-200"); + } + ops++; + } + double seconds = (System.nanoTime() - started) / 1_000_000_000.0; + System.out.printf( + "VESPERA_JFR_LOAD %s ops_per_sec=%.0f total_ops=%d seconds=%.2f%n", + mode, ops / seconds, ops, seconds); + } + + @Test + void sustainedSyncAndDirectLoad() throws IOException { + System.out.printf( + "VESPERA_JFR_LOAD warmup_seconds=%d load_seconds_per_mode=%d%n", + WARMUP_SECONDS, LOAD_SECONDS); + load("sync_dispatch_bytes", JfrAllocationProfileLoadTest::syncOnce); + load("direct_pooled", JfrAllocationProfileLoadTest::directOnce); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/SmallRequestLatencyBenchTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/SmallRequestLatencyBenchTest.java new file mode 100644 index 00000000..8c74d79f --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/SmallRequestLatencyBenchTest.java @@ -0,0 +1,196 @@ +package kr.go.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.devfive.vespera.bridge.VesperaBridge; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** E2E JNI latency benchmark gated behind {@code -Dvespera.bench=true}. */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class SmallRequestLatencyBenchTest { + + private static final int WARMUP = 20_000; + private static final int ITERS = 100_000; + private static final Map HEADERS = Map.of("accept", "application/json"); + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + } + + private static final class CountingOutputStream extends OutputStream { + long count; + + @Override + public void write(int b) { + count++; + } + + @Override + public void write(byte[] b, int off, int len) { + count += len; + } + } + + private static int syncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status(); + } + + private static int directOnce() { + ByteBuffer resp = + VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true); + // Consume like the controller does: header region must be parsed. + byte[] out = new byte[resp.remaining()]; + resp.get(out); + return VesperaBridge.decodeResponse(out).status(); + } + + private static int streamingOnce() throws IOException { + byte[] wireHeader = VesperaBridge.encodeRequestHeader("GET", "/health", null, HEADERS); + CountingOutputStream sink = new CountingOutputStream(); + int[] status = new int[1]; + VesperaBridge.dispatchFullStreamingWithHeader( + wireHeader, + headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(), + new ByteArrayInputStream(new byte[0]), + sink); + return status[0]; + } + + /** + * Async-then-synchronously-block — the WORST case for {@code CompletableFuture}. + * The ~15us/op this measures is dominated (~5-8us) by the caller thread parking + * on {@code future.get()} and being woken cross-thread after the Rust Tokio + * worker completes the future: OS-scheduler park/unpark latency, NOT Rust + * dispatch cost (~2us — see the sync/direct/streaming modes). Real async + * consumers chain continuations ({@code thenApply}/{@code thenCompose}) and + * never pay this park/wake. Treat this mode's absolute number as a cross-thread + * handoff-latency probe, not a dispatch-cost regression signal — watch the + * ratios and the other modes for dispatch regressions. + */ + private static int asyncOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + CompletableFuture future = new CompletableFuture<>(); + VesperaBridge.dispatchAsync(future, wire); + try { + byte[] resp = future.get(30, TimeUnit.SECONDS); + return VesperaBridge.decodeResponse(resp).status(); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new RuntimeException(e); + } + } + + /** Response-streaming only — no request pull thread (empty body inline). */ + private static int responseStreamingOnce() { + byte[] wire = VesperaBridge.encodeRequest(null, "GET", "/health", null, HEADERS, null); + CountingOutputStream sink = new CountingOutputStream(); + int[] status = new int[1]; + VesperaBridge.dispatchStreamingWithHeader( + wire, + headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(), + sink); + return status[0]; + } + + private interface Op { + int run() throws IOException; + } + + /** + * Interleaved, median-of-blocks latency measurement. + * + *

Modes are measured round-robin in small blocks instead of one long + * run each, so machine drift (CPU boost / thermal / background load) hits + * every mode equally within a round — the cross-mode RATIOS become + * noise-robust even when absolute ns/op drift {@code ±10%} run-to-run. Per + * mode the MEDIAN of the per-block ns/op is reported, which is robust to + * GC-pause outlier blocks. This is what makes the numbers trustworthy + * enough to watch for regressions in CI (see {@code jni-bench.yml}). + */ + private static long[] measureInterleaved(String[] names, Op[] ops) throws IOException { + final int rounds = 100; + final int block = ITERS / rounds; // 1000 iters/block, 100 blocks/mode + + // Warm up every mode fully (JIT, code cache) before any measurement. + for (int m = 0; m < ops.length; m++) { + for (int i = 0; i < WARMUP; i++) { + assertEquals(200, ops[m].run(), names[m] + " warmup status"); + } + } + + long[][] blockNs = new long[ops.length][rounds]; + long blackhole = 0; + for (int r = 0; r < rounds; r++) { + for (int m = 0; m < ops.length; m++) { + long t0 = System.nanoTime(); + for (int i = 0; i < block; i++) { + blackhole += ops[m].run(); + } + blockNs[m][r] = (System.nanoTime() - t0) / block; + } + } + if (blackhole == 0) { + throw new IllegalStateException("blackhole sink optimized away"); + } + + long[] medianNs = new long[ops.length]; + for (int m = 0; m < ops.length; m++) { + long[] sorted = blockNs[m].clone(); + java.util.Arrays.sort(sorted); + medianNs[m] = sorted[sorted.length / 2]; + System.out.printf( + "VESPERA_BENCH small_request mode=%s ns_per_op=%d" + + " (interleaved median rounds=%d block=%d)%n", + names[m], medianNs[m], rounds, block); + } + return medianNs; + } + + @Test + void smallRequestLatencyByMode() throws IOException { + String[] names = { + "sync_dispatch_bytes", + "direct_pooled", + "response_streaming_only", + "bidirectional_streaming", + "async_completable_future", + }; + Op[] ops = { + SmallRequestLatencyBenchTest::syncOnce, + SmallRequestLatencyBenchTest::directOnce, + SmallRequestLatencyBenchTest::responseStreamingOnce, + SmallRequestLatencyBenchTest::streamingOnce, + SmallRequestLatencyBenchTest::asyncOnce, + }; + long[] ns = measureInterleaved(names, ops); + long sync = ns[0]; + long direct = ns[1]; + long respStreaming = ns[2]; + long streaming = ns[3]; + long async = ns[4]; + + // Cross-mode ratios are the NOISE-ROBUST regression signal: every mode + // was measured under the same interleaved machine state, so these + // ratios stay stable run-to-run even when absolute ns/op drift ±10%. + System.out.printf( + "VESPERA_BENCH summary direct_vs_streaming=%.2fx direct_vs_sync=%.2fx" + + " resp_only_vs_bidi=%.2fx async_vs_sync=%.2fx async_vs_direct=%.2fx%n", + (double) streaming / direct, + (double) sync / direct, + (double) streaming / respStreaming, + (double) async / sync, + (double) async / direct); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingClosureStressTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingClosureStressTest.java new file mode 100644 index 00000000..347466a5 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingClosureStressTest.java @@ -0,0 +1,453 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * SIGSEGV gate for the cached + * {@code call_method_unchecked} JNI fast path landed in + * {@code crates/vespera_jni/src/streaming_closures.rs}. + * + *

Stress-tests the four cached Java {@code JMethodID}s the new + * code exercises on every streaming/async dispatch: + *

    + *
  • {@code java/io/InputStream.read([B)I} — pulled by + * {@link VesperaBridge#dispatchFullStreaming}
  • + *
  • {@code java/io/OutputStream.write([BII)V} — pushed by + * {@code dispatchFullStreaming} and + * {@code dispatchStreamingWithHeader}
  • + *
  • {@code java/util/function/Consumer.accept(Ljava/lang/Object;)V} — + * header callback fired by {@code dispatchStreamingWithHeader} + * before the first body byte reaches the {@code OutputStream}
  • + *
  • {@code java/util/concurrent/CompletableFuture.complete(Ljava/lang/Object;)Z} — + * async completion path used by {@code dispatchAsync}
  • + *
+ * + *

If any of those cached method IDs resolves the wrong + * class / signature / vtable slot, calling them through + * {@code call_method_unchecked} will SIGSEGV the test JVM — and the + * abnormal Gradle worker shutdown IS the test failure signal. + * The prior E2E run never exercised the cached path because + * {@code StreamingThroughputBenchTest} is gated behind + * {@code -Dvespera.bench=true} (see {@code @EnabledIfSystemProperty}). + * This test runs unconditionally as part of the normal {@code test} + * task to lock that gap. + * + *

Verification per iteration: + *

    + *
  • Random 1 MiB body driven by a single shared {@link Random} + * seed ({@code SEED}) for deterministic replay.
  • + *
  • SHA-256 of the body that left the JVM == SHA-256 of the body + * that came back through {@code /echo/stream}.
  • + *
  • For the bidirectional path: {@code InputStream.read} fired + * multiple times (multi-chunk pull) AND {@code OutputStream.write} + * fired multiple times (multi-chunk push), proving the cached + * method IDs were called repeatedly per dispatch. With the + * default 256 KiB streaming chunk size and a 1 MiB payload the + * Rust side performs ~4 pulls + 1 EOF read and ~4 pushes + * per iteration. (Assertions only require {@code > 1} — they + * are robust to chunk-size tuning down to 4 KiB or up to + * 512 KiB; below the multi-chunk threshold the test would need + * to bump the payload.)
  • + *
  • For the header-streaming path: {@code Consumer.accept} fires + * exactly once and before the + * first {@code OutputStream.write}; header decodes as wire JSON + * with status 200.
  • + *
  • For the async path: {@code CompletableFuture} completes + * successfully with a valid wire response (status 200, body + * matches by SHA-256).
  • + *
+ * + *

Iteration budget — sized to keep wall-clock for + * the whole class comfortably under ~90s on a normal developer machine + * while pushing the cached paths thousands of times: + *

    + *
  • {@code dispatchFullStreaming}: {@value #BIDI_ITERATIONS} × 1 MiB + * → ~4 000 cached {@code InputStream.read} calls + ~4 000 + * cached {@code OutputStream.write} calls (with the 256 KiB + * default chunk; was ~16 000 each at the prior 64 KiB default)
  • + *
  • {@code dispatchStreamingWithHeader}: {@value #HEADER_STREAMING_ITERATIONS} + * × 1 MiB → ~{@value #HEADER_STREAMING_ITERATIONS} cached + * {@code Consumer.accept} calls + ~2 000 cached + * {@code OutputStream.write} calls
  • + *
  • {@code dispatchAsync}: {@value #ASYNC_ITERATIONS} × 1 MiB → + * {@value #ASYNC_ITERATIONS} cached + * {@code CompletableFuture.complete} calls
  • + *
+ * + *

If a slower machine pushes the run over ~90s, drop these constants + * to 500 / 250 / 250 — the cached path is exercised plenty even at the + * lower budget; the higher budget is just a wider net for races. + * Per-test wall-clock is printed to stdout so reductions are + * data-driven. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class StreamingClosureStressTest { + + /** Shared seed so any failure replays deterministically. */ + private static final long SEED = 0xCAFEBABEL; + + /** 1 MiB — well above the default 256 KiB streaming chunk so each + * dispatch pulls/pushes ~4 chunks, exercising the cached path + * several times per call. Assertions only require {@code > 1} + * chunk so the test stays valid across the supported chunk-size + * range (4 KiB – 8 MiB). */ + private static final int PAYLOAD_BYTES = 1024 * 1024; + + private static final int BIDI_ITERATIONS = 1000; + private static final int HEADER_STREAMING_ITERATIONS = 500; + private static final int ASYNC_ITERATIONS = 500; + + private static final Map ECHO_HEADERS = + Map.of("content-type", "application/octet-stream"); + + /** Bound the async wait so a SIGSEGV-induced hang fails fast + * instead of stalling the Gradle worker until its own timeout. */ + private static final long ASYNC_TIMEOUT_SECONDS = 30; + + @BeforeAll + static void loadNative() { + VesperaBridge.init("rust_jni_demo"); + } + + private static byte[] sha256(byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private static byte[] randomPayload(Random rng) { + byte[] body = new byte[PAYLOAD_BYTES]; + rng.nextBytes(body); + return body; + } + + /** Counts {@code read(byte[])} invocations — the exact signature + * cached by {@code streaming_closures::call_input_stream_read}. */ + private static final class CountingInputStream extends InputStream { + private final InputStream delegate; + int readArrayCalls; + + CountingInputStream(InputStream delegate) { + this.delegate = delegate; + } + + @Override + public int read() throws IOException { + // Not on the cached path — but counted defensively in case + // the Rust side ever falls back to single-byte reads. + return delegate.read(); + } + + @Override + public int read(byte[] b) throws IOException { + readArrayCalls++; + return delegate.read(b); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + // Not on the cached path — Rust calls the no-offset overload. + return delegate.read(b, off, len); + } + } + + /** Counts {@code write(byte[], int, int)} invocations — the exact + * signature cached by + * {@code streaming_closures::call_output_stream_write}. */ + private static final class CountingByteSink extends OutputStream { + final ByteArrayOutputStream buf = new ByteArrayOutputStream(PAYLOAD_BYTES); + int writeRegionCalls; + + @Override + public void write(int b) { + // Not on the cached path; included for completeness. + buf.write(b); + } + + @Override + public void write(byte[] b, int off, int len) { + writeRegionCalls++; + buf.write(b, off, len); + } + + byte[] toBytes() { + return buf.toByteArray(); + } + + int size() { + return buf.size(); + } + } + + /** + * Exercises cached {@code InputStream.read([B)I} AND cached + * {@code OutputStream.write([BII)V} repeatedly per dispatch. + */ + @Test + @Order(1) + void bidirectionalStreaming_cachedReadAndWrite() throws Exception { + Random rng = new Random(SEED); + byte[] wireHeader = VesperaBridge.encodeRequestHeader( + "POST", "/echo/stream", null, ECHO_HEADERS); + + long totalReads = 0; + long totalWrites = 0; + long t0 = System.nanoTime(); + + for (int i = 0; i < BIDI_ITERATIONS; i++) { + byte[] payload = randomPayload(rng); + byte[] expectedSha = sha256(payload); + + CountingInputStream src = new CountingInputStream(new ByteArrayInputStream(payload)); + CountingByteSink sink = new CountingByteSink(); + + byte[] respHeader = + VesperaBridge.dispatchFullStreaming(wireHeader, src, sink); + VesperaBridge.DecodedResponse resp = VesperaBridge.decodeResponse(respHeader); + + assertEquals(200, resp.status(), + "iter " + i + ": echo must succeed (status)"); + assertEquals(PAYLOAD_BYTES, sink.size(), + "iter " + i + ": echoed byte count"); + assertArrayEquals(expectedSha, sha256(sink.toBytes()), + "iter " + i + ": SHA-256 round-trip"); + assertTrue(src.readArrayCalls > 1, + "iter " + i + ": expected multi-chunk pulls through cached" + + " InputStream.read, got " + src.readArrayCalls); + assertTrue(sink.writeRegionCalls > 1, + "iter " + i + ": expected multi-chunk pushes through cached" + + " OutputStream.write, got " + sink.writeRegionCalls); + + totalReads += src.readArrayCalls; + totalWrites += sink.writeRegionCalls; + } + + long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; + System.out.printf( + "STRESS bidi(/echo/stream): iter=%d payload=%dB elapsed=%dms" + + " cachedReads=%d cachedWrites=%d (avg/iter %.1f reads, %.1f writes)%n", + BIDI_ITERATIONS, PAYLOAD_BYTES, elapsedMs, + totalReads, totalWrites, + (double) totalReads / BIDI_ITERATIONS, + (double) totalWrites / BIDI_ITERATIONS); + } + + /** + * Exercises cached {@code Consumer.accept(Ljava/lang/Object;)V} + * (once per dispatch, before any body byte) and cached + * {@code OutputStream.write([BII)V} (many times per dispatch). + */ + @Test + @Order(2) + void responseStreamingWithHeader_cachedConsumerAndWrite() throws Exception { + Random rng = new Random(SEED); + long totalHeaderCalls = 0; + long totalWrites = 0; + long t0 = System.nanoTime(); + + for (int i = 0; i < HEADER_STREAMING_ITERATIONS; i++) { + byte[] payload = randomPayload(rng); + byte[] expectedSha = sha256(payload); + + byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", "/echo/stream", null, ECHO_HEADERS, payload); + + CountingByteSink sink = new CountingByteSink(); + AtomicInteger headerCalls = new AtomicInteger(); + AtomicReference headerBytesRef = new AtomicReference<>(); + // -1 sentinel; captured value MUST be 0 (no writes yet when + // the header consumer is called). + AtomicLong writesAtHeaderTime = new AtomicLong(-1); + + VesperaBridge.dispatchStreamingWithHeader( + wireRequest, + headerBytes -> { + writesAtHeaderTime.set(sink.writeRegionCalls); + // Copy because the JNI side may reuse the array. + headerBytesRef.set(headerBytes.clone()); + headerCalls.incrementAndGet(); + }, + sink); + + assertEquals(1, headerCalls.get(), + "iter " + i + ": header consumer must fire exactly once"); + assertEquals(0L, writesAtHeaderTime.get(), + "iter " + i + ": header consumer must fire BEFORE any" + + " OutputStream.write"); + byte[] hdr = headerBytesRef.get(); + assertNotNull(hdr, "iter " + i + ": header bytes captured"); + + VesperaBridge.DecodedResponse resp = VesperaBridge.decodeResponse(hdr); + assertEquals(200, resp.status(), + "iter " + i + ": wire header parses with status 200"); + assertEquals(PAYLOAD_BYTES, sink.size(), + "iter " + i + ": echoed byte count"); + assertArrayEquals(expectedSha, sha256(sink.toBytes()), + "iter " + i + ": SHA-256 round-trip"); + assertTrue(sink.writeRegionCalls > 1, + "iter " + i + ": expected multi-chunk pushes through cached" + + " OutputStream.write, got " + sink.writeRegionCalls); + + totalHeaderCalls += headerCalls.get(); + totalWrites += sink.writeRegionCalls; + } + + long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; + System.out.printf( + "STRESS header-stream(/echo/stream): iter=%d payload=%dB elapsed=%dms" + + " cachedConsumerCalls=%d cachedWrites=%d (avg/iter %.1f writes)%n", + HEADER_STREAMING_ITERATIONS, PAYLOAD_BYTES, elapsedMs, + totalHeaderCalls, totalWrites, + (double) totalWrites / HEADER_STREAMING_ITERATIONS); + } + + /** + * Exercises cached + * {@code CompletableFuture.complete(Ljava/lang/Object;)Z}. + */ + @Test + @Order(3) + void asyncDispatch_cachedFutureComplete() throws Exception { + Random rng = new Random(SEED); + long t0 = System.nanoTime(); + + for (int i = 0; i < ASYNC_ITERATIONS; i++) { + byte[] payload = randomPayload(rng); + byte[] expectedSha = sha256(payload); + + byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", "/echo/stream", null, ECHO_HEADERS, payload); + + CompletableFuture future = new CompletableFuture<>(); + VesperaBridge.dispatchAsync(future, wireRequest); + + byte[] wireResponse; + try { + wireResponse = future.get(ASYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException te) { + fail("iter " + i + ": dispatchAsync future did not complete within " + + ASYNC_TIMEOUT_SECONDS + "s"); + return; // unreachable; keeps the compiler happy + } + + assertNotNull(wireResponse, + "iter " + i + ": future must complete with non-null payload"); + assertTrue(future.isDone() && !future.isCompletedExceptionally(), + "iter " + i + ": future must be normally completed"); + + VesperaBridge.DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse); + assertEquals(200, resp.status(), "iter " + i + ": status"); + assertEquals(PAYLOAD_BYTES, resp.body().remaining(), + "iter " + i + ": body length"); + assertArrayEquals(expectedSha, sha256(resp.bodyBytes()), + "iter " + i + ": SHA-256 round-trip"); + } + + long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; + System.out.printf( + "STRESS async(/echo/stream): iter=%d payload=%dB elapsed=%dms" + + " cachedFutureCompleteCalls=%d%n", + ASYNC_ITERATIONS, PAYLOAD_BYTES, elapsedMs, ASYNC_ITERATIONS); + } + + /** + * Handler-panic fallback: {@code /echo/panic} panics before producing + * status/headers. The "header consumer invoked exactly once on every + * code path" contract requires {@code dispatchStreamingWithHeader} to + * still fire the consumer — with a wire-format {@code 500} header (the + * Rust-side {@code header_sent} fallback) — instead of leaving this + * caller hanging. Guards the JNI catch_unwind + fallback path that + * has no Rust-level unit test (it needs a real JVM). + */ + @Test + @Order(4) + void responseStreamingWithHeader_handlerPanic_firesHeaderWith500() { + byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", "/echo/panic", null, ECHO_HEADERS, new byte[] {1, 2, 3}); + + CountingByteSink sink = new CountingByteSink(); + AtomicInteger headerCalls = new AtomicInteger(); + AtomicReference headerBytesRef = new AtomicReference<>(); + + VesperaBridge.dispatchStreamingWithHeader( + wireRequest, + headerBytes -> { + headerBytesRef.set(headerBytes.clone()); + headerCalls.incrementAndGet(); + }, + sink); + + assertEquals(1, headerCalls.get(), + "header consumer must fire exactly once even when the handler panics"); + byte[] hdr = headerBytesRef.get(); + assertNotNull(hdr, "header bytes must be captured on a handler panic"); + VesperaBridge.DecodedResponse resp = VesperaBridge.decodeResponse(hdr); + assertEquals(500, resp.status(), + "a panic before the header must surface as a 500 header, not a hang"); + assertEquals(0, sink.size(), + "no body should be written when the handler panics before headers"); + } + + /** + * Push failed-flag: a hostile/broken {@code OutputStream} that throws + * on every write must not hang or SIGSEGV the JVM. The Rust push + * closure latches a {@code failed} flag on the first write failure and + * turns subsequent frames into a no-op instead of repeatedly crossing + * JNI into the broken sink; the dispatch still returns the wire header. + */ + @Test + @Order(5) + void responseStreaming_outputStreamThrows_doesNotHangOrCrash() { + byte[] payload = randomPayload(new Random(SEED)); + byte[] wireRequest = VesperaBridge.encodeRequest( + "POST", "/echo/stream", null, ECHO_HEADERS, payload); + + OutputStream throwing = new OutputStream() { + @Override + public void write(int b) throws IOException { + throw new IOException("sink closed"); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + throw new IOException("sink closed"); + } + }; + + byte[] respHeader = VesperaBridge.dispatchStreaming(wireRequest, throwing); + assertNotNull(respHeader, + "dispatch must return a header even when the OutputStream throws"); + VesperaBridge.DecodedResponse resp = VesperaBridge.decodeResponse(respHeader); + assertEquals(200, resp.status(), + "the handler succeeded (200); only the JVM sink failed"); + } +} diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingThroughputBenchTest.java b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingThroughputBenchTest.java new file mode 100644 index 00000000..b1952ab2 --- /dev/null +++ b/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/StreamingThroughputBenchTest.java @@ -0,0 +1,109 @@ +package kr.go.demo; + +import com.devfive.vespera.bridge.VesperaBridge; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Map; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * E2E streaming throughput benchmark through the REAL JNI boundary — + * measures {@code dispatchFullStreamingWithHeader} (the autoconfigured + * default dispatch mode) round-tripping a large body through the Rust + * {@code /echo} route. + * + *

The streaming chunk size is process-fixed after + * the first dispatch, so each chunk size needs its own JVM. Run via: + * + *

+ *   ./gradlew :demo-app:test --tests "*StreamingThroughputBenchTest*" \
+ *       -Dvespera.bench=true -Dvespera.streaming.chunkBytes=16384
+ * 
+ * + *

Gated behind {@code -Dvespera.bench=true} so normal test runs and + * CI skip it. + */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class StreamingThroughputBenchTest { + + private static final int PAYLOAD_BYTES = 64 * 1024 * 1024; // 64 MiB + private static final int WARMUP_ITERATIONS = 3; + private static final int MEASURE_ITERATIONS = 10; + + private static byte[] payload; + + @BeforeAll + static void setUp() { + VesperaBridge.init("rust_jni_demo"); + payload = new byte[PAYLOAD_BYTES]; + new Random(42).nextBytes(payload); + } + + /** OutputStream that counts bytes without storing them. */ + private static final class CountingOutputStream extends OutputStream { + long count; + + @Override + public void write(int b) { + count++; + } + + @Override + public void write(byte[] b, int off, int len) { + count += len; + } + } + + private static long roundTripOnce() throws IOException { + byte[] wireHeader = VesperaBridge.encodeRequestHeader( + "POST", "/echo/stream", null, + Map.of("content-type", "application/octet-stream")); + CountingOutputStream sink = new CountingOutputStream(); + int[] status = new int[1]; + VesperaBridge.dispatchFullStreamingWithHeader( + wireHeader, + headerBytes -> status[0] = VesperaBridge.decodeResponse(headerBytes).status(), + new ByteArrayInputStream(payload), + sink); + assertEquals(200, status[0], "echo status"); + assertEquals(PAYLOAD_BYTES, sink.count, "echoed byte count"); + return sink.count; + } + + @Test + void bidirectionalStreamingThroughput() throws IOException { + String chunkProp = System.getProperty("vespera.streaming.chunkBytes", "default(262144)"); + + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + roundTripOnce(); + } + + double[] mibPerSec = new double[MEASURE_ITERATIONS]; + for (int i = 0; i < MEASURE_ITERATIONS; i++) { + long t0 = System.nanoTime(); + roundTripOnce(); + long elapsedNs = System.nanoTime() - t0; + // Bidirectional: payload travels Java→Rust AND Rust→Java. + mibPerSec[i] = (PAYLOAD_BYTES / (1024.0 * 1024.0)) / (elapsedNs / 1_000_000_000.0); + } + + double mean = 0; + for (double v : mibPerSec) mean += v; + mean /= MEASURE_ITERATIONS; + double var = 0; + for (double v : mibPerSec) var += (v - mean) * (v - mean); + double stddev = Math.sqrt(var / MEASURE_ITERATIONS); + + System.out.printf( + "VESPERA_BENCH chunkBytes=%s payload=%d MiB iterations=%d" + + " throughput=%.1f MiB/s stddev=%.1f%n", + chunkProp, PAYLOAD_BYTES / (1024 * 1024), MEASURE_ITERATIONS, mean, stddev); + } +} diff --git a/examples/rust-jni-demo/src/routes/echo.rs b/examples/rust-jni-demo/src/routes/echo.rs index 2a77340b..e4baa0de 100644 --- a/examples/rust-jni-demo/src/routes/echo.rs +++ b/examples/rust-jni-demo/src/routes/echo.rs @@ -23,3 +23,29 @@ pub async fn echo(headers: HeaderMap, body: Bytes) -> Response { .to_owned(); ([(header::CONTENT_TYPE, ct)], body).into_response() } + +/// **Streaming** echo — passes the request body stream straight +/// through as the response body without ever buffering it. Unlike +/// `/echo` (which extracts `Bytes` and is therefore subject to axum's +/// 2 MiB `DefaultBodyLimit`), this handler consumes the raw +/// [`vespera::axum::body::Body`], so multi-GiB bidirectional streams +/// can be exercised end-to-end — used by the JNI streaming throughput +/// benchmark (`StreamingThroughputBenchTest`). +#[allow(clippy::unused_async)] +#[vespera::route(post, path = "/stream", tags = ["echo"])] +pub async fn echo_stream(body: vespera::axum::body::Body) -> Response { + Response::new(body) +} + +/// Always panics — exercises the JNI "header callback exactly once" +/// contract from the Java side. When this handler panics before +/// producing status/headers, `dispatchStreamingWithHeader` / +/// `dispatchFullStreamingWithHeader` must still invoke the header +/// consumer once with a wire-format `500` header (the `header_sent` +/// fallback) rather than leaving the caller hanging. Used by +/// `StreamingClosureStressTest`'s panic-fallback e2e case. +#[allow(clippy::unused_async, clippy::panic)] +#[vespera::route(post, path = "/panic", tags = ["echo"])] +pub async fn echo_panic() -> Response { + panic!("intentional handler panic for the header-once fallback e2e test"); +} diff --git a/examples/third/Cargo.lock b/examples/third/Cargo.lock deleted file mode 100644 index eeace9b2..00000000 --- a/examples/third/Cargo.lock +++ /dev/null @@ -1,1591 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-example" -version = "0.1.0" -dependencies = [ - "axum", - "axum-test", - "serde", - "serde_json", - "tokio", - "vespera", -] - -[[package]] -name = "axum-test" -version = "18.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0388808c0617a886601385c0024b9d0162480a763ba371f803d87b775115400" -dependencies = [ - "anyhow", - "axum", - "bytes", - "bytesize", - "cookie", - "expect-json", - "http", - "http-body-util", - "hyper", - "hyper-util", - "mime", - "pretty_assertions", - "reserve-port", - "rust-multipart-rfc7578_2", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "tokio", - "tower", - "url", -] - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "bytesize" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f4369ba008f82b968b1acbe31715ec37bd45236fa0726605a36cc3060ea256" - -[[package]] -name = "cc" -version = "1.2.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" -dependencies = [ - "serde", -] - -[[package]] -name = "erased-serde" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "expect-json" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7519e78573c950576b89eb4f4fe82aedf3a80639245afa07e3ee3d199dcdb29e" -dependencies = [ - "chrono", - "email_address", - "expect-json-macros", - "num", - "serde", - "serde_json", - "thiserror", - "typetag", - "uuid", -] - -[[package]] -name = "expect-json-macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bf7f5979e98460a0eb412665514594f68f366a32b85fa8d7ffb65bb1edee6a0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-core", - "futures-io", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-util" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "inventory" -version = "0.3.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" -dependencies = [ - "rustversion", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.177" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "reserve-port" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" -dependencies = [ - "thiserror", -] - -[[package]] -name = "rust-multipart-rfc7578_2" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" -dependencies = [ - "bytes", - "futures-core", - "futures-util", - "http", - "mime", - "rand", - "thiserror", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" -dependencies = [ - "libc", -] - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "syn" -version = "2.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "time" -version = "0.3.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - -[[package]] -name = "time-macros" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typetag" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" -dependencies = [ - "erased-serde", - "inventory", - "once_cell", - "serde", - "typetag-impl", -] - -[[package]] -name = "typetag-impl" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "url" -version = "2.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vespera" -version = "0.1.0" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn", - "vespera_core", -] - -[[package]] -name = "vespera_core" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/examples/third/Cargo.toml b/examples/third/Cargo.toml index 653a595b..bdfdce44 100644 --- a/examples/third/Cargo.toml +++ b/examples/third/Cargo.toml @@ -14,6 +14,6 @@ serde_json = "1" vespera = { path = "../../crates/vespera" } [dev-dependencies] -axum-test = "20.0" -insta = "1.47" +axum-test = "20.1" +insta = "1.48" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 00000000..919d1a31 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,603 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vespera-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "serde_json", + "tokio", + "vespera_inprocess", +] + +[[package]] +name = "vespera_inprocess" +version = "0.2.0" +dependencies = [ + "axum", + "bytes", + "http", + "http-body", + "http-body-util", + "serde", + "serde_json", + "tokio", + "tower", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 00000000..5da502d9 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,37 @@ +# Coverage-guided fuzzing for the wire trust boundary. +# +# Isolated from the root workspace via the empty `[workspace]` table +# below, so `cargo build --workspace` / `cargo test --workspace` at the +# repo root NEVER touch it — it builds only under `cargo fuzz` (nightly +# + libFuzzer; Linux/macOS). The deterministic, portable counterpart +# that DOES run under `cargo test` on every platform lives in +# `crates/vespera_inprocess/tests/wire_robustness.rs`. +# +# Run (Linux/macOS, requires `cargo install cargo-fuzz` + a nightly +# toolchain): +# cargo +nightly fuzz run wire_dispatch +[package] +name = "vespera-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +vespera_inprocess = { path = "../crates/vespera_inprocess" } +tokio = { version = "1", features = ["rt"] } +serde_json = "1" + +[[bin]] +name = "wire_dispatch" +path = "fuzz_targets/wire_dispatch.rs" +test = false +doc = false +bench = false + +# Empty table → this crate is its own workspace root, isolated from the +# repository's root workspace. +[workspace] diff --git a/fuzz/fuzz_targets/wire_dispatch.rs b/fuzz/fuzz_targets/wire_dispatch.rs new file mode 100644 index 00000000..b92c906e --- /dev/null +++ b/fuzz/fuzz_targets/wire_dispatch.rs @@ -0,0 +1,55 @@ +#![no_main] +//! Coverage-guided fuzz target for the binary wire trust boundary. +//! +//! libFuzzer feeds arbitrary bytes straight into +//! [`vespera_inprocess::dispatch_from_bytes`] and explores the parser; +//! the wire contract is asserted so any violation aborts and is +//! recorded as a reproducible crash: +//! +//! * it must **never panic** (no OOB / overflow / unwrap reachable from +//! hostile input), and +//! * it must **always return a well-formed length-prefixed wire +//! response** whose header is valid JSON carrying a numeric `status`. +//! +//! Run (Linux/macOS, nightly + `cargo install cargo-fuzz`): +//! ```text +//! cargo +nightly fuzz run wire_dispatch +//! ``` +//! +//! The portable, deterministic counterpart that runs under plain +//! `cargo test` on every platform is +//! `crates/vespera_inprocess/tests/wire_robustness.rs`. + +use std::sync::OnceLock; + +use libfuzzer_sys::fuzz_target; +use tokio::runtime::{Builder, Runtime}; +use vespera_inprocess::dispatch_from_bytes; + +fn runtime() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + Builder::new_current_thread() + .enable_all() + .build() + .expect("build current-thread runtime") + }) +} + +fuzz_target!(|data: &[u8]| { + let resp = dispatch_from_bytes(data.to_vec(), runtime()); + + // Contract — a violation here is a crash libFuzzer records for replay. + assert!(resp.len() >= 4, "response shorter than 4-byte length prefix"); + let header_len = u32::from_be_bytes(resp[..4].try_into().unwrap()) as usize; + assert!( + 4 + header_len <= resp.len(), + "header_len overflows response" + ); + let header: serde_json::Value = + serde_json::from_slice(&resp[4..4 + header_len]).expect("response header valid JSON"); + assert!( + header.get("status").and_then(serde_json::Value::as_u64).is_some(), + "response header carries a numeric status" + ); +}); diff --git a/libs/vespera-bridge-gradle-plugin/build.gradle.kts b/libs/vespera-bridge-gradle-plugin/build.gradle.kts index a5872120..5794248e 100644 --- a/libs/vespera-bridge-gradle-plugin/build.gradle.kts +++ b/libs/vespera-bridge-gradle-plugin/build.gradle.kts @@ -1,7 +1,12 @@ +import com.vanniktech.maven.publish.GradlePublishPlugin + plugins { `java-gradle-plugin` `kotlin-dsl` id("com.vanniktech.maven.publish") version "0.36.0" + // Gradle Plugin Portal publishing (`publishPlugins` task). Credentials + // come from GRADLE_PUBLISH_KEY / GRADLE_PUBLISH_SECRET env vars in CI. + id("com.gradle.plugin-publish") version "2.1.1" } group = "kr.devfive" @@ -23,6 +28,10 @@ repositories { } gradlePlugin { + // Required by the Plugin Portal (`com.gradle.plugin-publish`). + website.set("https://github.com/dev-five-git/vespera") + vcsUrl.set("https://github.com/dev-five-git/vespera.git") + plugins { create("vesperaBridge") { id = "kr.devfive.vespera-bridge" @@ -49,6 +58,11 @@ mavenPublishing { publishToMavenCentral(automaticRelease = true) if (shouldSign) signAllPublications() + // `com.gradle.plugin-publish` owns the sources/javadoc jars in this + // setup — vanniktech docs mandate GradlePublishPlugin (not GradlePlugin) + // when both plugins are applied, to avoid duplicate jar registration. + configure(GradlePublishPlugin()) + coordinates( groupId = "kr.devfive", artifactId = "vespera-bridge-gradle-plugin", diff --git a/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgeExtension.kt b/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgeExtension.kt index 92a33cf2..cba7def6 100644 --- a/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgeExtension.kt +++ b/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgeExtension.kt @@ -1,6 +1,7 @@ package kr.devfive.vespera import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property /** @@ -9,10 +10,11 @@ import org.gradle.api.provider.Property * ```kotlin * vespera { * crateName.set("my_rust_lib") - * cargoRoot.set(rootProject.layout.projectDirectory.dir("../..")) - * bridgeVersion.set("0.0.15") - * autoBuildCargo.set(false) // default: opt-in - * } + * cargoRoot.set(rootProject.layout.projectDirectory.dir("../..")) + * cargoSourceRoots.add("apps/native") + * bridgeVersion.set("") + * autoBuildCargo.set(false) // default: opt-in + * } * ``` */ abstract class VesperaBridgeExtension { @@ -30,6 +32,16 @@ abstract class VesperaBridgeExtension { */ abstract val cargoRoot: DirectoryProperty + /** + * Cargo source roots, relative to {@link #cargoRoot}, watched by the + * optional {@code cargoBuild} task. Each root contributes + * {@code /**/*.rs}; the plugin also always watches every + * {@code Cargo.toml} and {@code Cargo.lock}. Defaults cover a single + * crate ({@code src}) plus this repository's workspace layout + * ({@code crates}, {@code examples}). + */ + abstract val cargoSourceRoots: ListProperty + /** * Version of `kr.devfive:vespera-bridge` to add as an * `implementation` dependency. Must be set explicitly — the @@ -45,4 +57,25 @@ abstract class VesperaBridgeExtension { * Java build to invoke cargo implicitly. */ abstract val autoBuildCargo: Property + + /** + * Cargo build profile selecting both the output subdirectory and the + * build flag. `"release"` (default) → `cargo build --release` → + * `target/release/`; `"dev"` (or `"debug"`) → plain `cargo build` → + * `target/debug/`; any other `"

"` → `cargo build --profile

` → + * `target/

/`. Lets debug or custom-profile cdylibs be bundled + * without hand-editing the plugin (the previous hardcoded `--release` + * forced every consumer onto the release profile). + */ + abstract val cargoProfile: Property + + /** + * Base Cargo target directory that holds the per-profile output + * subdirectory. Defaults to `/target`. Set this when the + * workspace redirects Cargo output via `CARGO_TARGET_DIR` or a + * `.cargo/config.toml` `build.target-dir`, so the plugin locates the + * cdylib (and, when `autoBuildCargo` is on, directs cargo's output) + * at the right place instead of failing on the default `target/`. + */ + abstract val targetDir: DirectoryProperty } diff --git a/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgePlugin.kt b/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgePlugin.kt index 4de00531..dfa29634 100644 --- a/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgePlugin.kt +++ b/libs/vespera-bridge-gradle-plugin/src/main/kotlin/kr/devfive/vespera/VesperaBridgePlugin.kt @@ -5,6 +5,7 @@ import org.gradle.api.Project import org.gradle.api.Task import org.gradle.api.tasks.Copy import org.gradle.api.tasks.Exec +import org.gradle.language.jvm.tasks.ProcessResources import java.io.File /** @@ -12,10 +13,9 @@ import java.io.File * application: * * 1. Registers a `bundleNativeLib` task that copies the cdylib from - * `/target/release/` into - * `build/resources/main/native/-/` so - * `VesperaBridge.init(...)` can extract it at runtime. - * 2. Wires `bundleNativeLib` into `processResources`. + * `/target/release/` into a generated resources directory. + * 2. Wires those generated resources into `processResources` under + * `native/-/` so `VesperaBridge.init(...)` can extract it. * 3. Adds `kr.devfive:vespera-bridge:` as an * `implementation` dependency. * 4. Optionally (`autoBuildCargo = true`) registers a `cargoBuild` @@ -26,13 +26,13 @@ import java.io.File * * ```kotlin * plugins { - * id("kr.devfive.vespera-bridge") version "0.0.15" + * id("kr.devfive.vespera-bridge") version "" * } * * vespera { * crateName.set("my_rust_lib") * cargoRoot.set(rootProject.layout.projectDirectory.dir("../..")) - * bridgeVersion.set("0.0.15") + * bridgeVersion.set("") * } * ``` */ @@ -41,17 +41,25 @@ class VesperaBridgePlugin : Plugin { val ext = project.extensions .create("vespera", VesperaBridgeExtension::class.java) ext.autoBuildCargo.convention(false) + ext.cargoSourceRoots.convention(listOf("src", "crates", "examples")) + ext.cargoProfile.convention("release") // Compute platform-derived values eagerly (host machine info). val os = detectOs() val arch = detectArch() - val targetSubdir = "resources/main/native/$os-$arch" + val generatedResourcesDir = project.layout.buildDirectory.dir("generated/vesperaNativeResources") + val targetSubdir = "native/$os-$arch" - // Lazy file references — evaluated at task execution. + // Lazy file references — evaluated at task execution. The cdylib + // lives under `//`, so a + // debug / custom-profile build or a redirected CARGO_TARGET_DIR is + // located correctly instead of being hardcoded to `target/release/`. val cdylibFile = project.provider { - val root = ext.cargoRoot.get().asFile val name = ext.crateName.get() - File(root, "target/release/" + mapLibraryName(os, name)) + val targetBase = + if (ext.targetDir.isPresent) ext.targetDir.get().asFile + else File(ext.cargoRoot.get().asFile, "target") + File(targetBase, profileDir(ext.cargoProfile.get()) + "/" + mapLibraryName(os, name)) } val cargoBuildTask = project.tasks.register( @@ -62,14 +70,36 @@ class VesperaBridgePlugin : Plugin { t.group = "vespera" t.description = "Build the Rust cdylib via `cargo build --release`." t.workingDir = ext.cargoRoot.get().asFile - t.commandLine("cargo", "build", "-p", ext.crateName.get(), "--release") - // Up-to-date check: re-run on any .rs file or Cargo.lock change. - val rustSources = project.fileTree( - ext.cargoRoot.get().asFile.resolve("src") - ) - rustSources.include("**/*.rs") - t.inputs.files(rustSources) - t.inputs.file(ext.cargoRoot.get().asFile.resolve("Cargo.lock")) + // Profile-aware command: `release` → `--release`, `dev`/ + // `debug` → default build, any other → `--profile

`. + val profile = ext.cargoProfile.get() + val cmd = mutableListOf("cargo", "build", "-p", ext.crateName.get()) + when (profile) { + "release" -> cmd.add("--release") + "dev", "debug" -> {} // default profile → target/debug + else -> { cmd.add("--profile"); cmd.add(profile) } + } + t.commandLine(cmd) + // Honour a redirected target dir so cargo writes where + // `bundleNativeLib` later looks for the cdylib. + if (ext.targetDir.isPresent) { + t.environment( + "CARGO_TARGET_DIR", + ext.targetDir.get().asFile.absolutePath, + ) + } + // Up-to-date check: re-run on workspace manifests, Cargo.lock, + // and Rust sources in configured roots. This repository keeps + // Rust code under crates/* and examples/*, not only src/. + val cargoRoot = ext.cargoRoot.get().asFile + val cargoInputs = project.fileTree(cargoRoot) + cargoInputs.include("Cargo.toml") + cargoInputs.include("**/Cargo.toml") + ext.cargoSourceRoots.get().forEach { root -> + cargoInputs.include("${root.trimEnd('/', '\\')}/**/*.rs") + } + t.inputs.files(cargoInputs) + t.inputs.file(cargoRoot.resolve("Cargo.lock")).optional() t.outputs.file(cdylibFile) } } @@ -82,16 +112,20 @@ class VesperaBridgePlugin : Plugin { override fun execute(t: Copy) { t.group = "vespera" t.description = - "Copy the built cdylib into src/main/resources/native/-/." + "Copy the built cdylib into generated resources/native/-/." t.from(cdylibFile) - t.into(project.layout.buildDirectory.dir(targetSubdir)) + t.into(generatedResourcesDir.map { it.dir(targetSubdir) }) t.doFirst(object : org.gradle.api.Action { override fun execute(@Suppress("UNUSED_PARAMETER") task: Task) { val src = cdylibFile.get() require(src.exists()) { "Native library not found: $src\n" + - "Run: cargo build -p ${ext.crateName.get()} --release " + - "(or set vespera.autoBuildCargo = true)" + "Build the '${ext.crateName.get()}' cdylib for the " + + "'${ext.cargoProfile.get()}' profile (or set " + + "vespera.autoBuildCargo = true). If the workspace " + + "redirects Cargo output (CARGO_TARGET_DIR / " + + ".cargo/config.toml build.target-dir), set " + + "vespera.targetDir to that directory." } } }) @@ -110,28 +144,32 @@ class VesperaBridgePlugin : Plugin { } }) - // Hook into Java resource processing + dependency wiring. - project.afterEvaluate(object : org.gradle.api.Action { - override fun execute(p: Project) { - p.tasks.findByName("processResources")?.dependsOn(bundleTask) + // Hook into Java resource processing + dependency wiring lazily when a + // Java plugin creates `processResources` / `implementation`. Avoid + // afterEvaluate so configuration-cache snapshots do not depend on a + // late mutable project callback. + project.pluginManager.withPlugin("java") { + project.tasks.withType(ProcessResources::class.java).configureEach { + dependsOn(bundleTask) + from(generatedResourcesDir) + } - // Repository configuration is intentionally left to - // the user's settings.gradle.kts (dependencyResolution - // Management) — Gradle's "fail-on-project-repos" mode - // requires us not to mutate project.repositories from - // a plugin. Users typically add mavenCentral() (and - // mavenLocal() for development) at the settings level. - val version = ext.bridgeVersion.orNull - ?: error( + // Repository configuration is intentionally left to + // the user's settings.gradle.kts (dependencyResolution + // Management) — Gradle's "fail-on-project-repos" mode + // requires us not to mutate project.repositories from + // a plugin. Users typically add mavenCentral() (and + // mavenLocal() for development) at the settings level. + val bridgeDependency = ext.bridgeVersion + .map { version -> "kr.devfive:vespera-bridge:$version" } + .orElse(project.provider { + error( "vespera.bridgeVersion must be set explicitly. " + - "Example: vespera { bridgeVersion.set(\"0.0.15\") }" + "Example: vespera { bridgeVersion.set(\"\") }" ) - p.dependencies.add( - "implementation", - "kr.devfive:vespera-bridge:$version", - ) - } - }) + }) + project.dependencies.addProvider("implementation", bridgeDependency) + } } private fun detectOs(): String { @@ -157,4 +195,14 @@ class VesperaBridgePlugin : Plugin { "macos" -> "lib$name.dylib" else -> "lib$name.so" } + + /** + * Map a Cargo profile name to its `target/` output subdirectory. + * Cargo's built-in `dev` profile emits to `debug`; every other profile + * (`release`, or a custom `[profile.X]`) uses its own name verbatim. + */ + private fun profileDir(profile: String): String = when (profile) { + "dev", "debug" -> "debug" + else -> profile + } } diff --git a/libs/vespera-bridge/README.md b/libs/vespera-bridge/README.md index add6b8e4..c7e9a6fa 100644 --- a/libs/vespera-bridge/README.md +++ b/libs/vespera-bridge/README.md @@ -6,13 +6,13 @@ JNI bridge that lets a Java/Spring application embed a Rust [`vespera`](../../) kr.devfive vespera-bridge - 0.0.15 + 0.2.0 ``` ```kotlin dependencies { - implementation("kr.devfive:vespera-bridge:0.0.15") + implementation("kr.devfive:vespera-bridge:0.2.0") } ``` @@ -22,13 +22,13 @@ For Spring Boot apps the [`kr.devfive.vespera-bridge`](../vespera-bridge-gradle- ```kotlin plugins { - id("kr.devfive.vespera-bridge") version "0.0.15" + id("kr.devfive.vespera-bridge") version "0.1.1" } vespera { crateName.set("my_rust_lib") cargoRoot.set(rootProject.layout.projectDirectory.dir("../..")) - bridgeVersion.set("0.0.15") + bridgeVersion.set("0.2.0") } ``` @@ -56,17 +56,33 @@ Out of the box the autoconfigure module wires up: | Concern | Default | Override | |---|---|---| | **App selection** | Read `X-Vespera-App` request header; absent → default app | Property `vespera.bridge.app-header`, or custom [`AppNameResolver`](src/main/java/com/devfive/vespera/bridge/AppNameResolver.java) bean | -| **Dispatch mode** | [`BIDIRECTIONAL_STREAMING`](src/main/java/com/devfive/vespera/bridge/DispatchMode.java) for every request — safe for any payload size, transparent for the Rust router | Custom [`DispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java) bean | +| **Dispatch mode** | [`SmartDispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java) since 0.2.0 — picks per request: [`DIRECT`](src/main/java/com/devfive/vespera/bridge/DispatchMode.java) (pooled direct buffers, no JNI array copies) for small/bodyless safe requests (GET/HEAD/OPTIONS, Content-Length absent or ≤ 1 MiB) ~2.2 µs; `SYNC` (heap-buffered) for small unsafe requests (POST/PUT/PATCH/DELETE ≤ 256 KiB) ~3.2 µs; `BIDIRECTIONAL_STREAMING` for the rest ~24.1 µs | Property `vespera.bridge.dispatch-mode: bidirectional-streaming` (opt out, restore pre-0.2.0 default), or custom [`DispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java) bean | | **URL pattern** | Single `@RequestMapping("/**")` catch-all — every vespera router URL exactly mirrors the published OpenAPI path | Set `vespera.bridge.controller-enabled: false` and supply your own controller | | **Body handling** | Servlet `InputStream` straight through to Rust (no buffering) for streaming modes; full read for sync/async | (encoded by the chosen `DispatchMode`) | -Why `BIDIRECTIONAL_STREAMING` as the default mode? It's the only mode that processes every payload size correctly without dispatch-time hints: +Why `smart` as the default mode (since 0.2.0)? Measured on a small `GET /health` round-trip through the real JNI boundary the cheapest safe path per request is 7–11× cheaper than unconditional streaming: -- **Tiny request / tiny response** (`/health` → `"ok"`): processed as a single chunk, negligible overhead. -- **Small JSON RPC** (`/users` → `{...}`): single chunk both ways. -- **Multi-GB upload + multi-GB download**: chunk-bounded both ways, ~32 KiB resident. +| Request shape | Mode | ns/round-trip | +|---|---|---| +| Small/bodyless + safe (GET/HEAD/OPTIONS, Content-Length absent or ≤ 1 MiB) | `DIRECT` | ~2,200 | +| Small (≤ 256 KiB Content-Length) + unsafe (POST/PUT/PATCH/DELETE) | `SYNC` | ~3,200 | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | ~24,100 | + +Trade-offs the new default makes on your behalf: + +- **DIRECT** writes the wire response straight into a pooled direct `ByteBuffer` (per-thread, 64 KiB → `vespera.direct.maxBufferBytes` default 4 MiB). On responses larger than the pooled buffer the Java side **retries once with a bigger buffer** by default, which re-runs the Rust handler. This is why DIRECT is gated on safe methods only. Set `vespera.bridge.direct-retry-on-overflow=false` to surface the overflow instead of automatically retrying. +- **SYNC** fully buffers the response on the JVM heap. The 256 KiB request-size gate keeps the response size reasonable for JSON-RPC-shaped traffic; large or unknown-length bodies still stream. +- **`BIDIRECTIONAL_STREAMING`** is unchanged for large/unknown-length bodies — multi-GB upload + multi-GB download still runs chunk-bounded, ~32 KiB resident each side. + +The Spring endpoints **always** mirror vespera's `openapi.json` — `smart` picks the JNI path per request without any URL prefix or path-based heuristic that could diverge from the Rust router's view of the world. + +Restore the pre-0.2.0 default (every request that may carry a body streams both ways, ~24 µs per round-trip uniform) with: -This means the Spring endpoints **always** mirror vespera's `openapi.json` — there is no URL prefix or mode-detection heuristic that could diverge from the Rust router's view of the world. +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming +``` ## Customization @@ -79,8 +95,22 @@ vespera: bridge: app-header: X-My-App # change the header that selects the app controller-enabled: true # set false to disable our controller + direct-retry-on-overflow: true # set false to avoid DIRECT retry double-execution + max-buffered-request-bytes: 0 # 0 = unlimited; cap SYNC/ASYNC/DIRECT request buffering ``` +`vespera.bridge.direct-retry-on-overflow` defaults to `true` for backward +compatibility with the 0.2.x DIRECT fast path. When `false`, a DIRECT response +overflow raises the existing `BufferTooSmallException` path in the proxy (HTTP +500 with the required size) instead of growing the response buffer and re-running +the Rust handler. + +`vespera.bridge.max-buffered-request-bytes` defaults to `0` (unlimited) for +backward compatibility, matching the Rust-side `VESPERA_MAX_REQUEST_BYTES` +convention. Set it to a positive byte count to reject SYNC/ASYNC/DIRECT +requests whose buffered body exceeds the cap with HTTP 413. Bidirectional +streaming is exempt and remains the path for large or unknown-size uploads. + ### 2. Custom app-selection strategy Resolve the app name however you like — URL path segment, subdomain, JWT claim, … @@ -142,11 +172,26 @@ public class MyController { body); byte[] resp = VesperaBridge.dispatchBytes(wire); DecodedResponse d = VesperaBridge.decodeResponse(resp); - return ResponseEntity.status(d.status()).body(d.body()); + return ResponseEntity.status(d.status()).body(d.bodyBytes()); } } ``` +### 5. Custom async response executor + +The ASYNC proxy path completes the native dispatch future from a Rust/Tokio +worker thread, then parses the wire response on a JVM-managed executor. The +default bean is `vesperaBridgeAsyncResponseExecutor`, backed by +`ForkJoinPool.commonPool()`. Replace that bean by name to use an application +executor: + +```java +@Bean("vesperaBridgeAsyncResponseExecutor") +public Executor vesperaBridgeAsyncResponseExecutor() { + return Executors.newFixedThreadPool(4); +} +``` + ## Multi-app routing Register multiple named apps on the Rust side with `vespera::jni_apps!`: @@ -200,11 +245,11 @@ bytes 4+N.. : raw body bytes (UTF-8 text or binary — - Multi-valued response headers (e.g. `set-cookie`) render as JSON arrays so semantics are preserved — they're never comma-joined. - All failure paths (malformed wire, Rust panic, no app registered) return a valid length-prefixed response with status `4xx` / `5xx`, so the decoder never has to special-case errors. -## Four dispatch modes +## Dispatch modes -`VesperaBridge` exposes four native methods that all share the same -wire format, same registered router, and same panic-safe -`catch_unwind` discipline: +`VesperaBridge` exposes six `byte[]`-based native methods plus a +direct-buffer path — all sharing the same wire format, same registered +router, and same panic-safe `catch_unwind` discipline: | Method | Mode | Java side return | Memory footprint | |---|---|---|---| @@ -212,12 +257,181 @@ wire format, same registered router, and same panic-safe | `dispatchAsync(CompletableFuture, byte[])` | async (`CompletableFuture`) | `void` (future completes) | full body in memory | | `dispatchStreaming(byte[], OutputStream)` | sync, response-streaming | `byte[]` (header only) | chunk-bounded response | | `dispatchFullStreaming(byte[], InputStream, OutputStream)` | sync, **bidirectional streaming** | `byte[]` (header only) | chunk-bounded both ways | +| `dispatchStreamingWithHeader(byte[], Consumer, OutputStream)` | sync, response-streaming | `void` (header via callback, fires before first body byte) | chunk-bounded response | +| `dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream)` | sync, bidirectional streaming | `void` (header via callback) | chunk-bounded both ways | +| `dispatchDirect(ByteBuffer, int, ByteBuffer)` | sync, **direct buffers** | `int` (response length / overflow code) | full body, but no Java heap arrays | Pick the mode that matches your workload: - Small JSON RPC, single request/response → `dispatchBytes` +- Hot small/bounded payloads where JNI copy overhead matters → `dispatchDirect` / `dispatchDirectPooled` - Async I/O coordination (parallel Java requests, non-blocking) → `dispatchAsync` + `CompletableFuture` - Large download / streaming response (video, PDF, server-sent events) → `dispatchStreaming` + `OutputStream` - **Large upload + large download** (file transfer proxy, video transcoding, 1 GB ↔ 1 GB) → `dispatchFullStreaming` + `InputStream` + `OutputStream` +- The `*WithHeader` variants let Spring-style controllers commit status/headers from the callback **before** the first body byte is written + +## Direct buffer dispatch (no JNI region copies) + +`dispatchDirect(ByteBuffer in, int inLen, ByteBuffer out)` reads the +wire request from a **direct** `ByteBuffer` and writes the wire +response into another, eliminating the two JNI +`GetByteArrayRegion`/`SetByteArrayRegion` copies and the per-call Java +heap array allocations that `dispatchBytes` pays. On the success path +the response is **streamed straight into the out buffer** (wire header +first, then each body frame at its final offset) — no intermediate +response `Vec`. To be precise about what remains: one plain native +memcpy on the request side (axum requires owned request bytes) plus +the per-frame body copies; `422` responses are materialised internally +to keep `validation_errors` hoisted in the wire header. Measured at +**1.4–3.4× per round-trip** versus `dispatchBytes` depending on +payload size. + +Contract: +- Both buffers MUST be direct (`ByteBuffer.allocateDirect`); heap + buffers are rejected with `IllegalArgumentException` before crossing + JNI. +- The request is read from absolute offsets `in[0..inLen]` — the + buffer's position/limit are **ignored**; `inLen` is authoritative. +- Return `>= 0`: a complete wire response occupies `out[0..n]`. +- Return `< 0`: `-(requiredSize)` — the response did not fit; buffer + contents are undefined (a prefix may have been written). + `requiredSize` is exact, but **retrying re-runs the Rust handler**, +so only retry safe requests. +- `Integer.MIN_VALUE`: response exceeds 2 GiB (unrepresentable). + +`dispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow)` +wraps the raw call with per-thread reusable direct buffers (64 KiB +initial, doubling up to the `vespera.direct.maxBufferBytes` system +property, default 4 MiB) and returns a read-only view of the response +valid until the next dispatch on the same thread. On response +overflow it throws `BufferTooSmallException(requiredSize)` unless +`retryOnOverflow` is `true` — pass `true` only for safe +requests, because the retry dispatches again. In the Spring proxy, +`retryOnOverflow` is additionally gated by +`vespera.bridge.direct-retry-on-overflow` (default `true`; set `false` +to keep DIRECT from automatically double-executing any handler). + +The fastest variant skips the intermediate wire `byte[]` entirely — +`dispatchDirectPooled(appName, method, path, query, headers, body, +retryOnOverflow)` encodes straight into the pooled direct buffer via +`encodeRequestInto(...)`, so the body is copied heap→direct exactly +once. `encodeRequestInto(..., ByteBuffer target)` is also public for +callers managing their own buffers; it returns the bytes written or +`-(required)` without touching the buffer when `target` is too small +(an encoding-side signal — no dispatch has run, growing and retrying +is always safe, unlike the response-overflow retry). + +For the Spring proxy, `SmartDispatchModeResolver` is the +**autoconfigured default since 0.2.0** — `DispatchMode.DIRECT` / +`SYNC` activate automatically on small bounded requests, no property +required. Restore the pre-0.2.0 default (every request that may carry +a body streams both ways) with: + +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming # default since 0.2.0: smart +``` + +`smart` picks the cheapest safe path per request (measured on a small +`GET /health` round-trip through the real JNI boundary): + +| Request shape | Mode | ns/round-trip | +|---|---|---| +| Small/bodyless + safe (GET/HEAD/OPTIONS) | `DIRECT` | ~2,200 | +| Small (≤ 256 KiB Content-Length) + unsafe (POST/PUT/PATCH/DELETE) | `SYNC` | ~3,200 | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | ~24,100 | + +The safe-method gate on DIRECT matters because a response that +overflows the pooled buffer (`vespera.direct.maxBufferBytes`, default +4 MiB) is retried by default — which re-runs the Rust handler once. +Safe methods are not intended to mutate server state, but the replayed +response may still differ (for example timestamps or generated IDs). +Set `vespera.bridge.direct-retry-on-overflow=false` to surface the +overflow instead. SYNC never re-runs the handler (safe for POST), but +buffers the full response on the JVM heap, which the request-size gate +keeps reasonable for JSON-RPC-shaped traffic. + +Custom policies can still register the bean directly (the property is +ignored when a user `DispatchModeResolver` bean exists): + +```java +@Bean +public DispatchModeResolver dispatchModeResolver() { + return new BidirectionalStreamingDispatchModeResolver(); +} +``` + +### Virtual thread (Project Loom) limitation + +The pooled direct-buffer methods (`dispatchDirectPooled`) use +`ThreadLocal` to maintain per-thread reusable buffers +(64 KiB initial, growing to `vespera.direct.maxBufferBytes`, default +4 MiB). In Java 21+, `ThreadLocal` binds to the **virtual thread** +(not the carrier thread) — so on a virtual thread each dispatch would +allocate a fresh direct buffer, lose all pooling benefit, and +accumulate off-heap memory until the virtual thread is +garbage-collected. + +**Automatic mitigation (since 0.2.1):** `dispatchDirectPooled` detects +the calling thread via `Thread.isVirtual()` (resolved reflectively so +the library still targets Java 17) and, when it is a virtual thread, +**routes the request to the GC-managed heap `dispatchBytes` path +instead of the pooled direct buffer** — no per-vthread off-heap +accumulation, no configuration required. The DIRECT fast path keeps +its pooling benefit on platform threads (Tomcat's default request +pool); virtual-thread deployments transparently fall back to the heap +path at a small per-call allocation cost. + +You can still opt out of DIRECT entirely if you prefer streaming +end-to-end: +- Set `vespera.bridge.dispatch-mode=bidirectional-streaming` so DIRECT + is never chosen by the autoconfigured resolver. +- Or use `dispatchBytes`, `dispatchStreaming`, or + `dispatchFullStreaming` directly. +- Or lower `vespera.direct.maxBufferBytes` to reduce per-thread + allocation size on platform threads. + +`DispatchMode.BIDIRECTIONAL_STREAMING` is safe for virtual threads +and handles all payload sizes without pooling. + +### Synchronous dispatch runtime semantics (background tasks) + +`SYNC` and `DIRECT` drive each request on a **per-calling-thread +current-thread Tokio runtime** (one runtime per Java request thread, +created lazily, blocking pool capped at 4 threads). The request future and +any task the handler `.await`s run to completion normally. The one caveat: +a handler that **detaches** background work with `tokio::spawn(...)` and +returns *without awaiting it* makes progress on that detached task only +while a later request reuses the same Java thread's runtime in a +`block_on` — there are no dedicated worker threads on this path. + +If your handlers fire-and-forget background tasks, route them through a +mode backed by the shared multi-threaded runtime instead: + +- `dispatchAsync` / the `CompletableFuture` API, or +- `vespera.bridge.dispatch-mode=bidirectional-streaming` + +(both use the shared `RUNTIME`, whose worker count is tunable via +`vespera.runtime.workerThreads` / `VESPERA_RUNTIME_WORKERS`). Handlers that +only `.await` their own work are unaffected. + +### Operational sizing (threads & off-heap) + +The `DIRECT` fast path keeps per-platform-thread pooled direct +`ByteBuffer`s (64 KiB → `vespera.direct.maxBufferBytes`, default 4 MiB), +and `SYNC`/`DIRECT` keep one current-thread runtime per Java request +thread. On a large servlet pool (e.g. 200 Tomcat threads) the steady-state +cost is therefore bounded but not negligible: + +- **Off-heap**: up to `poolThreads × maxBufferBytes` retained direct + memory once each thread has served a large response (adaptive shrink + reclaims it after idle dispatches). Lower `vespera.direct.maxBufferBytes` + if off-heap pressure matters more than the DIRECT copy savings. +- **Threads**: worst case `poolThreads × 4` Tokio blocking threads if many + handlers use `spawn_blocking` concurrently (the multipart temp-file + extractor does). Cap Rust's shared async runtime with + `vespera.runtime.workerThreads` when the JVM's own pools compete for the + same cores, or when a container CPU limit is below the host CPU count. ## Direct API (without the proxy controller) @@ -245,7 +459,7 @@ byte[] wireResponse = VesperaBridge.dispatchBytes(wireRequest); DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse); System.out.println(resp.status()); // 200 System.out.println(resp.headers()); // { "content-type": "application/json", … } -System.out.println(new String(resp.body())); // the raw response body +System.out.println(new String(resp.bodyBytes())); // copies the raw response body ``` ### Async dispatch (`CompletableFuture`) @@ -321,11 +535,77 @@ try (InputStream upload = Files.newInputStream(Path.of("huge.mp4")); } ``` -Memory characteristics: **roughly 16 KiB chunk buffer + a 16-slot -mpsc channel buffer** in Rust, plus normal JVM `byte[]` chunks. A -1 GiB upload paired with a 1 GiB download runs in ~500 KiB resident -memory on each side. Backpressure is enforced naturally — if axum -reads slowly, `InputStream.read()` blocks on the bounded channel. +Memory characteristics: **roughly a 256 KiB chunk buffer + a 16-slot +mpsc channel buffer** in Rust (both configurable, see below), plus +normal JVM `byte[]` chunks. A 1 GiB upload paired with a 1 GiB +download runs in low-single-digit MiB resident memory on each side. +Backpressure is enforced naturally — if axum reads slowly, +`InputStream.read()` blocks on the bounded channel. + +#### Streaming tuning + +Both knobs are fixed for the process lifetime once the first dispatch +runs. Configuration precedence (first hit wins, then cached): + +1. **Programmatic setter** — `VesperaBridge.configureStreaming(chunkBytes, channelCapacity)` (Java API, call before or after init) +2. **System properties** — `vespera.streaming.chunkBytes`, `vespera.streaming.channelCapacity` +3. **Environment variables** — `VESPERA_STREAMING_CHUNK_BYTES`, `VESPERA_STREAMING_CHANNEL_CAPACITY` +4. **Built-in defaults** — 256 KiB chunk size, 16 channel slots + +| Setting | System property | Env var (fallback) | Default | Range | +|---|---|---|---|---| +| Chunk buffer size | `vespera.streaming.chunkBytes` | `VESPERA_STREAMING_CHUNK_BYTES` | 256 KiB | 4 KiB – 8 MiB | +| Request channel slots | `vespera.streaming.channelCapacity` | `VESPERA_STREAMING_CHANNEL_CAPACITY` | 16 | 1 – 1024 | +| Tokio worker threads | `vespera.runtime.workerThreads` | `VESPERA_RUNTIME_WORKERS` | logical CPUs | 1 – 1024 | + +**Java API** — call before `VesperaBridge.init(...)` for guaranteed precedence: + +```java +// Configure streaming parameters before init +VesperaBridge.configureStreaming( + 131072, // chunkBytes: 128 KiB (clamped to 4 KiB – 8 MiB) + 32 // channelCapacity: 32 slots (clamped to 1 – 1024) +); +VesperaBridge.init("my_rust_lib"); +``` + +When called before `init()`, values are stored as pending and applied +immediately after the native library loads, **before any dispatch can +occur**. This ensures the programmatic setter beats system properties +and environment variables (Rust-side precedence: setter > env > default). + +When called after `init()`, the native library is already loaded and +values are applied immediately (still beats env vars, but system +properties may have already been read during init). + +Throws `IllegalArgumentException` if `chunkBytes` is outside [4096, 8388608] or +`channelCapacity` is outside [1, 1024]. + +**System properties** — set before `VesperaBridge.init(...)`: + +```bash +java -Dvespera.streaming.chunkBytes=131072 \ + -Dvespera.streaming.channelCapacity=32 \ + -jar app.jar +``` + +**Environment variables** — fallback when no system property is set: + +```bash +export VESPERA_STREAMING_CHUNK_BYTES=131072 +export VESPERA_STREAMING_CHANNEL_CAPACITY=32 +java -jar app.jar +``` + +The worker-thread knob caps Rust's shared Tokio runtime — useful when +the JVM's own pools (Tomcat request threads, virtual-thread carriers) +compete with Tokio for the same cores, or when a container CPU limit +is lower than the host's logical CPU count. + +Larger chunks reduce the per-chunk JNI crossing cost (one +`SetByteArrayRegion` + one `OutputStream.write` per chunk) at the +price of per-stream memory — 256 KiB is a reasonable ceiling for +throughput-oriented deployments. ### Server-side response streaming (Spring `StreamingResponseBody`) @@ -365,23 +645,19 @@ byte[] wire = VesperaBridge.encodeRequest( pdf); DecodedResponse resp = VesperaBridge.decodeResponse( VesperaBridge.dispatchBytes(wire)); -assert Arrays.equals(pdf, resp.body()); // exact round-trip +assert Arrays.equals(pdf, resp.bodyBytes()); // exact round-trip (copy on demand) ``` -A Rust handler returning a binary response (e.g. `image/png`) flows the same way: `VesperaProxyController` inspects the response `Content-Type` and returns `ResponseEntity` for binary content, `ResponseEntity` for text-like content. +A Rust handler returning a binary response (e.g. `image/png`) flows the same way: `VesperaProxyController` returns `ResponseEntity` for **every** content type — the wire header already carries the exact `Content-Type`, which Spring's `ByteArrayHttpMessageConverter` writes verbatim. (Before 0.2.1 text-like content types were delivered as `ResponseEntity`; that path was dropped because it forced a redundant UTF-8 decode→re-encode round-trip.) ## VesperaProxyController behaviour `@RequestMapping("/**")` catches every HTTP request, regardless of method or content type, and: 1. Collects all incoming headers (lowercased keys). -2. Reads the body as `byte[]` (Spring's `@RequestBody byte[]`, `consumes = MediaType.ALL_VALUE`). -3. Encodes via `VesperaBridge.encodeRequest(...)` → `dispatchBytes(byte[])`. -4. Decodes via `VesperaBridge.decodeResponse(byte[])`. -5. Returns `ResponseEntity` for text-like `Content-Type` (e.g. `text/*`, `application/json`, `+json`, `+xml`, `application/xml`, `application/javascript`, `application/yaml`, `application/x-www-form-urlencoded`, `application/graphql`). -6. Returns `ResponseEntity` for everything else. - -Missing `Content-Type` defaults to "text" — matching the long-standing Vespera convention of treating unspecified content as JSON-shaped. +2. Asks the configured `DispatchModeResolver` which mode serves this request (default since 0.2.0: `SmartDispatchModeResolver` — DIRECT for small/bodyless safe requests, SYNC for small unsafe requests, BIDIRECTIONAL_STREAMING for everything else; opt out with `vespera.bridge.dispatch-mode=bidirectional-streaming`). +3. For `SYNC` / `ASYNC` / `STREAMING` / `DIRECT` modes the body is read into `byte[]` first (bodyless requests — explicit `Content-Length: 0`, e.g. the small safe GETs the SmartDispatch resolver routes through DIRECT — skip the read and reuse a shared empty array), then encoded via `VesperaBridge.encodeRequest(...)` and dispatched through the matching native method. +4. Sync/async responses are parsed straight from the wire response via the allocation-lean `WireHeaderReader` (status + headers) and returned as `ResponseEntity` for **every** `Content-Type` — the body is sliced once from the wire tail; the `Content-Type` header is carried verbatim, so no text/binary branching is needed. Streaming and DIRECT modes write status/headers and body straight to the servlet response. ## Native library loading @@ -396,6 +672,36 @@ The supported triples are `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `maco See [`examples/rust-jni-demo`](../../examples/rust-jni-demo/) for a complete Rust + Spring Boot integration including build scripts, native bundling, and a curl smoke test. +## 0.2.0 breaking changes + +### 1. Autoconfigured default `DispatchModeResolver` flipped to `SmartDispatchModeResolver` + +Pre-0.2.0 the autoconfigured default was [`BidirectionalStreamingDispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java) — every request that may carry a body streamed both ways, ~24.1 µs per round-trip uniform. Since 0.2.0 the default is [`SmartDispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java) — small bounded safe requests take `DIRECT` (~2.2 µs), small unsafe requests take `SYNC` (~3.2 µs), everything else still streams (~24.1 µs). + +| Request shape | Pre-0.2.0 mode | 0.2.0+ mode | +|---|---|---| +| Small/bodyless safe (GET/HEAD/OPTIONS, ≤ 1 MiB CL or no CL) | `STREAMING` / `BIDIRECTIONAL_STREAMING` | `DIRECT` | +| Small unsafe (POST/PUT/PATCH/DELETE, ≤ 256 KiB CL) | `BIDIRECTIONAL_STREAMING` | `SYNC` | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | `BIDIRECTIONAL_STREAMING` | + +Trade-offs the new default makes: +- **DIRECT** writes the wire response straight into a pooled per-thread direct `ByteBuffer` (64 KiB → `vespera.direct.maxBufferBytes`, default 4 MiB). Responses larger than the pooled buffer trigger a single retry with a bigger buffer, which **re-runs the Rust handler** — which is why DIRECT is gated on safe methods only. +- **SYNC** fully buffers the response on the JVM heap. The 256 KiB request-size gate keeps the response size reasonable for JSON-RPC-shaped traffic; large or unknown-length bodies still stream. + +**Opt out** (restore the pre-0.2.0 default): + +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming +``` + +Or register a custom [`DispatchModeResolver`](src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java) bean — `@ConditionalOnMissingBean` ensures it wins over both the property and the autoconfigured default. + +### 2. `DecodedResponse.body()` returns `ByteBuffer` + +`DecodedResponse.body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes); the owned `byte[]` materialisation moved to `DecodedResponse.bodyBytes()`. Callers that previously consumed `body()` as `byte[]` must switch to `bodyBytes()` (or read directly from the buffer). + ## Migrating from the JSON-envelope bridge (≤ 0.0.13) The pre-0.0.14 bridge used `dispatch(String) → String` with base64-encoded binary bodies. Migration: diff --git a/libs/vespera-bridge/build.gradle.kts b/libs/vespera-bridge/build.gradle.kts index cce0451a..4f6c75b6 100644 --- a/libs/vespera-bridge/build.gradle.kts +++ b/libs/vespera-bridge/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "kr.devfive" -version = "0.1.1" +version = "0.2.0" java { toolchain { @@ -31,11 +31,22 @@ dependencies { api("com.fasterxml.jackson.core:jackson-databind:2.17.0") testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") + // MockHttpServletRequest for resolver unit tests (no servlet container). + testImplementation("org.springframework:spring-test:6.1.6") + // WebApplicationContextRunner for autoconfigure branch tests + // (its AssertableWebApplicationContext implements AssertJ's + // AssertProvider, so assertj-core must be on the test classpath). + testImplementation("org.springframework.boot:spring-boot-test:3.2.5") + testImplementation("org.assertj:assertj-core:3.25.3") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.2") } tasks.named("test") { useJUnitPlatform() + // Opt-in micro-benchmarks (PerfAllocBench, gated by @EnabledIfSystemProperty) + // read this property; propagate it from the Gradle CLI into the forked test + // JVM — same pattern as the rust-jni-demo demo-app. + System.getProperty("vespera.bench")?.let { systemProperty("vespera.bench", it) } } // Gate Maven Central signing on the presence of in-memory signing diff --git a/libs/vespera-bridge/docs/jni-before-after-2026-06-11.md b/libs/vespera-bridge/docs/jni-before-after-2026-06-11.md new file mode 100644 index 00000000..496285ed --- /dev/null +++ b/libs/vespera-bridge/docs/jni-before-after-2026-06-11.md @@ -0,0 +1,237 @@ +# JNI BEFORE ↔ AFTER benchmark report (2026-06-11) + +## Headline + +The v0.2.0 JNI break is justified by the hot-path wins it unlocks: the new `direct_pooled` ByteBuffer path completes the tiny `/health` round-trip in **2,349 ns/op**, **1.55× faster than the 0.1.1-era sync baseline** (3,643 ns/op), and the existing sync byte-array path is still **20% faster** after the series. The largest measured gains are in binary streaming throughput: AFTER is **2.14× to 3.26× faster** across 16 KiB → 256 KiB chunks, peaking at **14,458 MiB/s** for 256 KiB chunks versus **4,440 MiB/s** BEFORE. Response decoding now exposes the zero-copy API that did not exist BEFORE; that API gap is the core reason the breaking change is worth taking. + +Small-request streaming and async latency did **not** improve in this run: response-only streaming, bidirectional streaming, and async-completable-future medians regressed versus the backported 0.1.1 harness. The async row is called out below as gate input for the follow-up attach/JMethodID optimization decision. + +## Latency table + +Protocol: 3 JVM invocations per side; run 1 discarded as cold; table value is the median of runs 2–3 (for two retained values, arithmetic midpoint). Lower is better. + +| mode | BEFORE ns/op | AFTER ns/op | delta | speedup | +|---|---:|---:|---:|---:| +| `sync_dispatch_bytes` | 3,643 | 2,930 | -713 ns (-19.6%) | 1.24× faster | +| `direct_pooled` | N/A[^direct-na] | 2,349 | N/A | N/A | +| `response_streaming_only` | 3,735 | 6,922 | +3,187 ns (+85.3%) | 0.54× | +| `bidirectional_streaming` | 11,752 | 20,988 | +9,236 ns (+78.6%) | 0.56× | +| `async_completable_future` | 22,071 | 23,869 | +1,798 ns (+8.1%) | 0.92× | + +[^direct-na]: `dispatchDirectPooled` / direct `ByteBuffer` dispatch did not exist in the 0.1.1 bridge, so the BEFORE harness drops this mode. Compared to the old BEFORE `sync_dispatch_bytes` baseline, AFTER `direct_pooled` is **1.55× faster**. + +## Throughput table + +Protocol: 64 MiB payload, 3 warmup iterations + 10 measured iterations per JVM; 3 JVM invocations per chunk size per side; run 1 discarded as cold; table value is the median of runs 2–3. Higher is better. + +| chunkBytes | BEFORE MiB/s | AFTER MiB/s | delta | +|---:|---:|---:|---:| +| 16,384 | 4,859.8 | 10,407.9 | +5,548.2 MiB/s (+114.2%, 2.14×) | +| 65,536 | 4,711.3 | 11,587.0 | +6,875.7 MiB/s (+146.0%, 2.46×) | +| 262,144 | 4,439.9 | 14,458.3 | +10,018.5 MiB/s (+225.6%, 3.26×) | + +## Raw measured values + +Logs are retained in `%TEMP%` as `bench-before-*.log` and `bench-after-*.log`. + +### Small request latency (`ns/op`) + +| side | run | `sync_dispatch_bytes` | `direct_pooled` | `response_streaming_only` | `bidirectional_streaming` | `async_completable_future` | +|---|---:|---:|---:|---:|---:|---:| +| BEFORE | 1 (discarded) | 3,201 | N/A | 3,531 | 12,101 | 21,381 | +| BEFORE | 2 | 3,867 | N/A | 3,932 | 13,188 | 21,664 | +| BEFORE | 3 | 3,419 | N/A | 3,538 | 10,315 | 22,478 | +| AFTER | 1 (discarded) | 3,026 | 2,223 | 6,485 | 20,150 | 25,163 | +| AFTER | 2 | 2,872 | 2,221 | 6,475 | 18,947 | 25,444 | +| AFTER | 3 | 2,987 | 2,476 | 7,368 | 23,029 | 22,294 | + +### Streaming throughput (`MiB/s`, mean ± stddev printed by the test) + +| side | chunkBytes | run | throughput | stddev | +|---|---:|---:|---:|---:| +| BEFORE | 16,384 | 1 (discarded) | 5,039.0 | 754.0 | +| BEFORE | 16,384 | 2 | 4,732.4 | 565.3 | +| BEFORE | 16,384 | 3 | 4,987.1 | 702.3 | +| BEFORE | 65,536 | 1 (discarded) | 5,007.3 | 660.6 | +| BEFORE | 65,536 | 2 | 4,627.3 | 577.8 | +| BEFORE | 65,536 | 3 | 4,795.3 | 738.8 | +| BEFORE | 262,144 | 1 (discarded) | 4,966.2 | 686.1 | +| BEFORE | 262,144 | 2 | 4,485.1 | 618.3 | +| BEFORE | 262,144 | 3 | 4,394.6 | 540.1 | +| AFTER | 16,384 | 1 (discarded) | 10,446.8 | 772.1 | +| AFTER | 16,384 | 2 | 10,377.0 | 1,270.2 | +| AFTER | 16,384 | 3 | 10,438.8 | 991.3 | +| AFTER | 65,536 | 1 (discarded) | 13,017.3 | 1,898.4 | +| AFTER | 65,536 | 2 | 12,882.9 | 1,952.3 | +| AFTER | 65,536 | 3 | 10,291.1 | 1,868.3 | +| AFTER | 262,144 | 1 (discarded) | 13,140.2 | 2,093.0 | +| AFTER | 262,144 | 2 | 13,907.1 | 1,462.6 | +| AFTER | 262,144 | 3 | 15,009.5 | 1,011.7 | + +## Gate input: `async_completable_future` + +`async_completable_future` was explicitly measured on both sides with the same backported harness. BEFORE retained runs were **21,664** and **22,478 ns/op** (median **22,071 ns/op**). AFTER retained runs were **25,444** and **22,294 ns/op** (median **23,869 ns/op**). That is an **8.1% latency regression** in this protocol, so attach/JMethodID async follow-up should be decided from this row rather than inferred from Rust-side criterion or from sync/direct results. + +## Methodology + +- BEFORE base commit: `6242533483056b20bb363c34917133a395044aa8` (`6242533`). +- BEFORE throwaway worktree head for the measurement: `01592f4cca9649fdfe9a0d68503a38284a37ad66` on branch `before-bench-harness`. +- AFTER commit: `015a444b2f1dd50c8ab0c4a7c2729aac2b1aa58e` from the main working tree. +- Java: `openjdk version "21.0.8" 2025-07-15 LTS`, `OpenJDK Runtime Environment Zulu21.44+17-CA (build 21.0.8+9-LTS)`, `OpenJDK 64-Bit Server VM Zulu21.44+17-CA (build 21.0.8+9-LTS, mixed mode, sharing)`. +- Cargo: `cargo 1.96.0 (30a34c682 2026-05-25)`. +- OS/CPU: Microsoft Windows 11 Pro 10.0.26200; AMD Ryzen 9 9950X 16-Core Processor; 16 cores / 32 logical processors. +- Small-request benchmark: `SmallRequestLatencyBenchTest`, 20,000 warmup iterations + 100,000 measured iterations, `-Dvespera.bench=true`. +- Streaming benchmark: `StreamingThroughputBenchTest`, 64 MiB payload, 3 warmup iterations + 10 measured iterations, `-Dvespera.bench=true`, chunk sizes `16384`, `65536`, `262144` via `-Dvespera.streaming.chunkBytes=`. +- JVM protocol: 3 Gradle/JVM invocations per side per benchmark; discard run 1 as cold; report median of runs 2–3 and retain both raw values above. +- Gradle invocation rule: every Gradle call used `--console=plain --no-daemon`; benchmark runs also used `--rerun-tasks` after Gradle's up-to-date check suppressed repeated benchmark execution. +- BEFORE `CARGO_TARGET_DIR` isolation: all BEFORE Cargo commands used `C:\Users\owjs3\Desktop\projects\vespera-before-bench\target-isolated`, so the main repo `target/` was never shared with the worktree. +- BEFORE cdylib evidence: isolated build produced `C:\Users\owjs3\Desktop\projects\vespera-before-bench\target-isolated\release\rust_jni_demo.dll`, length `1,774,592`, timestamp `2026-06-11 17:21:52 UTC`; because the Gradle plugin reads `target/release`, the DLL was copied to the worktree-local `target\release\rust_jni_demo.dll`, then bundled as `examples\rust-jni-demo\java\demo-app\build\resources\main\native\windows-x86_64\rust_jni_demo.dll`, length `1,774,592`, timestamp `2026-06-11 17:27:02 UTC`. +- AFTER cdylib evidence: main build produced `C:\Users\owjs3\Desktop\projects\vespera\target\release\rust_jni_demo.dll`, length `1,521,664`, timestamp `2026-06-11 14:35:03 UTC`; Gradle bundled `examples\rust-jni-demo\java\demo-app\build\resources\main\native\windows-x86_64\rust_jni_demo.dll`, length `1,521,664`, timestamp `2026-06-11 17:30:38 UTC`. +- Bridge versions: Maven local had both `kr/devfive/vespera-bridge/0.1.1` and `kr/devfive/vespera-bridge/0.2.0`. BEFORE `demo-app` was patched to `bridgeVersion.set("0.1.1")`; AFTER already pins `0.2.0`. +- BEFORE route support: the benchmark files did not exist at `6242533`, and the streaming benchmark's target route `POST /echo/stream` also did not exist. The throwaway worktree backported the current streaming echo route only to keep the throughput benchmark measuring JNI transport rather than route availability. Main production code was not changed. +- API availability: AFTER's `direct_pooled` / direct `ByteBuffer` path measures an API that did not exist BEFORE. The BEFORE gap is therefore recorded as `N/A`, and that missing path is part of the measured improvement unlocked by the v0.2.0 break. + +### Verbatim backport diff between AFTER bench files and BEFORE-patched bench files + +```diff +diff --git a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/SmallRequestLatencyBenchTest.java "b/..\\vespera-before-bench\\examples\\rust-jni-demo\\java\\demo-app\\src\\test\\java\\kr\\go\\demo\\SmallRequestLatencyBenchTest.java" +index 3327283..785f254 100644 +--- a/examples/rust-jni-demo/java/demo-app/src/test/java/kr/go/demo/SmallRequestLatencyBenchTest.java ++++ "b/..\\vespera-before-bench\\examples\\rust-jni-demo\\java\\demo-app\\src\\test\\java\\kr\\go\\demo\\SmallRequestLatencyBenchTest.java" +@@ -6,7 +6,6 @@ import com.devfive.vespera.bridge.VesperaBridge; + import java.io.ByteArrayInputStream; + import java.io.IOException; + import java.io.OutputStream; +-import java.nio.ByteBuffer; + import java.util.Map; + import java.util.concurrent.CompletableFuture; + import java.util.concurrent.TimeUnit; +@@ -18,16 +17,8 @@ import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + * E2E small-request latency benchmark through the REAL JNI boundary — + * quantifies what {@code vespera.bridge.dispatch-mode=smart} buys for + * the requests it targets (small bounded idempotent), by comparing the +- * three dispatch modes on the same tiny {@code GET /health} round-trip: +- * +- *

    +- *
  • {@code SYNC} — {@code encodeRequest} → {@code dispatchBytes} +- * → {@code decodeResponse} (two JNI array copies)
  • +- *
  • {@code DIRECT} — {@code dispatchDirectPooled} fast path +- * (pooled direct buffers, no Java heap arrays)
  • +- *
  • {@code BIDIRECTIONAL_STREAMING} — the autoconfigured default +- * ({@code dispatchFullStreamingWithHeader})
  • +- *
++ * dispatch modes available in the 0.1.1 bridge on the same tiny ++ * {@code GET /health} round-trip. + * + *

Gated behind {@code -Dvespera.bench=true} so normal test runs and + * CI skip it: +@@ -69,15 +60,6 @@ class SmallRequestLatencyBenchTest { + return VesperaBridge.decodeResponse(VesperaBridge.dispatchBytes(wire)).status(); + } + +- private static int directOnce() { +- ByteBuffer resp = +- VesperaBridge.dispatchDirectPooled(null, "GET", "/health", null, HEADERS, null, true); +- // Consume like the controller does: header region must be parsed. +- byte[] out = new byte[resp.remaining()]; +- resp.get(out); +- return VesperaBridge.decodeResponse(out).status(); +- } +- + private static int streamingOnce() throws IOException { + byte[] wireHeader = VesperaBridge.encodeRequestHeader("GET", "/health", null, HEADERS); + CountingOutputStream sink = new CountingOutputStream(); +@@ -137,7 +119,6 @@ class SmallRequestLatencyBenchTest { + @Test + void smallRequestLatencyByMode() throws IOException { + long sync = measure("sync_dispatch_bytes", SmallRequestLatencyBenchTest::syncOnce); +- long direct = measure("direct_pooled", SmallRequestLatencyBenchTest::directOnce); + long respStreaming = + measure( + "response_streaming_only", +@@ -149,12 +130,8 @@ class SmallRequestLatencyBenchTest { + "async_completable_future", + SmallRequestLatencyBenchTest::asyncOnce); + System.out.printf( +- "VESPERA_BENCH summary direct_vs_streaming=%.2fx direct_vs_sync=%.2fx" +- + " resp_only_vs_bidi=%.2fx async_vs_sync=%.2fx async_vs_direct=%.2fx%n", +- (double) streaming / direct, +- (double) sync / direct, ++ "VESPERA_BENCH summary resp_only_vs_bidi=%.2fx async_vs_sync=%.2fx%n", + (double) streaming / respStreaming, +- (double) async / sync, +- (double) async / direct); ++ (double) async / sync); + } + } + +--- StreamingThroughputBenchTest.java diff --- +``` + +`StreamingThroughputBenchTest.java` had no source-level diff after copying it into the BEFORE worktree; its bridge methods existed in 0.1.1. The separate route backport described above was required because `POST /echo/stream` was not present at `6242533`. + +## Deferred + +Text-envelope path optimization is intentionally deferred. The binary wire fast path covers the dominant JNI use case: Spring/Java proxying real request and response bytes through the length-prefixed binary envelope without base64 or domain JSON parsing. The text-envelope path is a niche direct-API fallback rather than the JNI hot path, so this perf series focuses on byte-array region copies, cached JNI method lookups, direct buffers, and binary streaming first. + +## Traps encountered and resolution + +- `dispatchDirectPooled` was absent from 0.1.1: dropped `direct_pooled` on the BEFORE side and reported it as `N/A` with the API-gap footnote. +- `POST /echo/stream` was absent from `6242533`: backported the current streaming echo route only in the throwaway worktree so streaming throughput compares JNI transport rather than a 404/route mismatch. +- Gradle repeated test invocations were `UP-TO-DATE`: reran the benchmark protocol with `--rerun-tasks` while retaining `--console=plain --no-daemon`. +- The Gradle plugin bundles from `target/release`: BEFORE Cargo still built with isolated `CARGO_TARGET_DIR=...\target-isolated`, then the built DLL was copied into the worktree-local `target/release` path before Gradle bundling. +- GPG signing blocked the throwaway worktree commit: the first commit attempt timed out in GPG; the ephemeral worktree commits were created with per-command `git -c commit.gpgsign=false`, with no config change and no push. + +## Re-gate: async attach optimization + +Decision: **keep the async completion daemon-attach optimization**. `jni` 0.22.4 source shows `JavaVM::attach_current_thread` is already a permanent cached attachment (`java_vm.rs` lines 450-469), while `attach_current_thread_for_scope` is the scoped detach-on-return API (`java_vm.rs` lines 500-513). The crate does not expose a safe daemon attachment helper and explicitly says daemon threads are not directly supported (`java_vm.rs` lines 1027-1047), so the async completion path uses JNI 1.4's raw `AttachCurrentThreadAsDaemon` entry from `jni-sys` and caches its `JNIEnv` per Tokio worker thread, with a per-completion local frame to prevent local-reference accumulation. + +Protocol: same 3 JVM invocations; run 1 discarded as cold; retained value is the arithmetic midpoint of runs 2-3. Gate metric is `async_completable_future`. + +| side | run | `sync_dispatch_bytes` | `direct_pooled` | `response_streaming_only` | `bidirectional_streaming` | `async_completable_future` | +|---|---:|---:|---:|---:|---:|---:| +| CURRENT | 1 (discarded) | 3,579 | 2,755 | 7,518 | 21,992 | 28,651 | +| CURRENT | 2 | 3,409 | 3,299 | 6,420 | 22,845 | 24,045 | +| CURRENT | 3 | 3,188 | 2,462 | 6,563 | 17,237 | 21,466 | +| DAEMON | 1 (discarded) | 2,890 | 2,265 | 6,119 | 16,315 | 20,270 | +| DAEMON | 2 | 2,987 | 2,188 | 6,307 | 18,893 | 21,027 | +| DAEMON | 3 | 3,158 | 2,263 | 6,242 | 18,002 | 21,921 | + +| metric | CURRENT median ns/op | DAEMON median ns/op | improvement | +|---|---:|---:|---:| +| `async_completable_future` | 22,756 | 21,474 | **1,282 ns/op faster** (-5.6%) | + +The measured win is above the **100 ns/op** keep gate. Follow-up review found that the daemon-attached Tokio worker must explicitly clear pending Java exceptions after every completion callback because it no longer gets jni-rs scoped-detach cleanup. The implementation now clears pending exceptions after callback success, callback error, and callback unwind while preserving the callback return/error. A targeted regression guard, `AsyncDispatchExceptionHygieneTest.throwingFutureCompleteDoesNotPoisonNextAsyncCompletion`, first forces `CompletableFuture.complete()` to throw and then asserts a normal `dispatchAsync` still completes with status 200; it failed before the cleanup with a timeout and passes after the fix. A single post-fix sanity bench run measured `async_completable_future` at **16,107 ns/op** (informational only; not a replacement for the 3-JVM gate). Verification also passed `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --check`, `cargo test --workspace`, `cargo build -p rust-jni-demo --release`, and the full `:demo-app:test` Gradle suite (including `StreamingClosureStressTest` and the new hygiene guard). + +## Addendum (same day, later session): allocator + streaming buffer pooling + +Two further changes, paired same-session benches (GET /health, 100k iters, mimalloc build): + +| mode | default alloc | + mimalloc | + chunk-buffer pooling | total delta | +|---|---:|---:|---:|---| +| sync_dispatch_bytes | 2,870 | 2,314 | 2,322 | **-19%** | +| direct_pooled | 2,376 | 2,017 | 2,000 | **-16%** | +| response_streaming | 18,617* | 17,610 | **2,434** | **-87%** | +| bidirectional_streaming | 37,543* | 32,326 | **2,605** | **-93%** | +| async_completable_future | 22,038 | 19,468 | ~15,000 | **-32%** | + +\* with the 256 KiB chunk default: each streaming dispatch allocated+zeroed fresh 256 KiB Java arrays (bidi: two), costing ~10µs each — this addendum's TLS pooling (per-OS-thread cached Global, fresh-alloc fallback when leased/reentrant) removes that per-dispatch cost entirely while keeping the 256 KiB throughput benefit for large transfers. mimalloc is opt-in via the vespera `mimalloc` cargo feature. + +## Concurrency frontier (B + C rounds, 32-logical-core machine) + +Single-thread latency was at its floor; the remaining headroom was CONCURRENT throughput. Measured with ConcurrencyBenchTest (N platform threads, 3s measure). + +### Diagnostic chain +1. **Artifact-drift caught by JFR**: the local mavenLocal bridge jar was stale (pre-P1 \ObjectMapper.readTree\) — every prior local demo bench measured the OLD decode. \gradlew clean jar publishToMavenLocal\ (the \clean\ is mandatory; same-version republish is UP-TO-DATE-skipped) fixed it. Source/release were always correct (CI republishes fresh). +2. **P1 confirmed once deployed**: JsonParser streaming decode cut per-op allocation **-31%** (3.5KB→2.4KB); this alone raised direct 16-thread throughput **+56%** — proving the plateau was substantially GC/allocation-driven below the knee. +3. **B (further decode-alloc reduction)**: manual BE header-len read + lazy header map + fewer body-view ByteBuffers → **-4~7%** alloc, but 16-thread throughput **+0.7%** (noise). Conclusion: past the GC knee, decode allocation is NOT the concurrency lever. +4. **C diagnostic**: worker-thread sweep — 16T throughput is INSENSITIVE to \ espera.runtime.workerThreads\ (2/8/32/64 all ~3.6-3.9M ops/s) → NOT worker saturation. The bottleneck is shared-runtime \lock_on\ context-enter contention (every sync dispatch block_on's one shared multi-thread Tokio runtime). +5. **C fix**: per-OS-thread \ hread_local!\ current-thread Tokio runtime for the sync paths (dispatchBytes, dispatchDirect) — zero shared-runtime state. Streaming/async keep the shared multi-thread RUNTIME. + +### C result (16-thread, the saturation metric) +| mode | before ops/s (eff) | after ops/s (eff) | delta | +|---|---|---|---| +| sync_dispatch_bytes | 4.09M (49.5%) | 4.67M (60.2%) | **+14.2%** | +| direct_pooled | 3.45M (47.5%) | 4.64M (66.8%) | **+34.6%** | + +Single-thread latency unchanged. Oracle-reviewed: TLS runtime drops at thread exit (outside block_on), reentrant nested dispatch panics are caught by catch_unwind → 500 wire, detached \ okio::spawn\ on the sync path no longer outlives block_on (documented, fragile pattern). Streaming bidirectional (spawn_blocking) + async (RUNTIME.spawn) verified unaffected. diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java index e7a80011..8b873e68 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolver.java @@ -3,26 +3,44 @@ import jakarta.servlet.http.HttpServletRequest; /** - * Default {@link DispatchModeResolver} — always returns - * {@link DispatchMode#BIDIRECTIONAL_STREAMING}. + * Conservative {@link DispatchModeResolver} — bidirectional streaming + * for every request that may carry a body, with one semantics-preserving + * fast path: provably bodyless requests (see + * {@link DispatchModeResolver#definitelyBodyless}) use response-only + * {@link DispatchMode#STREAMING}, skipping the request-pull plumbing + * that costs ~16 µs per request even when there is nothing to + * pull (measured 24.1 µs → 7.7 µs on a small GET). * - *

This is the safest universal default: every payload size - * (including 0-byte requests and tiny JSON bodies) is processed - * correctly through the bidirectional streaming JNI path, and the - * Spring endpoints exactly mirror the URLs in vespera's generated + *

Pre-0.2.0 default; opt-out since 0.2.0. The + * autoconfigured default flipped to {@link SmartDispatchModeResolver} + * in vespera-bridge 0.2.0 (DIRECT 2.2 µs / SYNC 3.2 µs vs + * bidirectional 24.1 µs on small bounded requests). Restore this + * resolver as the default with + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming}, or + * register it explicitly as a {@code @Bean DispatchModeResolver}. + * + *

This remains the safest universal policy: every payload size is + * processed correctly (responses always stream chunk-bounded; + * request bodies stream whenever one can exist), and the Spring + * endpoints exactly mirror the URLs in vespera's generated * {@code openapi.json}. No path-based mode discrimination means no - * surprise divergence from the Rust router's view. + * surprise divergence from the Rust router's view, and (unlike DIRECT + * in the smart default) the Rust handler is never re-run on response + * overflow. * *

Replace this with a custom {@link DispatchModeResolver} bean if * your application needs different modes for different routes * (e.g. sync for sub-KB JSON RPC, async for parallel I/O - * coordination). + * coordination) — or to restore unconditional bidirectional + * streaming with a one-line lambda. */ public final class BidirectionalStreamingDispatchModeResolver implements DispatchModeResolver { @Override public DispatchMode resolveMode(HttpServletRequest request) { - return DispatchMode.BIDIRECTIONAL_STREAMING; + return DispatchModeResolver.definitelyBodyless(request) + ? DispatchMode.STREAMING + : DispatchMode.BIDIRECTIONAL_STREAMING; } } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DirectOverflowMemory.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DirectOverflowMemory.java new file mode 100644 index 00000000..49aba47e --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DirectOverflowMemory.java @@ -0,0 +1,99 @@ +package com.devfive.vespera.bridge; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Remembers which {@code (app, method, path)} targets have overflowed + * the pooled DIRECT response buffer, so the proxy can skip DIRECT and stream + * those targets directly on subsequent requests. + * + *

Without this, a known-large (e.g. download) route routed to + * {@link DispatchMode#DIRECT} pays the DIRECT-overflow-then-stream + * double dispatch on every request: the Rust handler + * runs once into the pooled direct buffer (overflows), then again through + * response streaming. After the first overflow this memory downgrades the route + * to {@link DispatchMode#STREAMING} up front, so later requests dispatch once. + * + *

Thread-safe and bounded. {@link #shouldAvoidDirect} is a single volatile + * read until the first overflow is recorded, so apps that never overflow DIRECT + * — the steady state — pay no per-request cost. When the entry cap is reached + * the set is cleared wholesale (an approximate bound that needs no dependency); + * a re-learn then costs at most one extra overflow per affected route. + */ +final class DirectOverflowMemory { + + static final int DEFAULT_MAX_ENTRIES = 1024; + + private final int maxEntries; + private final Set overflowed = ConcurrentHashMap.newKeySet(); + + // Hot-path guard: a single volatile read. Stays false (zero lookups) until + // the first overflow is recorded; once true it never resets, because an app + // with oversized DIRECT responses pays the cheap contains() from then on. + private volatile boolean hasEntries = false; + + DirectOverflowMemory() { + this(DEFAULT_MAX_ENTRIES); + } + + DirectOverflowMemory(int maxEntries) { + this.maxEntries = Math.max(1, maxEntries); + } + + /** + * Whether a prior DIRECT dispatch of this route overflowed the pooled + * buffer (and so should stream up front instead of re-attempting DIRECT). + */ + boolean shouldAvoidDirect(String appName, String method, String path, String query) { + if (!hasEntries) { + return false; + } + return overflowed.contains(RouteKey.of(appName, method, path)); + } + + boolean shouldAvoidDirect(String method, String path) { + return shouldAvoidDirect(null, method, path, null); + } + + /** Record that this route overflowed DIRECT so future requests stream. */ + void recordOverflow(String appName, String method, String path, String query) { + if (overflowed.size() >= maxEntries) { + overflowed.clear(); + } + overflowed.add(RouteKey.of(appName, method, path)); + hasEntries = true; + } + + void recordOverflow(String method, String path) { + recordOverflow(null, method, path, null); + } + + int size() { + return overflowed.size(); + } + + private record RouteKey(String appName, String method, String path, int hash) { + static RouteKey of(String appName, String method, String path) { + String normalizedApp = appName == null || appName.isBlank() ? "_default" : appName; + return new RouteKey(normalizedApp, method, path, + 31 * (31 * normalizedApp.hashCode() + method.hashCode()) + path.hashCode()); + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + return obj instanceof RouteKey other + && appName.equals(other.appName) + && method.equals(other.method) + && path.equals(other.path); + } + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java index 192520f3..610828d7 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java @@ -4,14 +4,22 @@ * How {@link VesperaProxyController} dispatches an incoming HTTP * request through the Rust JNI bridge. * - *

The default {@link DispatchModeResolver} returns - * {@link #BIDIRECTIONAL_STREAMING} for every request so that the - * Spring side stays transparent to the vespera Rust router — the - * routes published in the generated {@code openapi.json} are reached - * via the same URLs, regardless of whether the underlying handler - * emits a small JSON body or streams a multi-gigabyte file. Users - * who want a different policy (sync for small JSON RPC, async for - * heavy I/O coordination, …) can register a custom + *

The autoconfigured default {@link DispatchModeResolver} since + * vespera-bridge 0.2.0 is {@link SmartDispatchModeResolver}: small + * bounded safe requests take {@link #DIRECT} (~2.2 µs), small + * unsafe requests take {@link #SYNC} (~3.2 µs), everything + * else falls back to {@link #BIDIRECTIONAL_STREAMING} (~24 µs). The + * Spring side stays transparent to the vespera Rust router either + * way — the routes published in the generated {@code openapi.json} + * are reached via the same URLs, regardless of whether the underlying + * handler emits a small JSON body or streams a multi-gigabyte file. + * + *

Restore the pre-0.2.0 default (every request that may carry a + * body streams both ways) with the conservative opt-out: + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming} → + * {@link BidirectionalStreamingDispatchModeResolver}. Users who + * want a different policy (sync for small JSON RPC, async for heavy + * I/O coordination, …) can register a custom * {@link DispatchModeResolver} bean — {@code @ConditionalOnMissingBean} * ensures the default is automatically disabled. */ @@ -50,11 +58,35 @@ public enum DispatchMode { * java.util.function.Consumer, java.io.InputStream, * java.io.OutputStream)}. * Both request and response bodies stream chunk-by-chunk. - * This is the default mode — it works correctly - * for every payload size (small requests are processed as a - * single chunk), so callers see the vespera Rust router's - * endpoints exactly as published in {@code openapi.json} with - * no special configuration. + * Works correctly for every payload size (small requests are + * processed as a single chunk). Selected by + * {@link SmartDispatchModeResolver} (the autoconfigured default + * since 0.2.0) for large or unknown-length bodies, and + * unconditionally by the conservative opt-out + * {@link BidirectionalStreamingDispatchModeResolver} + * ({@code vespera.bridge.dispatch-mode=bidirectional-streaming}, + * pre-0.2.0 default). */ BIDIRECTIONAL_STREAMING, + + /** + * Direct-buffer dispatch via + * {@link VesperaBridge#dispatchDirectPooled(byte[], boolean)} — + * eliminates the JNI region copies and per-call Java heap array + * allocations of {@link #SYNC}. + * + *

Selected by the autoconfigured + * {@link SmartDispatchModeResolver} (default since 0.2.0) for + * small, bounded, safe requests (GET/HEAD/OPTIONS with + * {@code Content-Length} absent or ≤ 1 MiB — + * the DIRECT gate {@code DEFAULT_MAX_DIRECT_BYTES}; the 256 KiB + * figure is the separate {@link #SYNC} gate). + * The safety gate matters because a response that overflows + * the pooled direct buffer re-runs the Rust handler once. Never + * selected by the conservative opt-out + * {@link BidirectionalStreamingDispatchModeResolver}; large or + * unbounded bodies always belong on + * {@link #BIDIRECTIONAL_STREAMING}. + */ + DIRECT, } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java index b1949f29..921590b0 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchModeResolver.java @@ -6,13 +6,20 @@ * Strategy for deciding which {@link DispatchMode} should serve an * incoming HTTP request. * - *

The autoconfigured default returns - * {@link DispatchMode#BIDIRECTIONAL_STREAMING} for every request, - * which works correctly across all payload sizes (small requests - * are processed as a single chunk) and keeps Spring endpoints - * aligned with the URLs published in vespera's {@code openapi.json} - * — no path-based mode selection that would diverge from the Rust - * router's view. + *

The autoconfigured default since vespera-bridge 0.2.0 is + * {@link SmartDispatchModeResolver}: small bounded safe + * requests take {@link DispatchMode#DIRECT} (~2.2 µs), small + * unsafe requests take {@link DispatchMode#SYNC} (~3.2 µs), + * everything else falls back to + * {@link DispatchMode#BIDIRECTIONAL_STREAMING} (~24 µs). Spring + * endpoints stay aligned with the URLs published in vespera's + * {@code openapi.json} either way — the mode is picked per request + * from request properties, not from the URL. + * + *

Restore the pre-0.2.0 default (every request that may carry a + * body streams both ways) with the conservative opt-out: + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming} → + * {@link BidirectionalStreamingDispatchModeResolver}. * *

Users who want a mixed policy (e.g. {@link DispatchMode#SYNC} * for sub-KB JSON RPC, {@link DispatchMode#STREAMING} for paths @@ -33,4 +40,30 @@ public interface DispatchModeResolver { * @return non-null {@link DispatchMode} value */ DispatchMode resolveMode(HttpServletRequest request); + + /** + * {@code true} when the request provably carries no body, so the + * bidirectional request-pull plumbing (a blocking pull thread, a + * bounded channel, and per-chunk JNI crossings — measured at + * ~16 µs per request) would be pure overhead. + * + *

Detection is deliberately conservative: + *

    + *
  • {@code Content-Length: 0} — provably empty for any method + * and protocol.
  • + *
  • HTTP/1.x only: no {@code Content-Length}, no + * {@code Transfer-Encoding}, and the method is GET / HEAD / + * OPTIONS — per RFC 9112 §6.3 such a request has no body. + * HTTP/2 is deliberately excluded because length-less DATA frames + * can carry a GET body and h2 has no {@code Transfer-Encoding} + * header.
  • + *
+ * + *

For protocols other than HTTP/1.x, absence of framing headers is + * treated as unknown rather than empty; callers that choose a + * non-bidirectional mode will still read the servlet input stream fully. + */ + static boolean definitelyBodyless(HttpServletRequest request) { + return RequestShape.definitelyBodyless(request); + } } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderAppNameResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderAppNameResolver.java index 3f569f42..6f45875c 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderAppNameResolver.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderAppNameResolver.java @@ -30,6 +30,20 @@ public HeaderAppNameResolver(String headerName) { @Override public String resolveAppName(HttpServletRequest request) { - return request.getHeader(headerName); + String value = request.getHeader(headerName); + if (value == null) { + return null; + } + if (!hasLeadingOrTrailingWhitespace(value)) { + return value.isEmpty() ? null : value; + } + String trimmed = value.strip(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static boolean hasLeadingOrTrailingWhitespace(String value) { + int len = value.length(); + return len > 0 && (Character.isWhitespace(value.charAt(0)) + || Character.isWhitespace(value.charAt(len - 1))); } } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderPolicy.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderPolicy.java new file mode 100644 index 00000000..7d186dee --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HeaderPolicy.java @@ -0,0 +1,540 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; + +final class HeaderPolicy { + private HeaderPolicy() {} + + /** + * Pure hop-by-hop response headers the proxy must NOT forward verbatim from + * the Rust wire response. Forwarding a handler-supplied (or malicious + * native) {@code transfer-encoding} / {@code connection} desynchronises + * framing at the servlet container or a downstream proxy (e.g. a wire + * {@code transfer-encoding: chunked} on a response the container frames with + * {@code Content-Length}). These are connection-scoped per RFC 9110 and are + * never legitimately emitted by an application handler. + * + *

{@code content-length} is not hop-by-hop by RFC semantics, but it is + * proxy-owned in this servlet bridge: buffered/direct responses set the + * exact bytes they write, and streaming responses let the servlet container + * frame the body. + * + *

Names are compared case-insensitively against the canonical lowercase + * form the wire header carries. + */ + static boolean isHopByHopResponseHeader(String name) { + return switch (name.length()) { + case 2 -> name.regionMatches(true, 0, "te", 0, 2); + case 7 -> name.regionMatches(true, 0, "trailer", 0, 7) + || name.regionMatches(true, 0, "upgrade", 0, 7); + case 10 -> name.regionMatches(true, 0, "connection", 0, 10) + || name.regionMatches(true, 0, "keep-alive", 0, 10); + case 17 -> name.regionMatches(true, 0, "transfer-encoding", 0, 17); + case 18 -> name.regionMatches(true, 0, "proxy-authenticate", 0, 18); + case 19 -> name.regionMatches(true, 0, "proxy-authorization", 0, 19); + default -> false; + }; + } + + /** + * Apply a Rust wire response header to the servlet response, dropping the + * hop-by-hop / framing headers the proxy owns ({@link #HOP_BY_HOP_RESPONSE_HEADERS}). + */ + static void addServletResponseHeaders( + HttpServletResponse response, ResponseHeaderAccumulator headers) { + for (HeaderPair header : headers.headers) { + addServletResponseHeader(response, header.name, header.value, headers.connectionTokens); + } + } + + static void addServletResponseHeader( + HttpServletResponse response, String name, String value, Set connectionTokens) { + if (!isHopByHopResponseHeader(name) + && !isContentLengthHeader(name) + && !isConnectionNominatedHeader(name, connectionTokens)) { + response.addHeader(name, value); + } + } + + static boolean isConnectionNominatedHeader(String name, Set connectionTokens) { + return connectionTokens != null && connectionTokens.contains(canonicalLowerHeaderName(name)); + } + + static boolean containsConnectionHeaderKey(byte[] wire, int off, int len) { + int headersObject = findHeadersObjectStart(wire, off, len); + return headersObject >= 0 && containsConnectionMemberName(wire, headersObject, off + len); + } + + static boolean containsConnectionHeaderKey(ByteBuffer wire, int off, int len) { + int headersObject = findHeadersObjectStart(wire, off, len); + return headersObject >= 0 && containsConnectionMemberName(wire, headersObject, off + len); + } + + private static int findHeadersObjectStart(byte[] wire, int off, int len) { + int end = off + len - 10; + for (int i = off; i <= end; i++) { + if ((wire[i] & 0xFF) == '"' && isHeadersLiteralAt(wire, i + 1)) { + int colon = skipJsonWhitespace(wire, i + 9, off + len); + if (colon < off + len && (wire[colon] & 0xFF) == ':') { + int object = skipJsonWhitespace(wire, colon + 1, off + len); + if (object < off + len && (wire[object] & 0xFF) == '{') { + return object + 1; + } + } + } + } + return -1; + } + + private static int findHeadersObjectStart(ByteBuffer wire, int off, int len) { + int end = off + len - 10; + for (int i = off; i <= end; i++) { + if ((wire.get(i) & 0xFF) == '"' && isHeadersLiteralAt(wire, i + 1)) { + int colon = skipJsonWhitespace(wire, i + 9, off + len); + if (colon < off + len && (wire.get(colon) & 0xFF) == ':') { + int object = skipJsonWhitespace(wire, colon + 1, off + len); + if (object < off + len && (wire.get(object) & 0xFF) == '{') { + return object + 1; + } + } + } + } + return -1; + } + + private static boolean containsConnectionMemberName(byte[] wire, int pos, int end) { + boolean expectName = true; + for (int i = pos; i < end; i++) { + int b = wire[i] & 0xFF; + if (b == '}') { + return false; + } + if (expectName && b == '"' && isConnectionLiteralAt(wire, i + 1)) { + int colon = skipJsonWhitespace(wire, i + 12, end); + if (colon < end && (wire[colon] & 0xFF) == ':') { + return true; + } + } + if (b == '"') { + i = skipJsonString(wire, i + 1, end); + } else if (b == ',') { + expectName = true; + } else if (b == ':') { + expectName = false; + } + } + return false; + } + + private static boolean containsConnectionMemberName(ByteBuffer wire, int pos, int end) { + boolean expectName = true; + for (int i = pos; i < end; i++) { + int b = wire.get(i) & 0xFF; + if (b == '}') { + return false; + } + if (expectName && b == '"' && isConnectionLiteralAt(wire, i + 1)) { + int colon = skipJsonWhitespace(wire, i + 12, end); + if (colon < end && (wire.get(colon) & 0xFF) == ':') { + return true; + } + } + if (b == '"') { + i = skipJsonString(wire, i + 1, end); + } else if (b == ',') { + expectName = true; + } else if (b == ':') { + expectName = false; + } + } + return false; + } + + private static int skipJsonWhitespace(byte[] wire, int pos, int end) { + int p = pos; + while (p < end) { + int b = wire[p] & 0xFF; + if (b != ' ' && b != '\n' && b != '\r' && b != '\t') { + break; + } + p++; + } + return p; + } + + private static int skipJsonWhitespace(ByteBuffer wire, int pos, int end) { + int p = pos; + while (p < end) { + int b = wire.get(p) & 0xFF; + if (b != ' ' && b != '\n' && b != '\r' && b != '\t') { + break; + } + p++; + } + return p; + } + + private static int skipJsonString(byte[] wire, int pos, int end) { + for (int i = pos; i < end; i++) { + int b = wire[i] & 0xFF; + if (b == '\\') { + i++; + } else if (b == '"') { + return i; + } + } + return end; + } + + private static int skipJsonString(ByteBuffer wire, int pos, int end) { + for (int i = pos; i < end; i++) { + int b = wire.get(i) & 0xFF; + if (b == '\\') { + i++; + } else if (b == '"') { + return i; + } + } + return end; + } + + private static boolean isHeadersLiteralAt(byte[] bytes, int pos) { + return (bytes[pos] & 0xFF) == 'h' + && (bytes[pos + 1] & 0xFF) == 'e' + && (bytes[pos + 2] & 0xFF) == 'a' + && (bytes[pos + 3] & 0xFF) == 'd' + && (bytes[pos + 4] & 0xFF) == 'e' + && (bytes[pos + 5] & 0xFF) == 'r' + && (bytes[pos + 6] & 0xFF) == 's' + && (bytes[pos + 7] & 0xFF) == '"'; + } + + private static boolean isHeadersLiteralAt(ByteBuffer bytes, int pos) { + return (bytes.get(pos) & 0xFF) == 'h' + && (bytes.get(pos + 1) & 0xFF) == 'e' + && (bytes.get(pos + 2) & 0xFF) == 'a' + && (bytes.get(pos + 3) & 0xFF) == 'd' + && (bytes.get(pos + 4) & 0xFF) == 'e' + && (bytes.get(pos + 5) & 0xFF) == 'r' + && (bytes.get(pos + 6) & 0xFF) == 's' + && (bytes.get(pos + 7) & 0xFF) == '"'; + } + + private static boolean isConnectionLiteralAt(byte[] bytes, int pos) { + return (bytes[pos] & 0xFF) == 'c' + && (bytes[pos + 1] & 0xFF) == 'o' + && (bytes[pos + 2] & 0xFF) == 'n' + && (bytes[pos + 3] & 0xFF) == 'n' + && (bytes[pos + 4] & 0xFF) == 'e' + && (bytes[pos + 5] & 0xFF) == 'c' + && (bytes[pos + 6] & 0xFF) == 't' + && (bytes[pos + 7] & 0xFF) == 'i' + && (bytes[pos + 8] & 0xFF) == 'o' + && (bytes[pos + 9] & 0xFF) == 'n' + && (bytes[pos + 10] & 0xFF) == '"'; + } + + private static boolean isConnectionLiteralAt(ByteBuffer bytes, int pos) { + return (bytes.get(pos) & 0xFF) == 'c' + && (bytes.get(pos + 1) & 0xFF) == 'o' + && (bytes.get(pos + 2) & 0xFF) == 'n' + && (bytes.get(pos + 3) & 0xFF) == 'n' + && (bytes.get(pos + 4) & 0xFF) == 'e' + && (bytes.get(pos + 5) & 0xFF) == 'c' + && (bytes.get(pos + 6) & 0xFF) == 't' + && (bytes.get(pos + 7) & 0xFF) == 'i' + && (bytes.get(pos + 8) & 0xFF) == 'o' + && (bytes.get(pos + 9) & 0xFF) == 'n' + && (bytes.get(pos + 10) & 0xFF) == '"'; + } + + record HeaderPair(String name, String value) {} + + static final class ResponseHeaderAccumulator implements BiConsumer { + final List headers = new ArrayList<>(8); + Set connectionTokens; + + @Override + public void accept(String name, String value) { + headers.add(new HeaderPair(name, value)); + if (name.length() == 10 && name.regionMatches(true, 0, "connection", 0, 10)) { + connectionTokens = addConnectionTokens(connectionTokens, value); + } + } + } + + static Set addConnectionTokens(Set tokens, String value) { + int start = 0; + int len = value.length(); + Set result = tokens; + int tokenCount = 0; + while (start < len) { + int comma = value.indexOf(',', start); + int end = comma >= 0 ? comma : len; + int tokenStart = trimHttpWhitespaceStart(value, start, end); + int tokenEnd = trimHttpWhitespaceEnd(value, tokenStart, end); + if (tokenStart < tokenEnd && tokenEnd - tokenStart <= 128) { + if (result == null) { + result = new HashSet<>(4); + } + result.add(lowerAsciiToken(value, tokenStart, tokenEnd)); + tokenCount++; + if (tokenCount >= 32) { + break; + } + } + if (comma < 0) { + break; + } + start = comma + 1; + } + return result; + } + + private static String lowerAsciiToken(String value, int start, int end) { + char[] chars = new char[end - start]; + for (int i = start; i < end; i++) { + char c = value.charAt(i); + chars[i - start] = (c >= 'A' && c <= 'Z') ? (char) (c + ('a' - 'A')) : c; + } + return new String(chars); + } + + private static int trimHttpWhitespaceStart(String value, int start, int end) { + int p = start; + while (p < end && isHttpWhitespace(value.charAt(p))) { + p++; + } + return p; + } + + private static int trimHttpWhitespaceEnd(String value, int start, int end) { + int p = end; + while (p > start && isHttpWhitespace(value.charAt(p - 1))) { + p--; + } + return p; + } + + private static boolean isHttpWhitespace(char c) { + return c == ' ' || c == '\t'; + } + + static boolean isContentLengthHeader(String name) { + return name.length() == 14 && name.regionMatches(true, 0, "content-length", 0, 14); + } + + // Package-private (not private) so unit tests can verify duplicate-header + // joining (B4) with MockHttpServletRequest. + static Map collectHeaders(HttpServletRequest request) { + // Pre-size for a typical request header count so the common case + // never resizes; keep LinkedHashMap (NOT HashMap) so insertion + // order — and thus the request header JSON field order — stays + // deterministic. + Map headers = new LinkedHashMap<>(32); + forEachRequestHeader(request, headers::put); + return headers; + } + + static void forEachRequestHeader(HttpServletRequest request, VesperaBridge.HeaderSink sink) { + Enumeration names = request.getHeaderNames(); + // The Servlet spec permits getHeaderNames() to return null when the + // container disallows header access; treat that as "no headers" + // rather than letting a NullPointerException turn a recoverable case + // into an HTTP 500. + if (names == null) { + return; + } + Set connectionTokens = requestConnectionTokens(request); + if (hasMergeRequiredHeaderName(request, names, connectionTokens)) { + forEachMergedRequestHeader(request, sink, connectionTokens); + return; + } + names = request.getHeaderNames(); + if (names == null) { + return; + } + while (names.hasMoreElements()) { + String name = names.nextElement(); + String lowerName = canonicalLowerHeaderName(name); + if (!isHopByHopRequestHeader(lowerName) + && !isConnectionNominatedHeader(lowerName, connectionTokens)) { + sink.put(lowerName, joinHeaderValues(name, request)); + } + } + } + + private static void forEachMergedRequestHeader( + HttpServletRequest request, VesperaBridge.HeaderSink sink, Set connectionTokens) { + Map merged = new LinkedHashMap<>(32); + Enumeration names = request.getHeaderNames(); + if (names == null) { + return; + } + while (names.hasMoreElements()) { + String name = names.nextElement(); + String lowerName = canonicalLowerHeaderName(name); + if (!isHopByHopRequestHeader(lowerName) + && !isConnectionNominatedHeader(lowerName, connectionTokens)) { + String value = joinHeaderValues(name, request); + merged.merge(lowerName, value, (left, right) -> + left + (lowerName.equals("cookie") ? "; " : ", ") + right); + } + } + merged.forEach(sink::put); + } + + private static boolean hasMergeRequiredHeaderName( + HttpServletRequest request, Enumeration names, Set connectionTokens) { + String seen0 = null, seen1 = null, seen2 = null, seen3 = null; + String seen4 = null, seen5 = null, seen6 = null, seen7 = null; + Set overflowSeen = null; + int count = 0; + while (names.hasMoreElements()) { + String lowerName = canonicalLowerHeaderName(names.nextElement()); + if (isHopByHopRequestHeader(lowerName) + || isConnectionNominatedHeader(lowerName, connectionTokens)) { + continue; + } + if (lowerName.equals(seen0) || lowerName.equals(seen1) + || lowerName.equals(seen2) || lowerName.equals(seen3) + || lowerName.equals(seen4) || lowerName.equals(seen5) + || lowerName.equals(seen6) || lowerName.equals(seen7) + || (overflowSeen != null && !overflowSeen.add(lowerName))) { + return true; + } + switch (count++) { + case 0 -> seen0 = lowerName; + case 1 -> seen1 = lowerName; + case 2 -> seen2 = lowerName; + case 3 -> seen3 = lowerName; + case 4 -> seen4 = lowerName; + case 5 -> seen5 = lowerName; + case 6 -> seen6 = lowerName; + case 7 -> seen7 = lowerName; + default -> { + if (overflowSeen == null) { + overflowSeen = new HashSet<>(8); + } + overflowSeen.add(lowerName); + } + } + } + return false; + } + + private static Set requestConnectionTokens(HttpServletRequest request) { + Enumeration values = request.getHeaders("Connection"); + Set tokens = null; + if (values == null) { + return null; + } + while (values.hasMoreElements()) { + tokens = addConnectionTokens(tokens, values.nextElement()); + } + return tokens; + } + + private static boolean isHopByHopRequestHeader(String name) { + return isHopByHopResponseHeader(name); + } + + /** + * Combine every value of a repeated request header so duplicates are + * not silently dropped before Rust sees them (the prior + * {@code request.getHeader(name)} returned only the first value). + * + *

The single-value case — the overwhelming majority of headers — + * returns the lone value with no allocation. Multiple same-name + * values are combined per RFC 7230 §3.2.2 with {@code ", "}, except + * {@code Cookie}, whose values themselves contain commas and must be + * joined with {@code "; "} per RFC 6265bis §5.4 so the Rust cookie + * parser still receives a valid cookie string. + */ + private static String joinHeaderValues(String name, HttpServletRequest request) { + Enumeration values = request.getHeaders(name); + if (values == null || !values.hasMoreElements()) { + // A non-conformant container can return an empty getHeaders(name) + // AND a null getHeader(name) for a name that getHeaderNames() + // listed; coalesce to "" so a null never reaches the wire-header + // JSON encoder (VesperaWireCodec.writeJsonString) and NPEs there. + String value = request.getHeader(name); + return value != null ? value : ""; + } + String first = values.nextElement(); + if (!values.hasMoreElements()) { + return first; + } + String separator = name.equalsIgnoreCase("cookie") ? "; " : ", "; + StringBuilder sb = new StringBuilder(first); + do { + sb.append(separator).append(values.nextElement()); + } while (values.hasMoreElements()); + return sb.toString(); + } + + /** + * Lowercase an HTTP header name while avoiding per-request lowercase + * allocations for common HTTP/1.1 canonical names. Header names are ASCII + * per RFC 9110 §5.1, so uncommon names fall back to a small ASCII copy only + * when they contain uppercase bytes. + */ + private static String canonicalLowerHeaderName(String name) { + switch (name) { + case "Host": return "host"; + case "Content-Type": return "content-type"; + case "Content-Length": return "content-length"; + case "Accept": return "accept"; + case "Accept-Encoding": return "accept-encoding"; + case "Accept-Language": return "accept-language"; + case "Authorization": return "authorization"; + case "Connection": return "connection"; + case "Cookie": return "cookie"; + case "User-Agent": return "user-agent"; + case "Referer": return "referer"; + case "Origin": return "origin"; + case "Cache-Control": return "cache-control"; + case "If-None-Match": return "if-none-match"; + case "If-Modified-Since": return "if-modified-since"; + case "X-Forwarded-For": return "x-forwarded-for"; + case "X-Forwarded-Host": return "x-forwarded-host"; + case "X-Forwarded-Proto": return "x-forwarded-proto"; + case "X-Request-Id": return "x-request-id"; + // X-Vespera-App is the multi-app routing header sent on EVERY + // request in multi-app deployments (the HeaderAppNameResolver + // default); keep it on the allocation-free switch path instead of + // falling through to a per-request char[]+String lowercase copy. + case "X-Vespera-App": return "x-vespera-app"; + default: break; + } + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c >= 'A' && c <= 'Z') { + return toLowerCaseAscii(name); + } + } + return name; + } + + private static String toLowerCaseAscii(String name) { + char[] chars = name.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + if (c >= 'A' && c <= 'Z') { + chars[i] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HttpMethods.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HttpMethods.java new file mode 100644 index 00000000..816867f0 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HttpMethods.java @@ -0,0 +1,54 @@ +package com.devfive.vespera.bridge; + +/** + * Allocation-free HTTP method classification shared by the proxy + * controller and the dispatch-mode resolvers. + * + *

Methods are matched case-insensitively via + * {@link String#equalsIgnoreCase} — which compares in place — instead of + * allocating an upper-cased copy ({@code method.toUpperCase(Locale.ROOT)}) + * on every request. + */ +final class HttpMethods { + + private HttpMethods() { + } + + /** + * Whether {@code method} is idempotent per RFC 9110 + * (GET / HEAD / PUT / DELETE / OPTIONS). Idempotent requests are not + * necessarily replay-identical, so this is NOT the DIRECT overflow-retry + * gate. {@code null} is treated as non-idempotent. + */ + static boolean isIdempotent(String method) { + if (method == null) { + return false; + } + return method.equalsIgnoreCase("GET") + || method.equalsIgnoreCase("HEAD") + || method.equalsIgnoreCase("PUT") + || method.equalsIgnoreCase("DELETE") + || method.equalsIgnoreCase("OPTIONS"); + } + + /** + * Whether {@code method} is "safe" per RFC 9110 §9.2.1 + * (GET / HEAD / OPTIONS) — not intended to mutate server state. Re-running + * it can still yield a different response (timestamps, random IDs). + * + *

This is the correct gate for the DIRECT overflow retry, which + * re-runs the handler: an idempotent-but-unsafe method (PUT / DELETE) + * can legitimately return a different response on a second run + * (e.g. a {@code DELETE} returning {@code 204} then {@code 404}), which + * the retry would wrongly surface to the client. {@code null} is treated + * as unsafe. + */ + static boolean isSafe(String method) { + if (method == null) { + return false; + } + return method.equalsIgnoreCase("GET") + || method.equalsIgnoreCase("HEAD") + || method.equalsIgnoreCase("OPTIONS"); + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/RequestShape.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/RequestShape.java new file mode 100644 index 00000000..4e2ceda5 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/RequestShape.java @@ -0,0 +1,79 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Per-request servlet metadata snapshot for the proxy hot path. + * + *

Servlet facades may compute/decode method, content length, protocol, and + * headers lazily. The Spring proxy and smart resolver need the same values, so + * capture them once and stash the immutable shape as a request attribute. + */ +final class RequestShape { + + private static final String ATTRIBUTE = RequestShape.class.getName(); + + final String method; + final long contentLength; + final boolean transferEncodingPresent; + final boolean definitelyBodyless; + + private RequestShape( + String method, + long contentLength, + boolean transferEncodingPresent, + boolean definitelyBodyless) { + this.method = method; + this.contentLength = contentLength; + this.transferEncodingPresent = transferEncodingPresent; + this.definitelyBodyless = definitelyBodyless; + } + + static RequestShape capture(HttpServletRequest request) { + Object existing = request.getAttribute(ATTRIBUTE); + if (existing instanceof RequestShape shape) { + return shape; + } + String method = request.getMethod(); + long contentLength = request.getContentLengthLong(); + boolean transferEncodingPresent = request.getHeader("Transfer-Encoding") != null; + boolean definitelyBodyless = definitelyBodyless(request, method, contentLength, transferEncodingPresent); + RequestShape shape = new RequestShape( + method, + contentLength, + transferEncodingPresent, + definitelyBodyless); + request.setAttribute(ATTRIBUTE, shape); + return shape; + } + + static RequestShape from(HttpServletRequest request) { + Object existing = request.getAttribute(ATTRIBUTE); + return existing instanceof RequestShape shape ? shape : capture(request); + } + + static boolean definitelyBodyless(HttpServletRequest request) { + return from(request).definitelyBodyless; + } + + private static boolean definitelyBodyless( + HttpServletRequest request, + String method, + long contentLength, + boolean transferEncodingPresent) { + if (transferEncodingPresent) { + return false; + } + if (contentLength == 0) { + return true; + } + if (contentLength > 0) { + return false; + } + String protocol = request.getProtocol(); + if (protocol == null || !protocol.regionMatches(true, 0, "HTTP/1.", 0, 7)) { + return false; + } + return HttpMethods.isSafe(method); + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java new file mode 100644 index 00000000..2733745b --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java @@ -0,0 +1,161 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Opt-in {@link DispatchModeResolver} that picks the cheapest safe + * JNI path per request (measured on a small {@code GET /health} + * round-trip: DIRECT 2.2 µs / SYNC 3.2 µs / bidirectional + * streaming 24.1 µs): + * + *

    + *
  • {@link DispatchMode#DIRECT} — safe requests + * (GET / HEAD / OPTIONS per RFC 9110) up to the DIRECT gate + * ({@link #DEFAULT_MAX_DIRECT_BYTES}, 1 MiB), or provably + * bodyless ones of any declared length. Safety matters because + * a DIRECT response overflow retries the dispatch, re-running the + * Rust handler.
  • + *
  • {@link DispatchMode#SYNC} — unsafe requests + * (POST / PUT / PATCH / DELETE) up to the SYNC gate + * ({@link #DEFAULT_MAX_SYNC_BYTES}, 256 KiB). SYNC never re-runs + * the handler, so it is safe for any method, but it fully buffers + * the response on the heap — so its gate is kept lower than the + * DIRECT gate, above which streaming wins. The Spring proxy also + * enforces {@code vespera.bridge.max-buffered-response-bytes} + * (64 MiB default) before serving a SYNC response.
  • + *
  • {@link DispatchMode#BIDIRECTIONAL_STREAMING} — everything + * else (larger or unknown-length bodies).
  • + *
+ * + *

Autoconfigured default since vespera-bridge 0.2.0. + * No property required — the autoconfigure module wires this resolver + * when no user {@code @Bean DispatchModeResolver} exists. Pin it + * explicitly with {@code vespera.bridge.dispatch-mode=smart}, or + * opt out to the pre-0.2.0 conservative default with + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming} → + * {@link BidirectionalStreamingDispatchModeResolver}. Or register a + * custom resolver — {@code @ConditionalOnMissingBean} guarantees it + * wins over both: + * + *

{@code
+ * @Bean
+ * public DispatchModeResolver dispatchModeResolver() {
+ *     return new SmartDispatchModeResolver();
+ * }
+ * }
+ */ +public class SmartDispatchModeResolver implements DispatchModeResolver { + + /** + * Default DIRECT request-size gate: 1 MiB (raised from 256 KiB, + * measured 2026-06). Safe requests up to this size dispatch + * through pooled direct buffers — measured 1.7–2.7× faster + * than streaming for 256 KiB–1 MiB bodies, provided + * {@code vespera.direct.maxRetainedBytes} (2 MiB default) keeps the + * response buffer resident so DIRECT does not re-run the handler. + */ + public static final long DEFAULT_MAX_DIRECT_BYTES = 1024 * 1024L; + + /** + * Default SYNC request-size gate: 256 KiB. Unsafe (POST/PUT/PATCH/DELETE) + * requests up to this size use SYNC; above it they stream, because + * SYNC fully buffers the response on the JVM heap, which loses to + * streaming for larger bodies (measured: SYNC 174 µs vs streaming + * 83 µs at 1 MiB). Kept lower than {@link #DEFAULT_MAX_DIRECT_BYTES} + * on purpose — SYNC and DIRECT scale differently with size. + */ + public static final long DEFAULT_MAX_SYNC_BYTES = 256 * 1024L; + + private final long maxDirectBytes; + private final long maxSyncBytes; + + public SmartDispatchModeResolver() { + this(DEFAULT_MAX_DIRECT_BYTES, DEFAULT_MAX_SYNC_BYTES); + } + + /** + * Single-gate constructor — sets BOTH the DIRECT and SYNC gates to + * {@code maxDirectBytes} (the pre-split behavior). Prefer + * {@link #SmartDispatchModeResolver(long, long)} to gate DIRECT and + * SYNC independently. + * + * @param maxDirectBytes largest {@code Content-Length} (bytes) eligible + * for DIRECT (and, here, SYNC) dispatch + */ + public SmartDispatchModeResolver(long maxDirectBytes) { + this(maxDirectBytes, maxDirectBytes); + } + + /** + * @param maxDirectBytes largest {@code Content-Length} eligible for + * DIRECT dispatch (safe methods) + * @param maxSyncBytes largest {@code Content-Length} eligible for SYNC + * dispatch (unsafe methods); typically + * lower than {@code maxDirectBytes} + */ + public SmartDispatchModeResolver(long maxDirectBytes, long maxSyncBytes) { + if (maxDirectBytes < 0 || maxSyncBytes < 0) { + throw new IllegalArgumentException("byte gates must be >= 0"); + } + this.maxDirectBytes = maxDirectBytes; + this.maxSyncBytes = maxSyncBytes; + } + + @Override + public DispatchMode resolveMode(HttpServletRequest request) { + return resolveMode(request, null); + } + + DispatchMode resolveMode(HttpServletRequest request, boolean currentThreadIsVirtual) { + return resolveMode(request, Boolean.valueOf(currentThreadIsVirtual)); + } + + private DispatchMode resolveMode(HttpServletRequest request, Boolean currentThreadIsVirtual) { + RequestShape shape = RequestShape.from(request); + long contentLength = shape.contentLength; + // Bodyless requests fit the direct buffer by definition even when + // Content-Length is absent (the common shape of GET) — without this, + // every length-less GET would miss the fast path. + boolean bodyless = shape.definitelyBodyless; + String method = shape.method; + + if (HttpMethods.isSafe(method)) { + // Safe (GET/HEAD/OPTIONS): DIRECT up to the (larger) DIRECT gate, + // else stream. Safety matters because a DIRECT response overflow + // re-runs the Rust handler. + boolean directSized = + bodyless || (contentLength >= 0 && contentLength <= maxDirectBytes); + if (!directSized) { + return DispatchMode.BIDIRECTIONAL_STREAMING; + } + // DIRECT's pooled direct buffers bind to the virtual thread (not + // the carrier) in Java 21+, so on a virtual-thread-per-request + // server dispatchDirectPooled allocates fresh off-heap buffers and + // falls back to the heap path anyway. Route virtual threads to + // SYNC (no off-heap pooling, no re-run) when small, but stream + // above the SYNC gate — SYNC's heap buffering loses to streaming + // for larger bodies, idempotent or not. + boolean virtualThread = currentThreadIsVirtual != null + ? currentThreadIsVirtual.booleanValue() + : VesperaBridge.currentThreadIsVirtual(); + if (virtualThread) { + return syncSized(contentLength, bodyless) + ? DispatchMode.SYNC + : DispatchMode.BIDIRECTIONAL_STREAMING; + } + return DispatchMode.DIRECT; + } + + // Unsafe (POST/PUT/PATCH/DELETE): SYNC never re-runs the handler, but + // fully buffers the response on the JVM heap — which loses to + // streaming above the (lower) SYNC gate. + return syncSized(contentLength, bodyless) + ? DispatchMode.SYNC + : DispatchMode.BIDIRECTIONAL_STREAMING; + } + + /** Whether a request fits the SYNC gate (bodyless or within the cap). */ + private boolean syncSized(long contentLength, boolean bodyless) { + return bodyless || (contentLength >= 0 && contentLength <= maxSyncBytes); + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java index f4ce32d8..72b7e477 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridge.java @@ -1,30 +1,27 @@ package com.devfive.vespera.bridge; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; - -import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.util.Objects; import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; /** * JNI bridge to any Rust cdylib built with vespera's JNI feature. * + *

This class owns only the pieces that must stay bound to the + * {@code com.devfive.vespera.bridge.VesperaBridge} symbol name — the + * {@code native} methods (whose JNI symbols are + * {@code Java_com_devfive_vespera_bridge_VesperaBridge_*}), native-library + * loading, and the public dispatch API. The pure-Java helpers live in + * sibling classes: wire request encoding / response decoding in + * {@link VesperaWireCodec}, and the per-thread direct-buffer pool in + * {@link VesperaDirectBufferPool}. The public methods here delegate to + * them, so callers see an unchanged surface. + * *

Wire format — both request and response use the * same layout: *

@@ -49,20 +46,41 @@
  */
 public class VesperaBridge {
 
-    private static final ObjectMapper MAPPER = new ObjectMapper();
-    private static final int WIRE_VERSION = 1;
+    @FunctionalInterface
+    public interface HeaderSink {
+        void put(String lowerName, String value);
+    }
+
+    @FunctionalInterface
+    public interface HeaderSource {
+        void writeTo(HeaderSink sink);
+    }
+
     private static volatile boolean loaded = false;
+    /** Name passed to the first successful {@link #init(String)} — used to
+     *  reject a later re-init with a different library name. */
+    private static String loadedLibraryName;
+
+    private static volatile Integer pendingChunkBytes = null;
+    private static volatile Integer pendingChannelCapacity = null;
 
     /**
      * Decoded wire-format response.
      *
+     * 

The {@code body} component is a zero-copy, read-only + * {@link ByteBuffer} view over the original wire response array. + * Its position is {@code 0} and its limit is the body length. The + * view does not expose {@link ByteBuffer#array()} access, so callers + * that genuinely need an owned {@code byte[]} should use + * {@link #bodyBytes()}, which materialises a copy on demand. + * * @param status HTTP status code from the upstream router * @param headers response headers; each value is either a * {@link String} (single-valued) or a * {@link List List<String>} * (multi-valued, e.g. {@code set-cookie}) * @param metadata vespera metadata (e.g. {@code version}) - * @param body raw response body bytes + * @param body read-only raw response body view * @param validationErrors Vespera-validation failures hoisted from * a {@code 422} JSON body so callers can * read them without a second JSON parse. @@ -76,25 +94,202 @@ public record DecodedResponse( int status, Map headers, Map metadata, - byte[] body, - List> validationErrors) {} + ByteBuffer body, + List> validationErrors) { + + public DecodedResponse { + Objects.requireNonNull(body, "body"); + if (!body.isReadOnly() || body.position() != 0) { + body = body.slice().asReadOnlyBuffer(); + } + } + + /** + * Return a fresh read-only duplicate of the response body view. + * The returned buffer is positioned at {@code 0} with + * {@code limit()} equal to the body length. + */ + @Override + public ByteBuffer body() { + return body.asReadOnlyBuffer(); + } + + /** + * Materialise the response body as an owned byte array. + * + *

This method copies the bytes from the zero-copy body view; + * use it at API boundaries that require {@code byte[]}. + */ + public byte[] bodyBytes() { + ByteBuffer view = body.asReadOnlyBuffer(); + byte[] bytes = new byte[view.remaining()]; + view.get(bytes); + return bytes; + } + } /** * Initialize the Rust engine. Tries bundled (JAR-embedded) first, * falls back to {@code java.library.path}. * + *

Streaming configuration is seeded from system properties + * before the first dispatch (values fixed for + * the process lifetime once read): + *

    + *
  • {@code vespera.streaming.chunkBytes} — per-chunk buffer + * size for streaming dispatches (default 256 KiB, clamped to + * 4 KiB – 8 MiB on the Rust side)
  • + *
  • {@code vespera.streaming.channelCapacity} — bound of the + * bidirectional request-body channel in slots (default 16, + * clamped to 1 – 1024)
  • + *
  • {@code vespera.runtime.workerThreads} — worker threads of + * the shared Tokio runtime (default: number of logical + * CPUs, clamped to 1 – 1024)
  • + *
+ * The {@code VESPERA_STREAMING_CHUNK_BYTES} / + * {@code VESPERA_STREAMING_CHANNEL_CAPACITY} / + * {@code VESPERA_RUNTIME_WORKERS} environment variables apply + * when no system property is set. + * * @param libraryName Cargo crate name (e.g. {@code "rust_jni_demo"}) */ public static synchronized void init(String libraryName) { - if (loaded) return; + Objects.requireNonNull(libraryName, "libraryName"); + if (loaded) { + // Re-init with the SAME library is a no-op (friendly for test + // harness resets / repeated Spring context starts). A DIFFERENT + // name is a bug — a JVM process loads exactly one vespera cdylib + // for its lifetime — so surface it instead of silently keeping + // the first library and dispatching to the wrong Rust app. + if (!loadedLibraryName.equals(libraryName)) { + throw new IllegalStateException( + "VesperaBridge is already initialised with native library '" + + loadedLibraryName + "' and cannot be re-initialised with a " + + "different library '" + libraryName + "'."); + } + return; + } try { - loadBundled(libraryName); - } catch (UnsatisfiedLinkError e) { + VesperaNativeLoader.loadBundled(libraryName); + } catch (VesperaNativeLoader.BundledNativeAbsent absent) { + // Fall back to the system library path ONLY when the bundled + // resource is genuinely ABSENT. A PRESENT-but-invalid bundled + // library (integrity / extraction / load failure) propagates from + // loadBundled and fails fast here instead of silently loading a + // different library — which would defeat the integrity check. System.loadLibrary(libraryName); } + // Mark the native library as loaded immediately after System.load / + // System.loadLibrary succeeds. Optional post-load configuration hooks + // below may still throw (for example, a native-side panic surfaced as an + // Error), but a later init() must not try to load the same cdylib again. loaded = true; + loadedLibraryName = libraryName; + // Apply pending streaming config (set via configureStreaming before init). + // Pending values beat system properties (Rust-side setter > env > default). + try { + int chunkBytes = pendingChunkBytes != null + ? pendingChunkBytes + : Integer.getInteger("vespera.streaming.chunkBytes", 0); + int channelCapacity = pendingChannelCapacity != null + ? pendingChannelCapacity + : Integer.getInteger("vespera.streaming.channelCapacity", 0); + configureStreaming0(chunkBytes, channelCapacity); + } catch (UnsatisfiedLinkError olderNativeLibrary) { + // Pre-0.2 native libraries don't export configureStreaming0. + // Streaming config then falls back to env vars / defaults — + // never block init over an optional tuning hook. + } + try { + configureRuntime0(Integer.getInteger("vespera.runtime.workerThreads", 0)); + } catch (UnsatisfiedLinkError olderNativeLibrary) { + // Same guard as above — older native libraries fall back to + // the VESPERA_RUNTIME_WORKERS env var / Tokio's default. + } } + /** + * Configure streaming tuning parameters for the Rust-side dispatch + * engine. Call before {@link #init(String)} for + * guaranteed precedence (values are stored pending and applied right + * after the native library loads, before any dispatch); calling after + * init applies immediately. + * + *

Precedence (first hit wins, then process-fixed): this method > + * system properties ({@code vespera.streaming.chunkBytes} / + * {@code vespera.streaming.channelCapacity}) > environment variables + * ({@code VESPERA_STREAMING_CHUNK_BYTES} / + * {@code VESPERA_STREAMING_CHANNEL_CAPACITY}) > defaults + * (256 KiB chunk, 16 channel slots). + * + * @param chunkBytes per-chunk buffer size for streaming dispatches + * @param channelCapacity bound of the bidirectional request-body + * channel in slots + * @throws IllegalArgumentException if {@code chunkBytes} is outside + * [4096, 8388608] (4 KiB – 8 MiB) or {@code channelCapacity} + * is outside [1, 1024] + */ + public static synchronized void configureStreaming(int chunkBytes, int channelCapacity) { + if (chunkBytes < 4096 || chunkBytes > 8388608) { + throw new IllegalArgumentException( + "chunkBytes " + chunkBytes + + " out of range [4096, 8388608] (4 KiB – 8 MiB)"); + } + if (channelCapacity < 1 || channelCapacity > 1024) { + throw new IllegalArgumentException( + "channelCapacity " + channelCapacity + " out of range [1, 1024]"); + } + if (loaded) { + // Native library already loaded — apply immediately. + try { + configureStreaming0(chunkBytes, channelCapacity); + } catch (UnsatisfiedLinkError olderNativeLibrary) { + // Pre-0.2 native libraries do not export configureStreaming0. + // Match init(): keep the validated Java-side values for any + // future reload/test reset, but degrade gracefully instead of + // surfacing a raw optional-feature LinkageError. + } + } else { + // Native library not yet loaded — store pending values. + // These will be applied in init() before any dispatch. + pendingChunkBytes = chunkBytes; + pendingChannelCapacity = channelCapacity; + } + } + + /** + * Clear all vespera-bridge buffers retained by the current Java + * thread. This is for servlet-container shutdown/redeploy hooks that want + * to release ThreadLocal-held app-class objects and direct buffers from + * container worker threads. Normal request handling should not call it; + * per-request clearing would defeat the hot-path pools. + */ + public static void clearCurrentThreadBuffers() { + VesperaDirectBufferPool.clearCurrentThreadBuffers(); + VesperaWireCodec.clearCurrentThreadBuffers(); + WireHeaderReader.clearCurrentThreadBuffers(); + VesperaProxyController.clearCurrentThreadBuffers(); + } + + /** + * Seed the Rust-side streaming configuration. Values {@code <= 0} + * leave the corresponding setting untouched (environment variable + * or built-in default applies). Calls after the configuration is + * fixed are silently ignored. + */ + private static native void configureStreaming0(int chunkBytes, int channelCapacity); + + /** + * Seed the shared Tokio runtime's worker thread count (system + * property {@code vespera.runtime.workerThreads}, env fallback + * {@code VESPERA_RUNTIME_WORKERS}; clamped to 1–1024 on the Rust + * side). Defaults to Tokio's heuristic (number of logical CPUs) + * — cap it when the JVM's own thread pools compete for the same + * cores. Values {@code <= 0} leave the setting untouched; calls + * after the runtime started are silently ignored. + */ + private static native void configureRuntime0(int workerThreads); + /** * Dispatch a wire-format HTTP-like request through the Rust axum * router (synchronous — blocks the calling @@ -122,6 +317,27 @@ public static synchronized void init(String libraryName) { * cancelled on the Java side, but the in-flight Rust dispatch * continues to completion (and its result is discarded). * + *

Threading contract (IMPORTANT): the future is + * completed on a Rust Tokio runtime worker thread, so any + * non-async continuation ({@code thenApply}, {@code thenAccept}, + * {@code whenComplete}, …) runs inline on that worker. + * Therefore: + *

    + *
  • attach heavy or blocking continuations with the {@code *Async} + * variants ({@code thenApplyAsync}, {@code whenCompleteAsync}, …) + * on your own {@link java.util.concurrent.Executor}; and
  • + *
  • never call a blocking vespera dispatch ({@link #dispatchBytes(byte[])} + * / {@link #dispatchDirect(java.nio.ByteBuffer, int, java.nio.ByteBuffer)}) + * from an inline continuation — that nests a blocking call inside + * the runtime worker and degrades to a {@code 500} wire response.
  • + *
+ * Completing the future off the worker (a {@code spawn_blocking} hand-off) + * was measured at ~16× the per-dispatch cost, so the worker-thread + * completion is kept and this contract is documented instead — the same + * approach Netty and async HTTP clients take. The autoconfigured Spring proxy + * never selects this async path (it uses DIRECT / SYNC / streaming), so this + * applies only to callers composing {@link CompletableFuture}s directly. + * * @param future the future to complete with the wire response * @param wireRequest length-prefixed binary wire request */ @@ -184,7 +400,8 @@ public static CompletableFuture dispatch(byte[] wireRequest) { * with an empty {@code body} array. *
  • The request body bytes flow through {@code inputStream} * — Rust calls {@code inputStream.read(byte[])} repeatedly - * (16 KiB at a time) until EOF.
  • + * (256 KiB at a time by default; see + * {@code vespera.streaming.chunkBytes}) until EOF. *
  • The response body bytes flow through {@code outputStream} * — Rust calls {@code outputStream.write(byte[])} for each * axum body frame.
  • @@ -231,6 +448,14 @@ public static byte[] encodeRequestHeader( return encodeRequestHeader(null, method, path, query, headers); } + public static byte[] encodeRequestHeader( + String method, + String path, + String query, + HeaderSource headers) { + return encodeRequestHeader(null, method, path, query, headers); + } + /** * Same as {@link #encodeRequestHeader(String, String, String, java.util.Map)} * but with an explicit app name for multi-app routing. See @@ -249,7 +474,22 @@ public static byte[] encodeRequestHeader( Objects.requireNonNull(path, "path"), query, headers != null ? headers : java.util.Map.of(), - new byte[0]); + VesperaWireCodec.EMPTY_BODY); + } + + public static byte[] encodeRequestHeader( + String appName, + String method, + String path, + String query, + HeaderSource headers) { + return encodeRequest( + appName, + Objects.requireNonNull(method, "method"), + Objects.requireNonNull(path, "path"), + query, + headers, + VesperaWireCodec.EMPTY_BODY); } /** @@ -288,6 +528,268 @@ public static native void dispatchFullStreamingWithHeader( InputStream inputStream, OutputStream outputStream); + /** + * Thrown by {@link #dispatchDirectPooled(byte[], boolean)} when the + * response exceeds the out-buffer capacity and the caller disallowed + * automatic retry (unsafe requests). Carries the exact + * buffer size needed for a successful retry. + * + *

    Retrying re-runs the dispatch — the Rust + * handler executes again. Only retry safe requests + * (GET/HEAD/OPTIONS) automatically; for unsafe methods the caller + * must decide. + */ + public static final class BufferTooSmallException extends RuntimeException { + private final int requiredSize; + + public BufferTooSmallException(int requiredSize) { + super("response requires a " + requiredSize + + "-byte direct out buffer; retry would re-run the dispatch"); + this.requiredSize = requiredSize; + } + + BufferTooSmallException(int requiredSize, String message) { + super(message); + this.requiredSize = requiredSize; + } + + /** Exact out-buffer capacity needed for a successful retry. */ + public int requiredSize() { + return requiredSize; + } + } + + /** + * Raw native entry — validated by {@link #dispatchDirect(ByteBuffer, + * int, ByteBuffer)}; never call this directly. + */ + private static native int dispatchDirect0(ByteBuffer in, int inLen, ByteBuffer out); + + /** + * Direct-buffer synchronous dispatch — eliminates + * both JNI region copies ({@code byte[]} ↔ native) and the per-call + * Java heap array allocations of {@link #dispatchBytes(byte[])}. + * + *

    Contract (position/limit are IGNORED — the + * explicit {@code inLen} parameter is authoritative): + *

      + *
    • {@code in} and {@code out} MUST be direct buffers; + * heap buffers are rejected here, before crossing JNI.
    • + *
    • The wire request is read from absolute offsets + * {@code in[0..inLen]}.
    • + *
    • Return {@code >= 0}: a complete wire response occupies + * {@code out[0..n]}.
    • + *
    • Return {@code < 0}: {@code -(requiredSize)} — the response + * did not fit. {@code out} contents are undefined + * (the response streams directly into the buffer, so a + * prefix may have been written). {@code requiredSize} is + * exact; retrying re-runs the dispatch (see + * {@link BufferTooSmallException}).
    • + *
    • {@code Integer.MIN_VALUE}: response exceeds 2 GiB and is + * unrepresentable in this protocol.
    • + *
    + * + *

    The buffers are only accessed for the duration of this call; + * they may be reused immediately after it returns. + * + * @param in direct buffer holding the wire request at [0..inLen) + * @param inLen number of valid request bytes in {@code in} + * @param out direct buffer that receives the wire response + * @return bytes written, or the negative protocol codes above + * @throws IllegalArgumentException if either buffer is not direct, read-only, + * {@code inLen} is negative, or exceeds {@code in.capacity()} + */ + public static int dispatchDirect(ByteBuffer in, int inLen, ByteBuffer out) { + Objects.requireNonNull(in, "in"); + Objects.requireNonNull(out, "out"); + if (!in.isDirect() || !out.isDirect()) { + throw new IllegalArgumentException( + "dispatchDirect requires direct ByteBuffers (use ByteBuffer.allocateDirect)"); + } + if (in.isReadOnly()) { + throw new IllegalArgumentException( + "dispatchDirect requires a writable in ByteBuffer (got a read-only buffer)"); + } + // SEC-2: the native side writes the wire response straight into + // `out` via a `&mut [u8]`; a read-only direct buffer (e.g. a + // read-only MappedByteBuffer) is backed by read-only pages, so + // writing to it is undefined behavior / a process crash. Reject + // it here — the native code cannot recover from a write fault. + if (out.isReadOnly()) { + throw new IllegalArgumentException( + "dispatchDirect requires a writable out ByteBuffer (got a read-only buffer)"); + } + if (inLen < 0 || inLen > in.capacity()) { + throw new IllegalArgumentException( + "inLen " + inLen + " out of range for in.capacity() " + in.capacity()); + } + return dispatchDirect0(in, inLen, out); + } + + /** + * Whether the calling thread is a virtual thread (Java 21+); always + * {@code false} on the Java 17 baseline. Delegates to + * {@link VesperaDirectBufferPool#currentThreadIsVirtual()} — used by + * {@link SmartDispatchModeResolver} to keep pooled direct-buffer work + * off virtual threads. + */ + static boolean currentThreadIsVirtual() { + return VesperaDirectBufferPool.currentThreadIsVirtual(); + } + + /** + * Pooled convenience around {@link #dispatchDirect(ByteBuffer, int, + * ByteBuffer)} using per-thread reusable direct buffers (64 KiB + * initial, doubling up to {@code vespera.direct.maxBufferBytes}, + * default 4 MiB). + * + *

    Returns a read-only view of the thread-local + * response buffer covering exactly the wire response bytes. The + * view is valid only until the next {@code dispatchDirect*} call on + * the same thread — consume (or copy) it before dispatching again. + * + *

    Virtual thread (Project Loom) limitation: The + * per-thread buffer pool is backed by {@link ThreadLocal}, which + * binds to the virtual thread (not the carrier thread) in + * Java 21+ semantics. In a virtual-thread-per-request server, each + * virtual thread allocates a fresh direct buffer and loses all + * pooling benefit; direct memory accumulates until the virtual thread + * is garbage-collected. {@link VesperaDirectBufferPool} detects this + * and routes virtual threads to the GC-managed heap + * {@link #dispatchBytes(byte[])} path. + * + *

    Fallback / overflow policy: + *

      + *
    • Request larger than the cap → falls back to + * {@link #dispatchBytes(byte[])} (safe: no dispatch has run + * yet) and wraps the result.
    • + *
    • Response overflow with {@code retryOnOverflow == true} → + * grows the out buffer (or falls back to {@code dispatchBytes} + * beyond the cap) and dispatches again. The handler + * runs twice — only pass {@code true} for safe + * requests.
    • + *
    • Response overflow with {@code retryOnOverflow == false} → + * throws {@link BufferTooSmallException}.
    • + *
    + * + * @param wireRequest length-prefixed binary wire request + * @param retryOnOverflow whether a response overflow may re-run the + * dispatch (safe requests only) + * @return read-only buffer view of the wire response, positioned at + * 0 with {@code limit()} = response length + */ + public static ByteBuffer dispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow) { + return VesperaDirectBufferPool.dispatchDirectPooled(wireRequest, retryOnOverflow); + } + + /** + * Encode-and-dispatch convenience that skips the intermediate + * wire-sized {@code byte[]} entirely: the wire request is encoded + * straight into the pooled direct in-buffer, so the + * body bytes are copied heap→direct exactly once. Same pooling, + * fallback, overflow, and view-validity semantics as + * {@link #dispatchDirectPooled(byte[], boolean)}. + * + * @param appName target app name (may be {@code null} for default) + * @param method HTTP method (uppercase) + * @param path URL path + * @param query raw query string (may be {@code null}) + * @param headers request headers + * @param body request body bytes (may be empty or {@code null}) + * @param retryOnOverflow whether a response overflow may re-run the + * dispatch (safe requests only) + * @return read-only buffer view of the wire response, valid until + * the next {@code dispatchDirect*} call on this thread + */ + public static ByteBuffer dispatchDirectPooled( + String appName, + String method, + String path, + String query, + Map headers, + byte[] body, + boolean retryOnOverflow) { + requireRequestInputs(method, path, headers); + return VesperaDirectBufferPool.dispatchDirectPooled( + appName, method, path, query, headers, body, retryOnOverflow); + } + + public static ByteBuffer dispatchDirectPooled( + String appName, + String method, + String path, + String query, + HeaderSource headers, + byte[] body, + boolean retryOnOverflow) { + requireRequestInputs(method, path); + return VesperaDirectBufferPool.dispatchDirectPooled( + appName, method, path, query, headers, body, retryOnOverflow); + } + + static ByteBuffer dispatchDirectPooled( + String appName, + String method, + String path, + String query, + HeaderSource headers, + byte[] body, + boolean retryOnOverflow, + boolean currentThreadIsVirtual) { + requireRequestInputs(method, path); + return VesperaDirectBufferPool.dispatchDirectPooled( + appName, method, path, query, headers, body, + retryOnOverflow, currentThreadIsVirtual); + } + + /** + * Encode a request directly into {@code target} + * starting at position 0 — no intermediate wire-sized {@code byte[]}. + * + *

    On success the wire bytes occupy {@code target[0..returned]} + * and {@code target}'s position is left at the end of the written + * region. If {@code target} is too small, returns + * {@code -(requiredSize)} and writes nothing. This is an + * encoding-side size signal: no dispatch has happened, so + * growing the buffer and retrying is always safe (unlike the + * response-overflow retry, which re-runs the handler). + * + * @param appName target app name (may be {@code null} for default) + * @param method HTTP method (uppercase) + * @param path URL path + * @param query raw query string (may be {@code null}) + * @param headers request headers + * @param body request body bytes (may be empty or {@code null}) + * @param target destination buffer (any kind; for the JNI direct + * path use {@code ByteBuffer.allocateDirect}) + * @return total bytes written ({@code >= 4}), or {@code -(required)} + */ + public static int encodeRequestInto( + String appName, + String method, + String path, + String query, + Map headers, + byte[] body, + ByteBuffer target) { + Objects.requireNonNull(target, "target"); + requireRequestInputs(method, path, headers); + return VesperaWireCodec.encodeRequestInto(appName, method, path, query, headers, body, target); + } + + public static int encodeRequestInto( + String appName, + String method, + String path, + String query, + HeaderSource headers, + byte[] body, + ByteBuffer target) { + Objects.requireNonNull(target, "target"); + requireRequestInputs(method, path); + return VesperaWireCodec.encodeRequestInto(appName, method, path, query, headers, body, target); + } + /** * Encode a request into the binary wire format. * @@ -304,7 +806,18 @@ public static byte[] encodeRequest( String query, Map headers, byte[] body) { - return encodeRequest(null, method, path, query, headers, body); + requireRequestInputs(method, path, headers); + return VesperaWireCodec.encodeRequest(null, method, path, query, headers, body); + } + + public static byte[] encodeRequest( + String method, + String path, + String query, + HeaderSource headers, + byte[] body) { + requireRequestInputs(method, path); + return VesperaWireCodec.encodeRequest(null, method, path, query, headers, body); } /** @@ -333,158 +846,48 @@ public static byte[] encodeRequest( String query, Map headers, byte[] body) { - try { - ObjectNode header = MAPPER.createObjectNode(); - header.put("v", WIRE_VERSION); - header.put("method", method); - header.put("path", path); - if (query != null && !query.isEmpty()) { - header.put("query", query); - } - if (headers != null && !headers.isEmpty()) { - ObjectNode hdrs = MAPPER.createObjectNode(); - for (Map.Entry e : headers.entrySet()) { - hdrs.put(e.getKey(), e.getValue()); - } - header.set("headers", hdrs); - } - if (appName != null && !appName.isBlank()) { - header.put("app", appName.trim()); - } - byte[] headerJson = MAPPER.writeValueAsBytes(header); - byte[] bodyBytes = body != null ? body : new byte[0]; - ByteBuffer buf = ByteBuffer - .allocate(4 + headerJson.length + bodyBytes.length) - .order(ByteOrder.BIG_ENDIAN); - buf.putInt(headerJson.length); - buf.put(headerJson); - buf.put(bodyBytes); - return buf.array(); - } catch (IOException e) { - throw new IllegalStateException("encodeRequest serialisation failed", e); - } + requireRequestInputs(method, path, headers); + return VesperaWireCodec.encodeRequest(appName, method, path, query, headers, body); } - /** - * Decode a wire-format response. - * - * @throws IllegalArgumentException if the wire bytes are malformed - */ - public static DecodedResponse decodeResponse(byte[] wire) { - if (wire == null || wire.length < 4) { - throw new IllegalArgumentException( - "wire response too short: " - + (wire == null ? "null" : wire.length + " bytes")); - } - ByteBuffer buf = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN); - int headerLen = buf.getInt(); - if (headerLen < 0 || (long) 4 + headerLen > wire.length) { - throw new IllegalArgumentException( - "wire header_len " + headerLen - + " overflows response (" + wire.length + " bytes)"); - } - try { - JsonNode header = MAPPER.readTree( - new java.io.ByteArrayInputStream(wire, 4, headerLen)); - int status = header.path("status").asInt(500); - - Map headers = new LinkedHashMap<>(); - JsonNode hdrs = header.path("headers"); - if (hdrs.isObject()) { - Iterator> it = hdrs.fields(); - while (it.hasNext()) { - Map.Entry e = it.next(); - JsonNode v = e.getValue(); - if (v.isArray()) { - List list = new ArrayList<>(v.size()); - for (JsonNode item : v) { - list.add(item.asText()); - } - headers.put(e.getKey(), list); - } else { - headers.put(e.getKey(), v.asText()); - } - } - } - - Map metadata = new LinkedHashMap<>(); - JsonNode mdNode = header.path("metadata"); - if (mdNode.isObject()) { - Iterator> it = mdNode.fields(); - while (it.hasNext()) { - Map.Entry e = it.next(); - metadata.put(e.getKey(), e.getValue().asText()); - } - } - - // Hoisted validation errors (Vespera Validated 422 path). - // null when absent (any non-422 or non-Vespera 422). - List> validationErrors = null; - JsonNode veNode = header.path("validation_errors"); - if (veNode.isArray()) { - validationErrors = new ArrayList<>(veNode.size()); - for (JsonNode item : veNode) { - Map entry = new LinkedHashMap<>(); - Iterator> it = item.fields(); - while (it.hasNext()) { - Map.Entry e = it.next(); - entry.put(e.getKey(), e.getValue().asText()); - } - validationErrors.add(entry); - } - } - - int bodyStart = 4 + headerLen; - byte[] body = Arrays.copyOfRange(wire, bodyStart, wire.length); - return new DecodedResponse(status, headers, metadata, body, validationErrors); - } catch (IOException e) { - throw new IllegalArgumentException("wire header JSON parse failed", e); - } + public static byte[] encodeRequest( + String appName, + String method, + String path, + String query, + HeaderSource headers, + byte[] body) { + requireRequestInputs(method, path); + return VesperaWireCodec.encodeRequest(appName, method, path, query, headers, body); } - // --- Internal: bundled native lib extraction --- - - private static void loadBundled(String libraryName) { - String os = detectOs(); - String arch = detectArch(); - String filename = mapLibraryName(os, libraryName); - String resourcePath = "native/" + os + "-" + arch + "/" + filename; - - try (InputStream in = - VesperaBridge.class.getClassLoader().getResourceAsStream(resourcePath)) { - if (in == null) { - throw new UnsatisfiedLinkError("Not found in JAR: " + resourcePath); + private static void requireRequestInputs( + String method, String path, Map headers) { + requireRequestInputs(method, path); + if (headers != null) { + for (Map.Entry header : headers.entrySet()) { + Objects.requireNonNull(header.getKey(), "header key"); + Objects.requireNonNull(header.getValue(), "header value"); } - String suffix = filename.substring(filename.lastIndexOf('.')); - Path temp = Files.createTempFile("vespera-", suffix); - temp.toFile().deleteOnExit(); - Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING); - System.load(temp.toAbsolutePath().toString()); - } catch (IOException e) { - throw new UnsatisfiedLinkError("Extract failed: " + e.getMessage()); } } - private static String detectOs() { - String os = System.getProperty("os.name", "").toLowerCase(); - if (os.contains("win")) return "windows"; - if (os.contains("mac") || os.contains("darwin")) return "macos"; - return "linux"; - } - - private static String detectArch() { - String arch = System.getProperty("os.arch", "").toLowerCase(); - if (arch.contains("amd64") || arch.contains("x86_64")) return "x86_64"; - if (arch.contains("aarch64") || arch.contains("arm64")) return "aarch64"; - return arch; + private static void requireRequestInputs(String method, String path) { + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(path, "path"); + if (path.indexOf('?') >= 0) { + throw new IllegalArgumentException( + "path must not contain '?' — pass the raw query string via the query parameter"); + } } - private static String mapLibraryName(String os, String name) { - return switch (os) { - case "windows" -> name + ".dll"; - case "macos" -> "lib" + name + ".dylib"; - default -> "lib" + name + ".so"; - }; + /** + * Decode a wire-format response. + * + * @throws IllegalArgumentException if the wire bytes are malformed + */ + public static DecodedResponse decodeResponse(byte[] wire) { + return VesperaWireCodec.decodeResponse(wire); } private VesperaBridge() {} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java index 050fd9db..99862c5d 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfiguration.java @@ -4,8 +4,22 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Qualifier; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; /** * Spring Boot autoconfigure entry point for vespera-bridge. @@ -25,16 +39,34 @@ * register a {@code @Bean AppNameResolver} — * the default {@link HeaderAppNameResolver} is automatically * disabled. + *

  • Conservative dispatch mode (opt-out from smart): + * set {@code vespera.bridge.dispatch-mode=bidirectional-streaming} + * to restore the pre-0.2.0 default + * ({@link BidirectionalStreamingDispatchModeResolver}) — every + * request that may carry a body streams both ways. Use when + * you want maximally uniform handler invocation semantics and + * are willing to pay the ~24 µs/request streaming cost on + * small JSON-RPC payloads.
  • *
  • Custom dispatch mode policy: * register a {@code @Bean DispatchModeResolver} — - * the default - * {@link BidirectionalStreamingDispatchModeResolver} is + * the default {@link SmartDispatchModeResolver} is * automatically disabled.
  • *
  • Completely BYO controller: * set {@code vespera.bridge.controller-enabled=false} and * provide your own {@code @RestController} that calls the * {@link VesperaBridge} native methods directly.
  • + *
  • Async response continuation executor: + * replace the {@code vesperaBridgeAsyncResponseExecutor} bean. + * The default is a small named daemon-thread pool.
  • * + * + *

    0.2.0 behavior change: the autoconfigured + * default {@link DispatchModeResolver} flipped from + * {@link BidirectionalStreamingDispatchModeResolver} to + * {@link SmartDispatchModeResolver}. Measured on a small {@code GET + * /health} round-trip through the real JNI boundary: DIRECT 2.2 µs / + * SYNC 3.2 µs vs the old bidirectional 24.1 µs. Restore the old + * behavior with {@code vespera.bridge.dispatch-mode=bidirectional-streaming}. */ @Configuration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) @@ -47,12 +79,131 @@ public AppNameResolver vesperaBridgeAppNameResolver(VesperaBridgeProperties prop return new HeaderAppNameResolver(props.getAppHeader()); } + /** + * Opt-out conservative dispatch mode: every request that may + * carry a body streams both ways + * ({@link BidirectionalStreamingDispatchModeResolver}). Restores + * the pre-0.2.0 default. + * + *

    Declared before the autoconfigured default so that + * {@code @ConditionalOnMissingBean} on the default skips when this + * one is created. Opt-in via + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming}; + * the autoconfigured default is now + * {@link SmartDispatchModeResolver} because DIRECT/SYNC are + * 7–11× cheaper than streaming for small bounded requests + * (measured 2.2–3.2 µs vs 24.1 µs on a small {@code GET /health}). + */ @Bean + @ConditionalOnProperty( + prefix = "vespera.bridge", + name = "dispatch-mode", + havingValue = "bidirectional-streaming") @ConditionalOnMissingBean - public DispatchModeResolver vesperaBridgeDispatchModeResolver() { + public DispatchModeResolver vesperaBridgeBidirectionalStreamingDispatchModeResolver() { return new BidirectionalStreamingDispatchModeResolver(); } + /** + * Autoconfigured default since 0.2.0: + * {@link SmartDispatchModeResolver} picks per request — DIRECT + * (pooled direct buffers, no JNI array copies) for small/bodyless + * safe requests, SYNC for small unsafe requests, + * BIDIRECTIONAL_STREAMING for everything else. + * + *

    The two trade-offs callers accept on the new default: + *

      + *
    • DIRECT retries (re-runs the Rust handler) once when a + * response exceeds {@code vespera.direct.maxBufferBytes} + * (default 4 MiB). This is why DIRECT is restricted to safe + * methods (GET/HEAD/OPTIONS).
    • + *
    • SYNC buffers the full response on the JVM heap. The + * 256 KiB request-size gate keeps the response size + * reasonable for JSON-RPC-shaped traffic.
    • + *
    + * + *

    Restore the pre-0.2.0 behavior with + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming}. + */ + @Bean + @ConditionalOnMissingBean + public DispatchModeResolver vesperaBridgeDispatchModeResolver(VesperaBridgeProperties props) { + // This default bean is created for `dispatch-mode=smart` AND for any + // unrecognized value (the `bidirectional-streaming` opt-out has its own + // @ConditionalOnProperty bean above). Surface a typo instead of letting + // it silently change dispatch semantics to smart. + String mode = props.getDispatchMode(); + if (mode != null + && !mode.equalsIgnoreCase("smart") + && !mode.equalsIgnoreCase("bidirectional-streaming")) { + throw new IllegalArgumentException( + "Unrecognized vespera.bridge.dispatch-mode '" + mode + + "'. Valid values: 'smart' (default), 'bidirectional-streaming'."); + } + return new SmartDispatchModeResolver(); + } + + @Bean("vesperaBridgeAsyncResponseExecutor") + @ConditionalOnMissingBean(name = "vesperaBridgeAsyncResponseExecutor") + public ExecutorService vesperaBridgeAsyncResponseExecutor(VesperaBridgeProperties props) { + // Default (asyncPoolSize <= 0) preserves the historical sizing: + // Math.max(2, Math.min(4, cpus)). A positive vespera.bridge.async-pool-size + // overrides the cap for high-concurrency async dispatch (clamped to >= 1). + int configured = props.getAsyncPoolSize(); + int threads = configured > 0 + ? configured + : Math.max(2, Math.min(4, Runtime.getRuntime().availableProcessors())); + AtomicInteger seq = new AtomicInteger(1); + ThreadFactory factory = task -> { + Thread thread = new Thread(task, "vespera-bridge-async-response-" + seq.getAndIncrement()); + thread.setDaemon(true); + return thread; + }; + int queueCapacity = Math.max(256, threads * 256); + return new ThreadPoolExecutor( + threads, + threads, + 0L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(queueCapacity), + factory, + // AbortPolicy, NOT CallerRunsPolicy: this executor's tasks are + // submitted from the thread that completes the native dispatch + // future — a Rust Tokio worker. CallerRunsPolicy would run the + // heavy wire-response build on that Tokio worker under + // saturation, stealing native dispatch capacity (violating the + // documented "no heavy continuations on Tokio workers" + // contract). AbortPolicy rejects instead; the proxy's + // dispatchAsyncFlow maps the rejection to a 503 backpressure + // signal. The bounded queue still absorbs bursts first. + new ThreadPoolExecutor.AbortPolicy()); + } + + @Bean + @ConditionalOnMissingBean + public VesperaBridgeThreadLocalCleanup vesperaBridgeThreadLocalCleanup() { + return new VesperaBridgeThreadLocalCleanup(); + } + + @Bean + @ConditionalOnProperty( + prefix = "vespera.bridge", + name = "clear-threadlocals-after-request", + havingValue = "true") + public FilterRegistrationBean vesperaBridgeThreadLocalCleanupFilter() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter((ServletRequest request, ServletResponse response, FilterChain chain) -> { + try { + chain.doFilter(request, response); + } finally { + VesperaBridge.clearCurrentThreadBuffers(); + } + }); + registration.setName("vesperaBridgeThreadLocalCleanupFilter"); + registration.setOrder(Integer.MAX_VALUE); + return registration; + } + @Bean @ConditionalOnProperty( prefix = "vespera.bridge", @@ -62,7 +213,15 @@ public DispatchModeResolver vesperaBridgeDispatchModeResolver() { @ConditionalOnMissingBean public VesperaProxyController vesperaProxyController( AppNameResolver appResolver, - DispatchModeResolver modeResolver) { - return new VesperaProxyController(appResolver, modeResolver); + DispatchModeResolver modeResolver, + @Qualifier("vesperaBridgeAsyncResponseExecutor") Executor asyncResponseExecutor, + VesperaBridgeProperties props) { + return new VesperaProxyController( + appResolver, + modeResolver, + asyncResponseExecutor, + props.isDirectRetryOnOverflow(), + props.getMaxBufferedRequestBytes(), + props.getMaxBufferedResponseBytes()); } } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java index 76cae4f4..e739389b 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeProperties.java @@ -19,6 +19,10 @@ * bridge: * app-header: X-My-App # override the default header name * controller-enabled: false # disable our controller (BYO controller) + * direct-retry-on-overflow: false # surface DIRECT overflow instead of retrying + * max-buffered-request-bytes: 10485760 # cap SYNC/ASYNC/DIRECT/STREAMING request buffering + * max-buffered-response-bytes: 67108864 # cap SYNC heap-buffered response bodies + * clear-threadlocals-after-request: false # clear per-thread buffers after each proxied request * }

    */ @ConfigurationProperties(prefix = "vespera.bridge") @@ -42,6 +46,79 @@ public class VesperaBridgeProperties { */ private boolean controllerEnabled = true; + /** + * Dispatch-mode policy for the autoconfigured proxy. + * + *
      + *
    • {@code smart} (default since 0.2.0) — small bounded safe + * requests (Content-Length absent/bodyless or ≤ 1 MiB; + * GET/HEAD/OPTIONS) take the pooled + * direct-buffer path, skipping JNI array copies and + * per-request stream setup; small unsafe requests + * (POST/PUT/PATCH/DELETE) take heap-buffered SYNC; everything else + * falls back to bidirectional streaming. Measured 2.2 µs + * (DIRECT) / 3.2 µs (SYNC) vs 24.1 µs (bidirectional) on + * a small {@code GET /health} round-trip. Trade-offs: + * DIRECT re-runs the handler when a response overflows the + * pooled buffer ({@code vespera.direct.maxBufferBytes}, + * default 4 MiB) — acceptable for safe requests + * only; SYNC fully buffers the response on the JVM heap.
    • + *
    • {@code bidirectional-streaming} — opt-out, restores the + * pre-0.2.0 default: every request that may carry a body + * streams both ways; safe for any payload size; the + * uniform per-request cost is ~24 µs even on small + * JSON-RPC payloads.
    • + *
    + */ + private String dispatchMode = "smart"; + + /** + * Whether the Spring proxy may retry a DIRECT response-buffer overflow + * for safe methods. Default {@code true} preserves the 0.2.x + * behavior (grow the direct response buffer once and re-run the Rust + * handler). Set {@code false} to surface + * {@link VesperaBridge.BufferTooSmallException} as a 500 instead, + * avoiding any automatic double execution. + */ + private boolean directRetryOnOverflow = true; + + /** + * Maximum request-body bytes the Spring proxy may buffer for + * SYNC/ASYNC/DIRECT/STREAMING dispatch modes. The conservative default is + * 64 MiB so a custom resolver cannot accidentally route an unknown-length + * upload into a heap-buffered mode and grow toward the JVM array ceiling. + * Set {@code 0} explicitly to restore unlimited buffering. Bidirectional + * streaming is exempt because it does not fully buffer the request body. + */ + private long maxBufferedRequestBytes = VesperaProxyController.DEFAULT_MAX_BUFFERED_REQUEST_BYTES; + + /** + * Maximum response-body bytes the Spring proxy will serve from the + * heap-buffered SYNC path. Default 64 MiB keeps SmartDispatch's small + * unsafe fast path bounded; set {@code 0} to restore unlimited SYNC + * response buffering. Large/unknown downloads should use streaming modes. + */ + private long maxBufferedResponseBytes = VesperaProxyController.DEFAULT_MAX_BUFFERED_RESPONSE_BYTES; + + /** + * Thread count for the autoconfigured {@code vesperaBridgeAsyncResponseExecutor} + * — the JVM-side pool that parses the ASYNC wire response off the native + * completion thread. Default {@code 0} preserves the historical sizing + * ({@code Math.max(2, Math.min(4, availableProcessors()))}). Set a positive + * value to override the cap for high-concurrency async dispatch; the value + * is clamped to at least {@code 1}. + */ + private int asyncPoolSize = 0; + + /** + * When true, an autoconfigured servlet filter clears vespera's per-thread + * direct/header/scratch buffers in a {@code finally} block after every + * request. Default false keeps hot servlet threads pooled for throughput; + * enable for containers/redeploy setups where idle worker threads outlive + * the Spring context and ThreadLocal retention is more important than reuse. + */ + private boolean clearThreadlocalsAfterRequest = false; + public String getAppHeader() { return appHeader; } @@ -57,4 +134,52 @@ public boolean isControllerEnabled() { public void setControllerEnabled(boolean controllerEnabled) { this.controllerEnabled = controllerEnabled; } + + public String getDispatchMode() { + return dispatchMode; + } + + public void setDispatchMode(String dispatchMode) { + this.dispatchMode = dispatchMode; + } + + public boolean isDirectRetryOnOverflow() { + return directRetryOnOverflow; + } + + public void setDirectRetryOnOverflow(boolean directRetryOnOverflow) { + this.directRetryOnOverflow = directRetryOnOverflow; + } + + public long getMaxBufferedRequestBytes() { + return maxBufferedRequestBytes; + } + + public void setMaxBufferedRequestBytes(long maxBufferedRequestBytes) { + this.maxBufferedRequestBytes = maxBufferedRequestBytes; + } + + public long getMaxBufferedResponseBytes() { + return maxBufferedResponseBytes; + } + + public void setMaxBufferedResponseBytes(long maxBufferedResponseBytes) { + this.maxBufferedResponseBytes = maxBufferedResponseBytes; + } + + public int getAsyncPoolSize() { + return asyncPoolSize; + } + + public void setAsyncPoolSize(int asyncPoolSize) { + this.asyncPoolSize = asyncPoolSize; + } + + public boolean isClearThreadlocalsAfterRequest() { + return clearThreadlocalsAfterRequest; + } + + public void setClearThreadlocalsAfterRequest(boolean clearThreadlocalsAfterRequest) { + this.clearThreadlocalsAfterRequest = clearThreadlocalsAfterRequest; + } } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeThreadLocalCleanup.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeThreadLocalCleanup.java new file mode 100644 index 00000000..caf1f835 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaBridgeThreadLocalCleanup.java @@ -0,0 +1,38 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.ServletRequestEvent; +import jakarta.servlet.ServletRequestListener; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextClosedEvent; + +/** + * Spring lifecycle hook for vespera-bridge ThreadLocal buffers. + * + *

    The DIRECT fast path intentionally keeps per-thread direct buffers hot in + * {@link VesperaDirectBufferPool}. A static {@code ThreadLocal} can otherwise + * retain buffers on servlet worker threads across webapp redeploys when the + * worker pool outlives the Spring context. This listener marks the context as + * closing and clears vespera buffers from any worker thread as its in-flight + * request is destroyed; it also clears the thread that receives the close event. + * + *

    Normal request handling is unchanged: before shutdown, request destruction + * only reads one volatile boolean and leaves pooling intact. + */ +public final class VesperaBridgeThreadLocalCleanup + implements ServletRequestListener, ApplicationListener { + + private volatile boolean closing; + + @Override + public void onApplicationEvent(ContextClosedEvent event) { + closing = true; + VesperaBridge.clearCurrentThreadBuffers(); + } + + @Override + public void requestDestroyed(ServletRequestEvent sre) { + if (closing) { + VesperaBridge.clearCurrentThreadBuffers(); + } + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaDirectBufferPool.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaDirectBufferPool.java new file mode 100644 index 00000000..2ae514bc --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaDirectBufferPool.java @@ -0,0 +1,419 @@ +package com.devfive.vespera.bridge; + +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.Objects; + +import com.devfive.vespera.bridge.VesperaBridge.BufferTooSmallException; +import com.devfive.vespera.bridge.VesperaBridge.HeaderSource; +import com.devfive.vespera.bridge.VesperaWireCodec.ExposedByteArrayOutputStream; + +/** + * Per-thread reusable direct {@link ByteBuffer} pool + * for the {@link VesperaBridge#dispatchDirect(ByteBuffer, int, ByteBuffer)} + * fast path — the allocation-amortising layer that backs the public + * {@code dispatchDirectPooled} entry points. + * + *

    Split out of {@link VesperaBridge} (which owns only JNI-symbol-bound + * native methods + library loading): this class holds the off-heap buffer + * pooling, adaptive retention, virtual-thread fallback, and overflow-retry + * policy. It calls {@link VesperaBridge#dispatchDirect} / + * {@link VesperaBridge#dispatchBytes} for the actual native dispatch and + * {@link VesperaWireCodec} for wire encoding. + * + *

    Virtual thread (Project Loom) limitation: the pool + * is backed by {@link ThreadLocal}, which binds to the virtual + * thread (not the carrier) in Java 21+. {@link #currentThreadIsVirtual()} + * detects this and routes virtual threads to the GC-managed heap + * {@link VesperaBridge#dispatchBytes(byte[])} path so off-heap memory does + * not accumulate per vthread. + */ +final class VesperaDirectBufferPool { + + private VesperaDirectBufferPool() {} + + /** Initial per-thread direct buffer capacity (64 KiB). */ + private static final int DIRECT_INITIAL_CAPACITY = 64 * 1024; + + /** + * Maximum per-thread direct buffer capacity (default 4 MiB, + * overridable via the {@code vespera.direct.maxBufferBytes} system + * property, clamped to 64 KiB–256 MiB). Payloads beyond the cap use the + * configured {@code vespera.direct.oversize-policy}: the default + * {@code heap-fallback} dispatches through {@link VesperaBridge#dispatchBytes(byte[])} + * (fully heap-buffered, not streaming), while {@code throw} rejects before + * native dispatch. + */ + private static final int DIRECT_MAX_HARD_CAPACITY = 256 * 1024 * 1024; + private static final int DIRECT_MAX_CAPACITY = directMaxCapacity(); + + private static int directMaxCapacity() { + int configured = Integer.getInteger("vespera.direct.maxBufferBytes", 4 * 1024 * 1024); + return Math.max(DIRECT_INITIAL_CAPACITY, Math.min(DIRECT_MAX_HARD_CAPACITY, configured)); + } + + /** + * Per-thread hard retention cap for the pooled + * direct buffers (system property + * {@code vespera.direct.maxRetainedBytes}, default 2 MiB; clamped + * to [{@link #DIRECT_INITIAL_CAPACITY}, {@link #DIRECT_MAX_CAPACITY}]). + * + *

    A buffer that a large dispatch grew beyond this cap is shrunk + * back to {@link #DIRECT_INITIAL_CAPACITY} adaptively + * — only after {@link #DIRECT_SHRINK_IDLE_DISPATCHES} consecutive + * dispatches stayed under the cap (so a repeatedly-large idempotent + * endpoint keeps its buffer instead of shrink/overflow/re-run on + * every call), yet a thread that stops handling large responses + * still releases the off-heap memory. Transient growth up to + * {@link #DIRECT_MAX_CAPACITY} for an individual request is always + * allowed — only steady-state retention is capped. + */ + private static final int DIRECT_RETAIN_CAPACITY = Math.max( + DIRECT_INITIAL_CAPACITY, + Math.min(DIRECT_MAX_CAPACITY, + Integer.getInteger("vespera.direct.maxRetainedBytes", 2 * 1024 * 1024))); + + /** + * Index 0 = request buffer, index 1 = response buffer. + * + *

    Held strongly per platform thread so baseline direct buffers stay + * resident on the hot DIRECT path. Oversized buffers are shrunk + * deterministically by {@link #recordDirectPoolUse(ByteBuffer[], int, int)} + * after an idle streak instead of relying on heap-pressure-driven soft + * reference clearing to manage off-heap memory. + */ + private static final ThreadLocal DIRECT_POOL = new ThreadLocal<>(); + + private static final int DIRECT_SHRINK_IDLE_DISPATCHES = 8; + private static final ThreadLocal DIRECT_UNDER_RETAIN_STREAK = + ThreadLocal.withInitial(() -> 0); + + private enum OversizePolicy { HEAP_FALLBACK, THROW } + + private static OversizePolicy oversizePolicy() { + String configured = System.getProperty("vespera.direct.oversize-policy", "heap-fallback"); + if ("throw".equalsIgnoreCase(configured)) { + return OversizePolicy.THROW; + } + if ("heap-fallback".equalsIgnoreCase(configured)) { + return OversizePolicy.HEAP_FALLBACK; + } + throw new IllegalArgumentException( + "Unrecognized vespera.direct.oversize-policy '" + configured + + "'. Valid values: 'heap-fallback', 'throw'."); + } + + private static void rejectPooledFallback(String reason, int required) { + throw new BufferTooSmallException(required, reason + + " cannot use pooled DIRECT under vespera.direct.oversize-policy=throw"); + } + + /** + * Handle to {@code Thread.isVirtual()} (final API since Java 21), + * resolved reflectively so this library still compiles and runs on + * the Java 17 baseline. {@code null} on pre-21 runtimes, where no + * thread is ever virtual. + */ + private static final java.lang.invoke.MethodHandle IS_VIRTUAL = resolveIsVirtual(); + + private static java.lang.invoke.MethodHandle resolveIsVirtual() { + try { + return java.lang.invoke.MethodHandles.lookup() + .findVirtual(Thread.class, "isVirtual", + java.lang.invoke.MethodType.methodType(boolean.class)); + } catch (ReflectiveOperationException pre21Runtime) { + return null; + } + } + + /** + * Whether the calling thread is a virtual thread (Java 21+); always + * {@code false} on the Java 17 baseline runtime. + * + *

    The pooled direct-buffer fast path is backed by + * {@link ThreadLocal}, which binds to the virtual thread + * (not its carrier) in Java 21+ — so on a virtual-thread-per-request + * server every dispatch would allocate a fresh direct buffer and + * accumulate off-heap memory until GC. {@link #dispatchDirectPooled} + * detects this and routes virtual threads to the GC-managed heap + * {@link VesperaBridge#dispatchBytes(byte[])} path instead. + */ + static boolean currentThreadIsVirtual() { + if (IS_VIRTUAL == null) { + return false; + } + try { + return (boolean) IS_VIRTUAL.invokeExact(Thread.currentThread()); + } catch (RuntimeException | Error fatalMustPropagate) { + // JVM Errors (OutOfMemoryError, StackOverflowError, …) and runtime + // exceptions are never the reflective-fallback case — let them + // propagate instead of silently reporting "not virtual". + throw fatalMustPropagate; + } catch (Throwable reflectiveFailureFallBackToPooled) { + // MethodHandle.invokeExact is declared `throws Throwable`; the only + // residual checked failure here is a reflective/linkage problem + // resolving Thread.isVirtual() — fall back to the non-virtual + // (pooled) path, preserving the prior behavior. + return false; + } + } + + /** + * Resolve the calling thread's pooled direct buffers, (re)allocating + * a baseline pair when none exists for this thread. + */ + private static ByteBuffer[] directPool() { + ByteBuffer[] pool = DIRECT_POOL.get(); + if (pool == null) { + pool = new ByteBuffer[] { + ByteBuffer.allocateDirect(DIRECT_INITIAL_CAPACITY), + ByteBuffer.allocateDirect(DIRECT_INITIAL_CAPACITY)}; + DIRECT_POOL.set(pool); + DIRECT_UNDER_RETAIN_STREAK.set(0); + return pool; + } + return pool; + } + + private static void recordDirectPoolUse(ByteBuffer[] pool, int requestLen, int responseLen) { + if (requestLen > DIRECT_RETAIN_CAPACITY || responseLen > DIRECT_RETAIN_CAPACITY) { + DIRECT_UNDER_RETAIN_STREAK.set(0); + return; + } + int streak = DIRECT_UNDER_RETAIN_STREAK.get() + 1; + if (streak < DIRECT_SHRINK_IDLE_DISPATCHES) { + DIRECT_UNDER_RETAIN_STREAK.set(streak); + return; + } + boolean requestGrown = pool[0].capacity() > DIRECT_RETAIN_CAPACITY; + boolean responseGrown = pool[1].capacity() > DIRECT_RETAIN_CAPACITY; + if (requestGrown) { + pool[0] = ByteBuffer.allocateDirect(DIRECT_INITIAL_CAPACITY); + } + if (responseGrown) { + pool[1] = ByteBuffer.allocateDirect(DIRECT_INITIAL_CAPACITY); + } + DIRECT_UNDER_RETAIN_STREAK.set(0); + } + + static void clearCurrentThreadBuffers() { + DIRECT_POOL.remove(); + DIRECT_UNDER_RETAIN_STREAK.remove(); + } + + static boolean directPoolPresentForTest() { + return DIRECT_POOL.get() != null; + } + + static ByteBuffer[] directPoolForTest() { + return directPool(); + } + + static void recordDirectPoolUseForTest(ByteBuffer[] pool, int requestLen, int responseLen) { + recordDirectPoolUse(pool, requestLen, responseLen); + } + + /** Smallest power-of-two-ish growth ≥ {@code needed}, capped. */ + private static int grownCapacity(int needed) { + int cap = DIRECT_INITIAL_CAPACITY; + while (cap < needed) { + cap = Math.min(cap * 2, DIRECT_MAX_CAPACITY); + if (cap == DIRECT_MAX_CAPACITY) break; + } + return Math.max(cap, needed); + } + + /** + * Pooled convenience around {@link VesperaBridge#dispatchDirect(ByteBuffer, + * int, ByteBuffer)} using per-thread reusable direct buffers (64 KiB + * initial, doubling up to {@code vespera.direct.maxBufferBytes}, + * default 4 MiB). See {@link VesperaBridge#dispatchDirectPooled(byte[], + * boolean)} for the full contract. + */ + static ByteBuffer dispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow) { + return dispatchDirectPooled(wireRequest, retryOnOverflow, currentThreadIsVirtual()); + } + + static ByteBuffer dispatchDirectPooled( + byte[] wireRequest, boolean retryOnOverflow, boolean currentThreadIsVirtual) { + Objects.requireNonNull(wireRequest, "wireRequest"); + if (currentThreadIsVirtual || wireRequest.length > DIRECT_MAX_CAPACITY) { + if (oversizePolicy() == OversizePolicy.THROW) { + rejectPooledFallback( + currentThreadIsVirtual ? "virtual thread" : "oversized request", + wireRequest.length); + } + // Explicit heap-fallback: virtual threads avoid per-vthread + // off-heap ThreadLocal retention, and oversized requests cannot fit + // the direct pool. This fully buffers via dispatchBytes; it is not a + // streaming fallback. + return ByteBuffer.wrap(VesperaBridge.dispatchBytes(wireRequest)).asReadOnlyBuffer(); + } + ByteBuffer[] pool = directPool(); + if (pool[0].capacity() < wireRequest.length) { + pool[0] = ByteBuffer.allocateDirect(grownCapacity(wireRequest.length)); + } + ByteBuffer in = pool[0]; + in.clear(); + in.put(wireRequest); + + return dispatchViaPool(pool, wireRequest.length, retryOnOverflow); + } + + static ByteBuffer dispatchDirectPooled( + String appName, + String method, + String path, + String query, + Map headers, + byte[] body, + boolean retryOnOverflow) { + byte[] bodyBytes = body != null ? body : VesperaWireCodec.EMPTY_BODY; + ExposedByteArrayOutputStream hdr = + VesperaWireCodec.fillHeaderJson(appName, method, path, query, headers); + try { + int headerLen = hdr.size(); + int total = VesperaWireCodec.wireTotalLength(headerLen, bodyBytes.length); + if (currentThreadIsVirtual() || total > DIRECT_MAX_CAPACITY) { + if (oversizePolicy() == OversizePolicy.THROW) { + rejectPooledFallback( + currentThreadIsVirtual() ? "virtual thread" : "oversized request", + total); + } + // Explicit heap-fallback: fully buffers via dispatchBytes, not + // streaming. The reusable header buffer is consumed here, before + // any other fillHeaderJson call. + byte[] wire = VesperaWireCodec.assembleWire(hdr.backingArray(), headerLen, bodyBytes); + return ByteBuffer.wrap(VesperaBridge.dispatchBytes(wire)).asReadOnlyBuffer(); + } + ByteBuffer[] pool = directPool(); + if (pool[0].capacity() < total) { + pool[0] = ByteBuffer.allocateDirect(grownCapacity(total)); + } + // Consume the reusable header buffer into the pooled direct buffer. + int written = VesperaWireCodec.assembleInto(hdr.backingArray(), headerLen, bodyBytes, pool[0]); + if (written != total) { + throw new IllegalStateException( + "assembleInto wrote " + written + ", expected " + total); + } + return dispatchViaPool(pool, total, retryOnOverflow); + } finally { + VesperaWireCodec.shrinkHeaderBufferIfOversized(hdr); + } + } + + static ByteBuffer dispatchDirectPooled( + String appName, + String method, + String path, + String query, + HeaderSource headers, + byte[] body, + boolean retryOnOverflow) { + return dispatchDirectPooled( + appName, method, path, query, headers, body, + retryOnOverflow, currentThreadIsVirtual()); + } + + static ByteBuffer dispatchDirectPooled( + String appName, + String method, + String path, + String query, + HeaderSource headers, + byte[] body, + boolean retryOnOverflow, + boolean currentThreadIsVirtual) { + byte[] bodyBytes = body != null ? body : VesperaWireCodec.EMPTY_BODY; + ExposedByteArrayOutputStream hdr = + VesperaWireCodec.fillHeaderJson(appName, method, path, query, headers); + try { + int headerLen = hdr.size(); + int total = VesperaWireCodec.wireTotalLength(headerLen, bodyBytes.length); + if (currentThreadIsVirtual || total > DIRECT_MAX_CAPACITY) { + if (oversizePolicy() == OversizePolicy.THROW) { + rejectPooledFallback( + currentThreadIsVirtual ? "virtual thread" : "oversized request", + total); + } + byte[] wire = VesperaWireCodec.assembleWire(hdr.backingArray(), headerLen, bodyBytes); + return ByteBuffer.wrap(VesperaBridge.dispatchBytes(wire)).asReadOnlyBuffer(); + } + ByteBuffer[] pool = directPool(); + if (pool[0].capacity() < total) { + pool[0] = ByteBuffer.allocateDirect(grownCapacity(total)); + } + int written = VesperaWireCodec.assembleInto(hdr.backingArray(), headerLen, bodyBytes, pool[0]); + if (written != total) { + throw new IllegalStateException( + "assembleInto wrote " + written + ", expected " + total); + } + return dispatchViaPool(pool, total, retryOnOverflow); + } finally { + VesperaWireCodec.shrinkHeaderBufferIfOversized(hdr); + } + } + + /** + * Dispatch the request already prepared in the pooled in-buffer + * ({@code pool[0][0..reqLen]}) and apply the response-overflow + * policy. {@code wireFallback} supplies the equivalent wire bytes + * lazily — only materialised when a permitted retry exceeds the + * pool cap and must take the {@link VesperaBridge#dispatchBytes} path. + */ + private static ByteBuffer dispatchViaPool( + ByteBuffer[] pool, int reqLen, boolean retryOnOverflow) { + boolean recorded = false; + try { + int n = VesperaBridge.dispatchDirect(pool[0], reqLen, pool[1]); + if (n == Integer.MIN_VALUE) { + throw responseExceedsTwoGiBException(); + } + if (n < 0 && n != Integer.MIN_VALUE) { + int required = -n; + if (!retryOnOverflow) { + throw new BufferTooSmallException(required); + } + if (required > DIRECT_MAX_CAPACITY) { + // Response exceeds the pooled direct buffer's hard cap. Do NOT + // heap-buffer the whole response via dispatchBytes — that + // defeats streaming and risks an OOM spike on large downloads + // (a small/bodyless safe GET the SmartDispatch resolver routes + // here can still return gigabytes). Surface the overflow so the + // caller re-routes this request through response streaming. + throw new BufferTooSmallException(required); + } + pool[1] = ByteBuffer.allocateDirect(grownCapacity(required)); + n = VesperaBridge.dispatchDirect(pool[0], reqLen, pool[1]); + } + if (n == Integer.MIN_VALUE) { + throw responseExceedsTwoGiBException(); + } + if (n < 0 && n != Integer.MIN_VALUE) { + // A second overflow is legitimate: the retry re-ran the + // handler, and a non-deterministic handler may produce a + // larger response this time. Surface the new exact size + // instead of retrying unboundedly. + throw new BufferTooSmallException(-n); + } + if (n < 0) { + throw new IllegalStateException( + "dispatchDirect protocol violation: return code " + n + " after retry"); + } + ByteBuffer view = pool[1].asReadOnlyBuffer(); + view.position(0).limit(n); + recordDirectPoolUse(pool, reqLen, n); + recorded = true; + return view; + } finally { + if (!recorded) { + recordDirectPoolUse(pool, reqLen, 0); + } + } + } + + static IllegalStateException responseExceedsTwoGiBException() { + return new IllegalStateException( + "dispatchDirect response exceeds 2 GiB and cannot be represented; use streaming dispatch"); + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaNativeLoader.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaNativeLoader.java new file mode 100644 index 00000000..b73d0db5 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaNativeLoader.java @@ -0,0 +1,127 @@ +package com.devfive.vespera.bridge; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** Native library lookup/extraction helpers for {@link VesperaBridge}. */ +final class VesperaNativeLoader { + + private VesperaNativeLoader() {} + + /** + * Signals the bundled native library is genuinely ABSENT from the + * classpath — the one legitimate reason to fall back to the system + * library path. + */ + static final class BundledNativeAbsent extends RuntimeException { + BundledNativeAbsent(String message) { + super(message); + } + } + + static void loadBundled(String libraryName) { + String os = detectOs(); + String arch = detectArch(); + String filename = mapLibraryName(os, libraryName); + String resourcePath = "native/" + os + "-" + arch + "/" + filename; + + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException sha256Missing) { + throw new UnsatisfiedLinkError( + "SHA-256 unavailable for native library verification: " + + sha256Missing.getMessage()); + } + + try (InputStream in = + VesperaBridge.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (in == null) { + throw new BundledNativeAbsent("Not found in JAR: " + resourcePath); + } + String suffix = filename.substring(filename.lastIndexOf('.')); + Path temp = Files.createTempFile("vespera-", suffix); + boolean loaded = false; + + try { + long copiedBytes; + try (DigestInputStream din = new DigestInputStream(in, digest)) { + copiedBytes = Files.copy(din, temp, StandardCopyOption.REPLACE_EXISTING); + } + byte[] resourceDigest = digest.digest(); + long extractedBytes = Files.size(temp); + if (copiedBytes != extractedBytes) { + throw new UnsatisfiedLinkError("Native library extraction failed for " + resourcePath + + ": copied " + copiedBytes + " bytes but extracted file has " + + extractedBytes + " bytes."); + } + if (Boolean.getBoolean("vespera.native.verifyExtractedDigest")) { + byte[] extractedDigest = digestOfFile(temp, digest); + if (!MessageDigest.isEqual(resourceDigest, extractedDigest)) { + throw new UnsatisfiedLinkError( + "Native library integrity check failed for " + resourcePath + + ": extracted file does not match the bundled resource " + + "(corrupted or modified extraction)."); + } + } + + System.load(temp.toAbsolutePath().toString()); + loaded = true; + temp.toFile().deleteOnExit(); + } finally { + if (!loaded) { + try { + Files.deleteIfExists(temp); + } catch (IOException deleteFailure) { + // The load failure is more important; the temp path is + // still deleteOnExit-free, so do not mask the root cause. + } + } + } + } catch (IOException e) { + UnsatisfiedLinkError ule = new UnsatisfiedLinkError("Extract failed: " + e.getMessage()); + ule.initCause(e); + throw ule; + } + } + + private static byte[] digestOfFile(Path file, MessageDigest digest) throws IOException { + digest.reset(); + try (InputStream fin = Files.newInputStream(file)) { + byte[] buf = new byte[64 * 1024]; + int n; + while ((n = fin.read(buf)) != -1) { + digest.update(buf, 0, n); + } + } + return digest.digest(); + } + + private static String detectOs() { + String os = System.getProperty("os.name", "").toLowerCase(); + if (os.contains("win")) return "windows"; + if (os.contains("mac") || os.contains("darwin")) return "macos"; + return "linux"; + } + + private static String detectArch() { + String arch = System.getProperty("os.arch", "").toLowerCase(); + if (arch.contains("amd64") || arch.contains("x86_64")) return "x86_64"; + if (arch.contains("aarch64") || arch.contains("arm64")) return "aarch64"; + return arch; + } + + private static String mapLibraryName(String os, String name) { + return switch (os) { + case "windows" -> name + ".dll"; + case "macos" -> "lib" + name + ".dylib"; + default -> "lib" + name + ".so"; + }; + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java index 9f472156..b2307a50 100644 --- a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java @@ -1,27 +1,31 @@ package com.devfive.vespera.bridge; -import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.core.io.AbstractResource; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import java.io.IOException; +import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; -import java.util.Enumeration; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.RejectedExecutionException; /** * Catch-all proxy controller — autoconfigured by @@ -47,10 +51,15 @@ * * *

    The autoconfigured defaults ({@link HeaderAppNameResolver} on - * {@code X-Vespera-App} + - * {@link BidirectionalStreamingDispatchModeResolver}) keep the - * proxy transparent for every payload size. Replace either bean - * to change the policy without subclassing this controller. + * {@code X-Vespera-App} + {@link SmartDispatchModeResolver} since + * 0.2.0) keep the proxy transparent for every payload size while + * routing small bounded safe requests through the + * direct-buffer fast path (DIRECT 2.2 µs / SYNC 3.2 µs vs streaming + * 24.1 µs on a small {@code GET /health}). Restore the pre-0.2.0 + * bidirectional default with + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming}, or + * replace either bean to change the policy without subclassing this + * controller. */ @RestController public class VesperaProxyController { @@ -60,26 +69,107 @@ public class VesperaProxyController { private final AppNameResolver appResolver; private final DispatchModeResolver modeResolver; + private final Executor asyncResponseExecutor; + private final boolean directRetryOnOverflow; + private final long maxBufferedRequestBytes; + private final long maxBufferedResponseBytes; + + // Adaptive DIRECT-overflow avoidance: routes that overflowed the pooled + // direct buffer once are streamed up front thereafter, removing the + // repeated DIRECT-overflow-then-stream double dispatch a known-large + // (download) route would otherwise pay on every request. Internal state, so + // it is created directly rather than injected (no bean-wiring change). + private final DirectOverflowMemory directOverflowMemory = new DirectOverflowMemory(); + + static final long DEFAULT_MAX_BUFFERED_REQUEST_BYTES = 64L * 1024L * 1024L; + static final long DEFAULT_MAX_BUFFERED_RESPONSE_BYTES = 64L * 1024L * 1024L; + + /** + * One-time guard for the "custom resolver routed an UNSAFE method to + * DIRECT, downgraded to SYNC" warning. A misconfigured custom + * {@link DispatchModeResolver} would otherwise log on every unsafe + * request; warn once at WARN, then DEBUG thereafter, so the + * misconfiguration is observable without per-request log spam. + */ + private static final java.util.concurrent.atomic.AtomicBoolean + UNSAFE_DIRECT_DOWNGRADE_WARNED = + new java.util.concurrent.atomic.AtomicBoolean(false); public VesperaProxyController(AppNameResolver appResolver, DispatchModeResolver modeResolver) { + this(appResolver, modeResolver, ForkJoinPool.commonPool(), true, + DEFAULT_MAX_BUFFERED_REQUEST_BYTES, DEFAULT_MAX_BUFFERED_RESPONSE_BYTES); + } + + public VesperaProxyController(AppNameResolver appResolver, + DispatchModeResolver modeResolver, + Executor asyncResponseExecutor, + boolean directRetryOnOverflow) { + this(appResolver, modeResolver, asyncResponseExecutor, directRetryOnOverflow, + DEFAULT_MAX_BUFFERED_REQUEST_BYTES, DEFAULT_MAX_BUFFERED_RESPONSE_BYTES); + } + + public VesperaProxyController(AppNameResolver appResolver, + DispatchModeResolver modeResolver, + Executor asyncResponseExecutor, + boolean directRetryOnOverflow, + long maxBufferedRequestBytes) { + this(appResolver, modeResolver, asyncResponseExecutor, directRetryOnOverflow, + maxBufferedRequestBytes, DEFAULT_MAX_BUFFERED_RESPONSE_BYTES); + } + + public VesperaProxyController(AppNameResolver appResolver, + DispatchModeResolver modeResolver, + Executor asyncResponseExecutor, + boolean directRetryOnOverflow, + long maxBufferedRequestBytes, + long maxBufferedResponseBytes) { this.appResolver = Objects.requireNonNull(appResolver, "appResolver"); this.modeResolver = Objects.requireNonNull(modeResolver, "modeResolver"); + this.asyncResponseExecutor = Objects.requireNonNull(asyncResponseExecutor, "asyncResponseExecutor"); + this.directRetryOnOverflow = directRetryOnOverflow; + this.maxBufferedRequestBytes = Math.max(0, maxBufferedRequestBytes); + this.maxBufferedResponseBytes = Math.max(0, maxBufferedResponseBytes); } @RequestMapping(value = "/**", consumes = MediaType.ALL_VALUE) public Object proxy(HttpServletRequest request, HttpServletResponse response) throws IOException { + final RequestShape shape = RequestShape.capture(request); final String appName = appResolver.resolveAppName(request); - final DispatchMode mode = modeResolver.resolveMode(request); - final String method = request.getMethod(); - final String path = request.getRequestURI(); + final Boolean currentThreadIsVirtual; + final DispatchMode mode; + if (modeResolver instanceof SmartDispatchModeResolver smartResolver) { + boolean virtualThread = VesperaBridge.currentThreadIsVirtual(); + currentThreadIsVirtual = Boolean.valueOf(virtualThread); + mode = smartResolver.resolveMode(request, virtualThread); + } else { + currentThreadIsVirtual = null; + mode = modeResolver.resolveMode(request); + } + final String method = shape.method; + // Path RELATIVE to the servlet context: a Spring app deployed under + // a non-root context (e.g. server.servlet.context-path=/api) must + // still forward `/health` — not `/api/health` — so the Rust router + // sees exactly the URL published in the generated openapi.json. + final String path = pathWithinApplication(request); final String query = Objects.toString(request.getQueryString(), ""); - final Map headers = collectHeaders(request); + final VesperaBridge.HeaderSource headers = sink -> HeaderPolicy.forEachRequestHeader(request, sink); + + // Adaptive DIRECT-overflow avoidance: a route that overflowed the + // pooled direct buffer before is streamed up front, so it dispatches + // ONCE instead of paying the DIRECT-overflow-then-stream double + // dispatch again. `shouldAvoidDirect` is a single volatile read until + // the first overflow is recorded, so non-overflowing apps pay nothing. + final DispatchMode effectiveMode = + (mode == DispatchMode.DIRECT + && directOverflowMemory.shouldAvoidDirect(appName, method, path, query)) + ? DispatchMode.STREAMING + : mode; if (log.isDebugEnabled()) { - log.debug("-> Rust {} {} app={} mode={}", method, path, appName, mode); + log.debug("-> Rust {} {} app={} mode={}", method, path, appName, effectiveMode); } // For bidirectional streaming, pass the servlet InputStream @@ -87,16 +177,27 @@ public Object proxy(HttpServletRequest request, // mode, materialise the body bytes here (replaces Spring's // @RequestBody, which we cannot use because it would consume // the InputStream and leave the bidirectional path empty). - switch (mode) { + switch (effectiveMode) { case SYNC: - return dispatchSync(appName, method, path, query, headers, - readBody(request)); + dispatchSync(response, appName, method, path, query, headers, + readBody(request, shape, maxBufferedRequestBytes), + maxBufferedResponseBytes); + return null; case ASYNC: return dispatchAsyncFlow(appName, method, path, query, headers, - readBody(request)); + readBody(request, shape, maxBufferedRequestBytes)); case STREAMING: - dispatchStreaming(request, response, appName, method, path, query, - headers, readBody(request)); + // STREAMING materialises the REQUEST body (only the response + // streams), so it must honour the same buffered-request cap + // as SYNC/ASYNC/DIRECT — otherwise a custom resolver routing + // a bodyful request here would bypass + // vespera.bridge.max-buffered-request-bytes. + dispatchStreaming(response, appName, method, path, query, + headers, readBody(request, shape, maxBufferedRequestBytes)); + return null; + case DIRECT: + dispatchDirectMode(response, appName, method, path, query, headers, + readBody(request, shape, maxBufferedRequestBytes), currentThreadIsVirtual); return null; case BIDIRECTIONAL_STREAMING: default: @@ -106,45 +207,313 @@ public Object proxy(HttpServletRequest request, } /** - * Fully read the servlet request body into a byte array. Used - * by sync / async / response-streaming modes (the bidirectional - * mode forwards the InputStream as-is). + * Resolve the request path RELATIVE to the servlet context path so a + * Spring app deployed under a non-root context + * ({@code server.servlet.context-path=/api}) still forwards the + * context-relative URL the Rust router and the generated + * {@code openapi.json} know — {@code /api/health} on the wire becomes + * {@code /health}. At the root context ({@code getContextPath()} + * empty) the request URI is returned unchanged; a request to the bare + * context root collapses to {@code "/"}. + * + *

    Package-private so unit tests can verify it directly with + * {@code MockHttpServletRequest}. */ - private static byte[] readBody(HttpServletRequest request) throws IOException { + static String pathWithinApplication(HttpServletRequest request) { + String uri = request.getRequestURI(); + String context = request.getContextPath(); + if (context == null || context.isEmpty() || !uri.startsWith(context)) { + return uri; + } + // Only strip when the context is a whole leading path segment — the + // servlet container guarantees this, but guard against a degenerate + // `/apixyz` being mis-stripped against context `/api`. + if (uri.length() > context.length() && uri.charAt(context.length()) != '/') { + return uri; + } + String stripped = uri.substring(context.length()); + return stripped.isEmpty() ? "/" : stripped; + } + + /** + * Largest body for which {@link #readBody} trusts {@code + * Content-Length} enough to pre-allocate the exact array. Beyond + * this (or for unknown length) it falls back to {@code readAllBytes}, + * which grows with the bytes actually present — so a lying / huge + * {@code Content-Length} header cannot force a giant up-front + * allocation. + */ + private static final int MAX_FIXED_BODY = 64 * 1024 * 1024; + + /** + * Largest body that can be materialised into a single Java {@code byte[]} + * (the JVM array-length ceiling is just under {@link Integer#MAX_VALUE}). + * A buffered request whose length provably exceeds this can never be read + * via {@code readAllBytes}/{@code readNBytes}, so it is rejected with 413 + * rather than allowed to attempt an impossible allocation; such requests + * must go through {@code BIDIRECTIONAL_STREAMING}. + */ + private static final long MAX_BUFFERED_BODY = Integer.MAX_VALUE - 8L; + + private static final int DIRECT_BODY_SCRATCH_INITIAL = 16 * 1024; + private static final int DIRECT_BODY_COPY_CHUNK = 1024 * 1024; + private static final int DIRECT_BODY_SCRATCH_RETAIN_CAPACITY = 256 * 1024; + private static final int DIRECT_BODY_SCRATCH_SHRINK_IDLE_WRITES = 8; + private static final ThreadLocal DIRECT_BODY_SCRATCH = + ThreadLocal.withInitial(() -> new byte[DIRECT_BODY_SCRATCH_INITIAL]); + private static final ThreadLocal DIRECT_BODY_SCRATCH_SMALL_WRITE_STREAK = + ThreadLocal.withInitial(() -> 0); + + /** + * Drop this thread's reusable heap scratch buffer used for DIRECT response + * body copies. Intended for servlet-container shutdown/redeploy cleanup; + * keep pooling active during request handling. + */ + static void clearCurrentThreadBuffers() { + DIRECT_BODY_SCRATCH.remove(); + DIRECT_BODY_SCRATCH_SMALL_WRITE_STREAK.remove(); + } + + // Package-private (not private) so unit tests can exercise the + // bodyless fast path and length-based reads with MockHttpServletRequest. + static byte[] readBody(HttpServletRequest request) throws IOException { + return readBody(request, 0); + } + + static byte[] readBody(HttpServletRequest request, long maxBufferedRequestBytes) + throws IOException { + return readBody(request, RequestShape.from(request), maxBufferedRequestBytes); + } + + static byte[] readBody( + HttpServletRequest request, RequestShape shape, long maxBufferedRequestBytes) + throws IOException { + // Provably bodyless requests skip the servlet InputStream + // acquisition + readAllBytes allocations entirely. This covers + // both Content-Length: 0 AND length-less GET/HEAD/OPTIONS (the + // hottest path — the small safe GETs the SmartDispatch + // resolver routes through DIRECT, which previously still paid a + // getInputStream()+readAllBytes() round-trip on an empty body). + if (shape.definitelyBodyless) { + return VesperaWireCodec.EMPTY_BODY; + } + long contentLength = shape.contentLength; + long cap = Math.max(0, maxBufferedRequestBytes); + if (cap > 0 && contentLength > cap) { + throw payloadTooLarge(contentLength, cap); + } + // A buffered body must fit a single Java byte[] (≈ 2 GiB). A larger + // known Content-Length can never be materialised here, so reject it + // (413) instead of letting readAllBytes()/readNBytes() attempt an + // impossible allocation and throw OutOfMemoryError. Such requests must + // go through BIDIRECTIONAL_STREAMING. + if (contentLength > MAX_BUFFERED_BODY) { + throw payloadTooLarge(contentLength, MAX_BUFFERED_BODY); + } try (InputStream in = request.getInputStream()) { - return in.readAllBytes(); + if (cap > 0 && contentLength < 0) { + long cappedPlusOne = cap == Long.MAX_VALUE ? Long.MAX_VALUE : cap + 1; + long effectiveLimit = Math.min(cappedPlusOne, MAX_BUFFERED_BODY); + int readLimit = (int) effectiveLimit; + byte[] body = in.readNBytes(readLimit); + if ((long) body.length > cap) { + throw payloadTooLarge(body.length, cap); + } + if ((long) body.length == MAX_BUFFERED_BODY && cap >= MAX_BUFFERED_BODY) { + throw payloadTooLarge(body.length, MAX_BUFFERED_BODY); + } + return body; + } + if (contentLength > 0 && (cap > 0 || contentLength <= MAX_FIXED_BODY)) { + // Known, bounded length: one exact allocation filled in + // place, skipping readAllBytes()'s grow-by-doubling and + // its final trim copy. readNBytes blocks until the + // buffer is full or EOF; the servlet container caps the + // stream at Content-Length, so a well-formed request + // returns exactly contentLength bytes (a short read + // yields a correctly-sized smaller array). + return in.readNBytes((int) contentLength); + } + // Unknown length (-1) with NO soft cap: a buffered mode is the + // WRONG path for an open-ended stream (it should have been routed + // to BIDIRECTIONAL_STREAMING). Bound the read at MAX_FIXED_BODY + // (64 MiB) instead of the ~2 GiB single-array ceiling, so a + // (mis)configured resolver feeding a runaway chunked upload into a + // buffered mode cannot grow the JVM heap toward OOM. Reading one + // byte past the bound distinguishes "exactly at the bound" from + // "over". Known-length bodies keep the documented `cap=0` + // "unlimited" behaviour below. + if (contentLength < 0) { + byte[] body = in.readNBytes(MAX_FIXED_BODY + 1); + if ((long) body.length > MAX_FIXED_BODY) { + throw payloadTooLarge(body.length, MAX_FIXED_BODY); + } + return body; + } + // Oversized KNOWN length with no explicit cap (cap=0, + // contentLength > MAX_FIXED_BODY): the caller opted into unlimited + // buffering for a SIZED body, so honour it up to the single-array + // ceiling (the actual read stops at the known Content-Length). + byte[] body = in.readNBytes((int) MAX_BUFFERED_BODY); + if ((long) body.length == MAX_BUFFERED_BODY) { + throw payloadTooLarge(body.length, MAX_BUFFERED_BODY); + } + return body; } } - // ── Mode handlers ───────────────────────────────────────────────── + private static ResponseStatusException payloadTooLarge(long actualBytes, long capBytes) { + return new ResponseStatusException( + HttpStatus.PAYLOAD_TOO_LARGE, + "buffered request body exceeds vespera.bridge.max-buffered-request-bytes=" + + capBytes + " (actual " + actualBytes + " bytes)"); + } - /** Sync — full request body materialised, full response materialised. */ - private ResponseEntity dispatchSync( + /** + * Synchronous dispatch — writes the wire response straight to the + * servlet response (status + headers via {@link WireHeaderReader}, + * then the body region written directly from the wire array). This + * drops both the body-sized {@code Arrays.copyOfRange} and the + * {@code ResponseEntity} object that the prior + * {@link #buildResponseEntityFromWire} path allocated per response. + * Mirrors {@link #dispatchDirectMode}; the async path still uses + * {@code buildResponseEntityFromWire} (Spring async completion), but + * returns a zero-copy {@code Resource} view over the wire body. + */ + private static void dispatchSync( + HttpServletResponse response, String appName, String method, String path, String query, - Map headers, byte[] body) { - byte[] bodyBytes = body != null ? body : new byte[0]; + VesperaBridge.HeaderSource headers, byte[] body) throws IOException { + dispatchSync(response, appName, method, path, query, headers, body, 0); + } + + private static void dispatchSync( + HttpServletResponse response, + String appName, String method, String path, String query, + VesperaBridge.HeaderSource headers, byte[] body, + long maxBufferedResponseBytes) throws IOException { byte[] wireReq = VesperaBridge.encodeRequest( - appName, method, path, query, headers, bodyBytes); + appName, method, path, query, headers, body); byte[] wireResp = VesperaBridge.dispatchBytes(wireReq); - DecodedResponse decoded = VesperaBridge.decodeResponse(wireResp); - return buildResponseEntity(decoded); + rejectOversizedBufferedResponse(wireResp, maxBufferedResponseBytes); + writeWireResponse(wireResp, response, method); + } + + private static void rejectOversizedBufferedResponse(byte[] wireResp, long maxBufferedResponseBytes) { + long cap = Math.max(0, maxBufferedResponseBytes); + if (cap <= 0) { + return; + } + int headerLen = VesperaWireCodec.readHeaderLength(wireResp); + long bodyLen = (long) wireResp.length - 4L - headerLen; + if (bodyLen > cap) { + throw new ResponseStatusException( + HttpStatus.PAYLOAD_TOO_LARGE, + "buffered response body exceeds vespera.bridge.max-buffered-response-bytes=" + + cap + " (actual " + bodyLen + " bytes); use streaming dispatch"); + } } /** - * Async — request body materialised, response delivered via a - * {@link CompletableFuture}. Spring MVC adapts the future - * automatically to its servlet-async machinery. + * Write a complete wire response ({@code [u32 BE header_len | JSON + * header | body]}) straight to the servlet response: status + headers + * applied from the header region via the allocation-lean + * {@link WireHeaderReader}, then the body region written directly from + * {@code wire} with no {@code byte[]} slice copy. The exact body + * length is known, so {@code Content-Length} is always proxy-owned and + * set to the exact bytes written to the servlet response. */ + private static void writeWireResponse(byte[] wire, HttpServletResponse response) + throws IOException { + writeWireResponse(wire, response, null); + } + + private static void writeWireResponse(byte[] wire, HttpServletResponse response, String method) + throws IOException { + int headerLen = VesperaWireCodec.readHeaderLength(wire); + int[] statusHolder = {500}; + if (HeaderPolicy.containsConnectionHeaderKey(wire, 4, headerLen)) { + HeaderPolicy.ResponseHeaderAccumulator headerAccumulator = new HeaderPolicy.ResponseHeaderAccumulator(); + WireHeaderReader.apply( + wire, 4, headerLen, + s -> { + statusHolder[0] = s; + response.setStatus(s); + }, + headerAccumulator); + HeaderPolicy.addServletResponseHeaders(response, headerAccumulator); + } else { + WireHeaderReader.apply( + wire, 4, headerLen, + s -> { + statusHolder[0] = s; + response.setStatus(s); + }, + (name, value) -> HeaderPolicy.addServletResponseHeader(response, name, value, null)); + } + int bodyOff = 4 + headerLen; + int bodyLen = wire.length - bodyOff; + boolean statusPermitsBody = responseStatusPermitsBody(statusHolder[0]); + boolean methodPermitsBody = requestMethodPermitsBody(method); + int bytesToWrite = statusPermitsBody && methodPermitsBody ? bodyLen : 0; + response.setContentLength(statusPermitsBody ? bodyLen : 0); + if (bytesToWrite > 0) { + response.getOutputStream().write(wire, bodyOff, bodyLen); + } + } + private CompletableFuture> dispatchAsyncFlow( String appName, String method, String path, String query, - Map headers, byte[] body) { - byte[] bodyBytes = body != null ? body : new byte[0]; + VesperaBridge.HeaderSource headers, byte[] body) { byte[] wireReq = VesperaBridge.encodeRequest( - appName, method, path, query, headers, bodyBytes); - return VesperaBridge.dispatch(wireReq).thenApply(wireResp -> { - DecodedResponse decoded = VesperaBridge.decodeResponse(wireResp); - return buildResponseEntity(decoded); - }); + appName, method, path, query, headers, body); + return VesperaBridge.dispatch(wireReq) + .thenApplyAsync( + wireResp -> buildCappedResponseEntityFromWire( + wireResp, method, maxBufferedResponseBytes), + asyncResponseExecutor) + // The async executor uses AbortPolicy (NOT CallerRunsPolicy): + // under saturation the heavy wire response build must NOT run on + // the thread that completed the native future — that is a Rust + // Tokio worker, and stealing it degrades native dispatch + // throughput (the documented "no heavy continuations on Tokio + // workers" contract). A rejected submission surfaces here as a + // RejectedExecutionException; translate it to a clean 503 + // backpressure signal instead of an opaque 500. `handle` (not + // `exceptionally`) so the wildcard `ResponseEntity` result + // type infers cleanly across the success / failure arms. + .handle((resp, ex) -> ex == null ? resp : asyncFailureToResponse(ex)); + } + + /** + * Map an async-dispatch failure to a response: a saturated-executor + * rejection becomes a {@code 503 Service Unavailable} backpressure signal; + * every other failure is re-propagated unchanged so Spring's async error + * handling maps it exactly as before. Package-private + static so the + * rejection-classification ({@link #isRejectedExecution}) is unit-testable + * without a live JNI dispatch. + */ + static ResponseEntity asyncFailureToResponse(Throwable ex) { + if (isRejectedExecution(ex)) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .contentType(MediaType.TEXT_PLAIN) + .body("vespera: async response executor saturated" + .getBytes(StandardCharsets.UTF_8)); + } + throw (ex instanceof CompletionException ce) ? ce : new CompletionException(ex); + } + + /** Whether {@code ex} (or any cause in its chain) is a rejected submission. */ + static boolean isRejectedExecution(Throwable ex) { + for (Throwable t = ex; t != null; t = t.getCause()) { + if (t instanceof RejectedExecutionException) { + return true; + } + if (t == t.getCause()) { + break; + } + } + return false; } /** @@ -154,16 +523,18 @@ private CompletableFuture> dispatchAsyncFlow( * first body byte hits the wire. */ private void dispatchStreaming( - HttpServletRequest request, HttpServletResponse response, + HttpServletResponse response, String appName, String method, String path, String query, - Map headers, byte[] body) throws IOException { - byte[] bodyBytes = body != null ? body : new byte[0]; + VesperaBridge.HeaderSource headers, byte[] body) throws IOException { byte[] wireReq = VesperaBridge.encodeRequest( - appName, method, path, query, headers, bodyBytes); + appName, method, path, query, headers, body); + BodyPermittingOutputStream bodyOut = + new BodyPermittingOutputStream(response.getOutputStream(), method); VesperaBridge.dispatchStreamingWithHeader( wireReq, - headerBytes -> applyDecodedHeader(headerBytes, response), - response.getOutputStream()); + headerBytes -> bodyOut.applyPermitsBody( + applyDecodedHeader(headerBytes, response, method)), + bodyOut); response.getOutputStream().flush(); } @@ -177,27 +548,264 @@ private void dispatchStreaming( private void dispatchBidirectional( HttpServletRequest request, HttpServletResponse response, String appName, String method, String path, String query, - Map headers) throws IOException { + VesperaBridge.HeaderSource headers) throws IOException { byte[] wireHeader = VesperaBridge.encodeRequestHeader( appName, method, path, query, headers); + BodyPermittingOutputStream bodyOut = + new BodyPermittingOutputStream(response.getOutputStream(), method); VesperaBridge.dispatchFullStreamingWithHeader( wireHeader, - headerBytes -> applyDecodedHeader(headerBytes, response), + headerBytes -> bodyOut.applyPermitsBody( + applyDecodedHeader(headerBytes, response, method)), request.getInputStream(), - response.getOutputStream()); + bodyOut); response.getOutputStream().flush(); } - // ── Helpers ────────────────────────────────────────────────────── + /** + * Direct-buffer dispatch — request body materialised (DIRECT is + * gated to small bounded payloads by the resolver), response served + * from the pooled direct buffer without a {@code byte[]} + * materialisation: the header slice is decoded to commit + * status/headers, then the body region is channelled straight into + * the servlet output stream. + * + *

    Overflow retry (which re-runs the Rust handler) is permitted + * only for safe methods (GET/HEAD/OPTIONS), which are not + * intended to mutate server state. The replayed response may still + * differ (for example timestamps or generated request IDs); for every + * other method — including + * idempotent-but-unsafe PUT/DELETE, whose second run can return a + * different response (e.g. DELETE → 204 then 404) — a + * {@link VesperaBridge.BufferTooSmallException} surfaces as a + * {@code 500} with the required size, so the controller never + * double-executes a handler whose response could change. + */ + private void dispatchDirectMode( + HttpServletResponse response, + String appName, String method, String path, String query, + VesperaBridge.HeaderSource headers, byte[] body, + Boolean currentThreadIsVirtual) throws IOException { + if (!isSafe(method)) { + // DIRECT runs the Rust handler on the FIRST dispatch before any + // overflow is known; for an UNSAFE method an overflow would 500 + // *after* the side effect already happened (partial unsafe + // execution). A custom DispatchModeResolver can route an unsafe + // method here, so gate it at the controller boundary: serve unsafe + // requests via SYNC, which never re-runs the handler. + // + // The autoconfigured SmartDispatchModeResolver never routes unsafe + // methods to DIRECT, so reaching here means a CUSTOM resolver is + // misconfigured. Make it observable: warn once (then DEBUG) so the + // operator sees the silent downgrade without per-request spam. + if (UNSAFE_DIRECT_DOWNGRADE_WARNED.compareAndSet(false, true)) { + log.warn("DispatchModeResolver routed unsafe method {} to DIRECT; " + + "downgrading to SYNC (DIRECT overflow retry re-runs the handler, " + + "unsafe for non-safe methods). Fix the custom resolver to avoid " + + "this downgrade. Further occurrences log at DEBUG.", method); + } else if (log.isDebugEnabled()) { + log.debug("DispatchModeResolver routed unsafe method {} to DIRECT; " + + "downgrading to SYNC.", method); + } + dispatchSync(response, appName, method, path, query, headers, body, + maxBufferedResponseBytes); + return; + } + ByteBuffer wireResp; + try { + // Encodes straight into the pooled direct buffer — no + // intermediate wire-sized byte[]. + boolean retry = directRetryOnOverflow && isSafe(method); + wireResp = currentThreadIsVirtual == null + ? VesperaBridge.dispatchDirectPooled( + appName, method, path, query, headers, body, retry) + : VesperaBridge.dispatchDirectPooled( + appName, method, path, query, headers, body, + retry, currentThreadIsVirtual.booleanValue()); + } catch (VesperaBridge.BufferTooSmallException overflow) { + // The first dispatch already ran; its oversized result was discarded. + if (isSafe(method) && directRetryOnOverflow) { + // Safe method + retry enabled: the response is larger than the + // pooled direct buffer's hard cap. Re-route through response + // streaming so a large download streams chunk-by-chunk instead + // of being heap-buffered — the prior dispatchBytes fallback + // could spike the JVM heap (OOM) on multi-GiB responses. A safe + // re-run is not intended to mutate state, but its response may + // differ (timestamps, random IDs). The DIRECT path has not + // committed yet, so streaming takes over cleanly. + // + // Remember this route so the NEXT request to it streams up + // front (see DirectOverflowMemory + the effectiveMode downgrade + // in proxy()) — avoiding a repeated DIRECT-overflow-then-stream + // double dispatch on a known-large route. Recorded only on this + // safe + retry path, where we actually fall back to streaming. + directOverflowMemory.recordOverflow(appName, method, path, query); + dispatchStreaming(response, appName, method, path, query, headers, body); + return; + } + // Unsafe method (or retry disabled): re-running could return a + // different response (e.g. DELETE → 204 then 404), so surface the + // size to the operator instead of silently double-executing. + byte[] error = ("vespera DIRECT overflow: response needs " + + overflow.requiredSize() + + " bytes; route this request via BIDIRECTIONAL_STREAMING") + .getBytes(StandardCharsets.UTF_8); + response.setStatus(500); + response.setContentType("text/plain; charset=utf-8"); + response.setContentLength(error.length); + response.getOutputStream().write(error); + response.getOutputStream().flush(); + return; + } + + // Commit status + headers parsed straight from the direct buffer — + // no byte[] copy, no DecodedResponse object graph (maps / metadata / + // body views). addHeader on the still-uncommitted response is + // equivalent to setHeader for a header's first value and appends for + // multi-valued headers (e.g. set-cookie). + int bodyLen = applyDirectHeaderAndPositionBody(wireResp, response, method); + + // Stream the body region of the direct buffer with an explicit + // per-thread heap scratch. Channels.newChannel(OutputStream) + // allocates its own temporary heap buffer for direct-buffer writes; + // keeping the scratch here makes the copy strategy predictable and + // avoids one allocation per DIRECT response. Loop until the whole + // ByteBuffer region is consumed before flushing/committing. + if (bodyLen > 0) { + writeDirectBody(wireResp, response.getOutputStream()); + } + } + + /** + * Read and validate the wire header length prefix against the actual + * buffer length BEFORE {@link WireHeaderReader#apply} indexes into it. + * The direct / streaming callback paths receive these bytes straight + * from native Rust; a malformed length (negative, or overrunning the + * buffer) must surface as a clear {@link IllegalArgumentException} + * rather than an {@link IndexOutOfBoundsException} escaping mid-response. + * Mirrors the guard the heap {@code byte[]} paths + * ({@link #writeWireResponse}, {@link #buildResponseEntityFromWire}) + * already apply. + */ + static int readValidatedHeaderLen(ByteBuffer wire) { + // Delegates to the single source of truth in VesperaWireCodec so the + // u32 BE prefix decode + bounds contract stays byte-identical across + // every wire-frame split site (heap byte[] and direct ByteBuffer). The + // helper decodes from absolute bytes (order-independent) — never + // wire.getInt(0), which honours the buffer's CURRENT byte order — so a + // LITTLE_ENDIAN view can never misparse the big-endian wire prefix. + return VesperaWireCodec.readHeaderLength(wire); + } + + // Package-private so tests can verify DIRECT header/body-length behavior + // without invoking the native dispatchDirect JNI symbol. + static int applyDirectHeaderAndPositionBody( + ByteBuffer wireResp, HttpServletResponse response) { + return applyDirectHeaderAndPositionBody(wireResp, response, null); + } + + static int applyDirectHeaderAndPositionBody( + ByteBuffer wireResp, HttpServletResponse response, String method) { + int headerLen = readValidatedHeaderLen(wireResp); + int[] statusHolder = {500}; + if (HeaderPolicy.containsConnectionHeaderKey(wireResp, 4, headerLen)) { + HeaderPolicy.ResponseHeaderAccumulator headerAccumulator = new HeaderPolicy.ResponseHeaderAccumulator(); + WireHeaderReader.apply( + wireResp, + 4, + headerLen, + s -> { + statusHolder[0] = s; + response.setStatus(s); + }, + headerAccumulator); + HeaderPolicy.addServletResponseHeaders(response, headerAccumulator); + } else { + WireHeaderReader.apply( + wireResp, + 4, + headerLen, + s -> { + statusHolder[0] = s; + response.setStatus(s); + }, + (name, value) -> HeaderPolicy.addServletResponseHeader(response, name, value, null)); + } + int bodyOff = 4 + headerLen; + int bodyLen = wireResp.limit() - bodyOff; + boolean statusPermitsBody = responseStatusPermitsBody(statusHolder[0]); + boolean methodPermitsBody = requestMethodPermitsBody(method); + int bytesToWrite = statusPermitsBody && methodPermitsBody ? bodyLen : 0; + response.setContentLength(statusPermitsBody ? bodyLen : 0); + wireResp.position(bodyOff); + return bytesToWrite; + } + + private static boolean responseStatusPermitsBody(int status) { + return (status < 100 || status >= 200) && status != 204 && status != 304; + } + + private static boolean responsePermitsBody(int status, String method) { + return responseStatusPermitsBody(status) && requestMethodPermitsBody(method); + } + + private static boolean requestMethodPermitsBody(String method) { + return method == null || !"HEAD".equalsIgnoreCase(method); + } + + private static void writeDirectBody(ByteBuffer body, OutputStream out) throws IOException { + int initialRemaining = body.remaining(); + try { + byte[] scratch = directBodyScratch(Math.min(initialRemaining, DIRECT_BODY_COPY_CHUNK)); + while (body.hasRemaining()) { + int n = Math.min(body.remaining(), scratch.length); + body.get(scratch, 0, n); + out.write(scratch, 0, n); + } + } finally { + shrinkDirectBodyScratchIfIdle(initialRemaining); + } + } - private static Map collectHeaders(HttpServletRequest request) { - Map headers = new LinkedHashMap<>(); - Enumeration names = request.getHeaderNames(); - while (names.hasMoreElements()) { - String name = names.nextElement(); - headers.put(name.toLowerCase(Locale.ROOT), request.getHeader(name)); + private static byte[] directBodyScratch(int required) { + byte[] scratch = DIRECT_BODY_SCRATCH.get(); + if (scratch.length < required) { + scratch = new byte[Math.min(DIRECT_BODY_COPY_CHUNK, required)]; + DIRECT_BODY_SCRATCH.set(scratch); + } + return scratch; + } + + private static void shrinkDirectBodyScratchIfIdle(int remainingAfterWrite) { + byte[] scratch = DIRECT_BODY_SCRATCH.get(); + if (scratch.length <= DIRECT_BODY_SCRATCH_RETAIN_CAPACITY) { + DIRECT_BODY_SCRATCH_SMALL_WRITE_STREAK.set(0); + return; + } + if (remainingAfterWrite > DIRECT_BODY_SCRATCH_RETAIN_CAPACITY) { + DIRECT_BODY_SCRATCH_SMALL_WRITE_STREAK.set(0); + return; + } + int streak = DIRECT_BODY_SCRATCH_SMALL_WRITE_STREAK.get() + 1; + if (streak >= DIRECT_BODY_SCRATCH_SHRINK_IDLE_WRITES) { + DIRECT_BODY_SCRATCH.set(new byte[DIRECT_BODY_SCRATCH_INITIAL]); + DIRECT_BODY_SCRATCH_SMALL_WRITE_STREAK.set(0); + } else { + DIRECT_BODY_SCRATCH_SMALL_WRITE_STREAK.set(streak); } - return headers; + } + + /** + * "Safe" per RFC 9110 (GET/HEAD/OPTIONS) — not intended to mutate server + * state, so the DIRECT overflow retry is allowed even though the replayed + * response may differ (timestamps, random IDs). Idempotent-but-unsafe + * methods (PUT/DELETE) are intentionally excluded: their second run can + * return a different response (e.g. DELETE → 204 then 404), so on overflow + * they fail with {@link VesperaBridge.BufferTooSmallException} instead of + * auto-retrying and silently double-executing. + */ + private static boolean isSafe(String method) { + return HttpMethods.isSafe(method); } /** @@ -205,62 +813,182 @@ private static Map collectHeaders(HttpServletRequest request) { * called from streaming dispatch callbacks BEFORE the first body * byte is written, while the response is still uncommitted. */ - private static void applyDecodedHeader(byte[] headerBytes, - HttpServletResponse response) { - DecodedResponse meta = VesperaBridge.decodeResponse(headerBytes); - response.setStatus(meta.status()); - for (Map.Entry entry : meta.headers().entrySet()) { - Object val = entry.getValue(); - if (val instanceof List list) { - for (Object v : list) { - response.addHeader(entry.getKey(), String.valueOf(v)); - } - } else if (val != null) { - response.setHeader(entry.getKey(), String.valueOf(val)); - } + static boolean applyDecodedHeader(byte[] headerBytes, + HttpServletResponse response, + String method) { + // Apply status + headers straight from the wire header bytes via + // the allocation-lean WireHeaderReader — the same path + // dispatchDirectMode uses. This avoids the DecodedResponse object + // graph (headers map, the always-allocated metadata LinkedHashMap, + // and the body ByteBuffer view) that VesperaBridge.decodeResponse + // builds, on every streaming dispatch's header callback. + // addHeader on an uncommitted response equals setHeader for a + // header's first value and appends for multi-valued headers + // (e.g. set-cookie), preserving the prior semantics. + int headerLen = VesperaWireCodec.readHeaderLength(headerBytes); + int[] statusHolder = {500}; + if (HeaderPolicy.containsConnectionHeaderKey(headerBytes, 4, headerLen)) { + HeaderPolicy.ResponseHeaderAccumulator headerAccumulator = new HeaderPolicy.ResponseHeaderAccumulator(); + WireHeaderReader.apply( + headerBytes, 4, headerLen, + s -> { + statusHolder[0] = s; + response.setStatus(s); + }, + headerAccumulator); + HeaderPolicy.addServletResponseHeaders(response, headerAccumulator); + } else { + WireHeaderReader.apply( + headerBytes, 4, headerLen, + s -> { + statusHolder[0] = s; + response.setStatus(s); + }, + (name, value) -> HeaderPolicy.addServletResponseHeader(response, name, value, null)); } + return responsePermitsBody(statusHolder[0], method); } /** - * Convert a fully-decoded sync/async wire response into a - * Spring {@link ResponseEntity}. Body is delivered as - * {@link String} for text-like Content-Types, - * {@code byte[]} otherwise. + * Build a {@link ResponseEntity} straight from the wire response + * {@code byte[]} with minimal allocation: + * + *

      + *
    • status + headers via the allocation-lean + * {@link WireHeaderReader} (parses directly to {@link HttpHeaders} — + * no {@code DecodedResponse} graph: no {@code metadata} map, no + * intermediate headers map, no body {@code ByteBuffer} views), and
    • + *
    • body exposed as a {@link org.springframework.core.io.Resource} + * view over the wire tail — no body-sized {@code byte[]} slice copy.
    • + *
    + * + *

    {@link VesperaBridge#decodeResponse(byte[])} stays the public API for + * external/streaming consumers; this is a controller-internal fast path. + * Pure Java (no JNI) — run by the controller on its configured async + * response executor instead of the native completion thread. */ - private static ResponseEntity buildResponseEntity(DecodedResponse decoded) { + static ResponseEntity buildResponseEntityFromWire(byte[] wire) { + return buildResponseEntityFromWire(wire, null); + } + + static ResponseEntity buildResponseEntityFromWire(byte[] wire, String method) { + int headerLen = VesperaWireCodec.readHeaderLength(wire); HttpHeaders httpHeaders = new HttpHeaders(); - for (Map.Entry entry : decoded.headers().entrySet()) { - Object val = entry.getValue(); - if (val instanceof List list) { - for (Object v : list) { - httpHeaders.add(entry.getKey(), String.valueOf(v)); + int[] statusHolder = {500}; + if (HeaderPolicy.containsConnectionHeaderKey(wire, 4, headerLen)) { + HeaderPolicy.ResponseHeaderAccumulator headerAccumulator = new HeaderPolicy.ResponseHeaderAccumulator(); + WireHeaderReader.apply( + wire, + 4, + headerLen, + s -> statusHolder[0] = s, + headerAccumulator); + for (HeaderPolicy.HeaderPair header : headerAccumulator.headers) { + if (!HeaderPolicy.isHopByHopResponseHeader(header.name()) + && !HeaderPolicy.isContentLengthHeader(header.name()) + && !HeaderPolicy.isConnectionNominatedHeader(header.name(), headerAccumulator.connectionTokens)) { + httpHeaders.add(header.name(), header.value()); } - } else if (val != null) { - httpHeaders.set(entry.getKey(), String.valueOf(val)); } + } else { + WireHeaderReader.apply( + wire, + 4, + headerLen, + s -> statusHolder[0] = s, + (name, value) -> { + if (!HeaderPolicy.isHopByHopResponseHeader(name) && !HeaderPolicy.isContentLengthHeader(name)) { + httpHeaders.add(name, value); + } + }); + } + HttpStatusCode status = HttpStatusCode.valueOf(statusHolder[0]); + int bodyOff = 4 + headerLen; + int bodyLen = wire.length - bodyOff; + boolean statusPermitsBody = responseStatusPermitsBody(statusHolder[0]); + int bytesToExpose = statusPermitsBody && requestMethodPermitsBody(method) ? bodyLen : 0; + httpHeaders.setContentLength(statusPermitsBody ? bodyLen : 0); + return new ResponseEntity<>( + new WireBodyResource(wire, bodyOff, bytesToExpose), httpHeaders, status); + } + + /** + * Build the buffered {@code ASYNC} response entity, enforcing the + * {@code vespera.bridge.max-buffered-response-bytes} cap FIRST — parity with + * the {@code SYNC} path ({@link #dispatchSync} via + * {@link #rejectOversizedBufferedResponse}). Without this a custom + * {@link DispatchModeResolver} returning {@link DispatchMode#ASYNC} would + * heap-buffer an arbitrarily large Rust response (retained through + * {@link WireBodyResource} until Spring finishes writing it), defeating the + * cap and risking OOM / GC pressure. Runs on the async response executor + * (NOT the Tokio completion thread), so the cap check stays off the native + * worker. Package-private + static so the cap wiring is unit-testable + * without a live JNI dispatch. + */ + static ResponseEntity buildCappedResponseEntityFromWire( + byte[] wire, String method, long maxBufferedResponseBytes) { + rejectOversizedBufferedResponse(wire, maxBufferedResponseBytes); + return buildResponseEntityFromWire(wire, method); + } + + static final class BodyPermittingOutputStream extends OutputStream { + private final OutputStream delegate; + private final String method; + private boolean permitsBody = true; + + BodyPermittingOutputStream(OutputStream delegate, String method) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.method = method; + } + + void applyPermitsBody(boolean permitsBody) { + this.permitsBody = permitsBody; + } + + @Override + public void write(int b) throws IOException { + if (permitsBody) { + delegate.write(b); + } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (permitsBody) { + delegate.write(b, off, len); + } + } + + @Override + public void flush() throws IOException { + delegate.flush(); + } + } + + static final class WireBodyResource extends AbstractResource { + private final byte[] wire; + private final int offset; + private final int length; + + WireBodyResource(byte[] wire, int offset, int length) { + this.wire = Objects.requireNonNull(wire, "wire"); + this.offset = offset; + this.length = length; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(wire, offset, length); + } + + @Override + public long contentLength() { + return length; + } + + @Override + public String getDescription() { + return "vespera wire response body slice"; } - HttpStatus status = HttpStatus.valueOf(decoded.status()); - String contentType = httpHeaders.getFirst(HttpHeaders.CONTENT_TYPE); - if (isTextContentType(contentType)) { - String bodyStr = new String(decoded.body(), StandardCharsets.UTF_8); - return new ResponseEntity<>(bodyStr, httpHeaders, status); - } - return new ResponseEntity<>(decoded.body(), httpHeaders, status); - } - - private static boolean isTextContentType(String ct) { - if (ct == null) return true; - String mime = ct.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); - return mime.startsWith("text/") - || mime.equals("application/json") - || mime.endsWith("+json") - || mime.equals("application/xml") - || mime.endsWith("+xml") - || mime.equals("application/javascript") - || mime.equals("application/ecmascript") - || mime.equals("application/yaml") - || mime.equals("application/x-yaml") - || mime.equals("application/x-www-form-urlencoded") - || mime.equals("application/graphql"); } } diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaWireCodec.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaWireCodec.java new file mode 100644 index 00000000..66739df4 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaWireCodec.java @@ -0,0 +1,681 @@ +package com.devfive.vespera.bridge; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.Objects; + +import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse; +import com.devfive.vespera.bridge.VesperaBridge.HeaderSink; +import com.devfive.vespera.bridge.VesperaBridge.HeaderSource; + +/** + * Binary wire-format request encoding and response decoding for + * {@link VesperaBridge}. + * + *

    This is the pure-Java, native-free half of the bridge: it turns + * Java request parts into the length-prefixed wire bytes the Rust side + * parses, and decodes wire responses back into a {@link DecodedResponse}. + * It holds no JNI symbols, so it lives outside {@link VesperaBridge} + * (whose class name is fixed by the {@code Java_com_devfive_vespera_bridge_VesperaBridge_*} + * native symbol contract). + * + *

    Wire layout (request and response share it): + *

    + *   bytes 0..4    : u32 BE = header_json byte length N
    + *   bytes 4..4+N  : UTF-8 JSON header
    + *   bytes 4+N..   : raw body bytes (no encoding applied)
    + * 
    + * + *

    Package-private: callers go through the {@link VesperaBridge} + * public delegators (and {@link VesperaDirectBufferPool} for the + * direct-buffer path). + */ +final class VesperaWireCodec { + + private VesperaWireCodec() {} + + /** Lowercase hex digits for the JSON C0 control-character escapes. */ + private static final byte[] HEX = { + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + }; + private static final int WIRE_VERSION = 1; + /** Shared empty request body — avoids a {@code new byte[0]} per call. */ + static final byte[] EMPTY_BODY = new byte[0]; + private static final int HEADER_INITIAL_CAPACITY = 256; + private static final int HEADER_RETAIN_CAPACITY = 32 * 1024; + /** + * Hard ceiling on the per-thread header encode buffer (64 MiB). The wire + * request header only ever carries method/path/query/headers/app — never + * the body, which is appended separately in {@link #assembleWire} / + * {@link #assembleInto} — and servlet containers already cap inbound header + * sizes orders of magnitude below this. It is pure defense-in-depth: a + * pathological header that tried to grow the buffer past the ceiling fails + * fast with an exception instead of doubling toward an OutOfMemoryError. + */ + private static final int MAX_HEADER_BUFFER_BYTES = 64 * 1024 * 1024; + + /** + * Per-thread reusable byte buffer for {@link #fillHeaderJson}. + * Reset (size cleared, capacity preserved) per call and filled + * byte-direct — no per-call encoder object. If one request grows + * the backing array past {@link #HEADER_RETAIN_CAPACITY}, the next + * use on that thread drops it back to {@link #HEADER_INITIAL_CAPACITY} + * so oversized cookies/headers do not pin a large array for the + * servlet-thread lifetime. Virtual-thread caveat as the direct pool: + * each vthread gets its own ~256 B buffer in Java 21+ and loses pooling + * until GC. + */ + private static final ThreadLocal HEADER_BUF = + ThreadLocal.withInitial(() -> new ExposedByteArrayOutputStream(HEADER_INITIAL_CAPACITY)); + + /** + * Drop this thread's reusable wire-header encoder buffer. Intended for + * servlet-container shutdown/redeploy hooks; normal request handling keeps + * the pool hot and must not call this per request. + */ + static void clearCurrentThreadBuffers() { + HEADER_BUF.remove(); + } + + static int currentHeaderBufferCapacityForTest() { + return HEADER_BUF.get().capacity(); + } + + /** + * {@link ByteArrayOutputStream} that exposes its backing array so the + * serialized header is copied straight into the wire (heap array or + * direct buffer) without {@link ByteArrayOutputStream#toByteArray()} + * first materialising a second, exact-sized copy per request. + * + *

    Callers MUST read only {@code [0, size())}: the backing array is + * usually larger than the content (grow-by-doubling) and is reused + * across calls on the same thread, so the bytes must be consumed + * before the next {@link #fillHeaderJson} on that thread. + */ + static final class ExposedByteArrayOutputStream extends ByteArrayOutputStream { + ExposedByteArrayOutputStream(int size) { + super(size); + } + + /** Backing buffer; valid content is {@code [0, size())} only. */ + byte[] backingArray() { + return buf; + } + + int capacity() { + return buf.length; + } + + /** + * Append one byte WITHOUT the inherited {@code synchronized} — + * {@link #HEADER_BUF} is thread-local, so the monitor is pure + * overhead on this single-threaded encode hot path. Grows the + * backing array by doubling, mirroring {@link ByteArrayOutputStream}. + */ + void put(int b) { + if (count == buf.length) { + buf = java.util.Arrays.copyOf(buf, growCap(buf.length, count + 1)); + } + buf[count++] = (byte) b; + } + + /** + * Append the bytes of an ASCII literal (caller guarantees every + * char is {@code < 0x80}) — used for the fixed JSON structure + * (keys, braces, colons). Non-synchronized, single bulk reserve. + */ + void putAscii(String lit) { + int n = lit.length(); + if (count + n > buf.length) { + buf = java.util.Arrays.copyOf(buf, growCap(buf.length, count + n)); + } + for (int i = 0; i < n; i++) { + buf[count++] = (byte) lit.charAt(i); + } + } + + /** + * Smallest power-of-two growth of {@code current} that holds + * {@code needed} bytes, capped at {@link #MAX_HEADER_BUFFER_BYTES}. + * The cap is only ever consulted on a (rare) reallocation, so the + * encode hot path pays nothing. A {@code needed} beyond the ceiling — + * only reachable by a pathological header far larger than any servlet + * container admits — fails fast instead of doubling toward an OOM. + */ + private static int growCap(int current, int needed) { + if (needed > MAX_HEADER_BUFFER_BYTES) { + throw new IllegalArgumentException( + "wire header exceeds " + MAX_HEADER_BUFFER_BYTES + " bytes"); + } + int cap = current < 1 ? 1 : current; + while (cap < needed) { + cap <<= 1; + if (cap < 0 || cap > MAX_HEADER_BUFFER_BYTES) { + return MAX_HEADER_BUFFER_BYTES; + } + } + return cap; + } + } + + private static final class HeaderJsonSink implements HeaderSink { + private final ExposedByteArrayOutputStream buf; + private boolean started; + + HeaderJsonSink(ExposedByteArrayOutputStream buf) { + this.buf = buf; + } + + @Override + public void put(String lowerName, String value) { + Objects.requireNonNull(lowerName, "header key"); + Objects.requireNonNull(value, "header value"); + if (started) { + buf.put(','); + } else { + buf.putAscii(",\"headers\":{"); + started = true; + } + writeJsonString(buf, lowerName); + buf.put(':'); + writeJsonString(buf, value); + } + } + + // ── Encode ─────────────────────────────────────────────────────── + + static byte[] encodeRequest( + String appName, + String method, + String path, + String query, + Map headers, + byte[] body) { + ExposedByteArrayOutputStream hdr = fillHeaderJson(appName, method, path, query, headers); + try { + return assembleWire(hdr.backingArray(), hdr.size(), body != null ? body : EMPTY_BODY); + } finally { + shrinkHeaderBufferIfOversized(hdr); + } + } + + static byte[] encodeRequest( + String appName, + String method, + String path, + String query, + HeaderSource headers, + byte[] body) { + ExposedByteArrayOutputStream hdr = fillHeaderJson(appName, method, path, query, headers); + try { + return assembleWire(hdr.backingArray(), hdr.size(), body != null ? body : EMPTY_BODY); + } finally { + shrinkHeaderBufferIfOversized(hdr); + } + } + + static int encodeRequestInto( + String appName, + String method, + String path, + String query, + Map headers, + byte[] body, + ByteBuffer target) { + ExposedByteArrayOutputStream hdr = fillHeaderJson(appName, method, path, query, headers); + try { + return assembleInto( + hdr.backingArray(), hdr.size(), body != null ? body : EMPTY_BODY, target); + } finally { + shrinkHeaderBufferIfOversized(hdr); + } + } + + static int encodeRequestInto( + String appName, + String method, + String path, + String query, + HeaderSource headers, + byte[] body, + ByteBuffer target) { + ExposedByteArrayOutputStream hdr = fillHeaderJson(appName, method, path, query, headers); + try { + return assembleInto( + hdr.backingArray(), hdr.size(), body != null ? body : EMPTY_BODY, target); + } finally { + shrinkHeaderBufferIfOversized(hdr); + } + } + + /** + * Total wire length {@code 4 + headerLen + bodyLen}, computed in + * {@code long} and validated against {@code Integer.MAX_VALUE}. + * + *

    A body approaching the ~2 GiB Java array limit would otherwise + * overflow the {@code int} addition into a negative / small value, + * corrupting capacity checks ({@code target.capacity() < total}) and + * array sizing ({@code new byte[...]} → {@code NegativeArraySizeException}). + * A buffered wire request cannot exceed 2 GiB on the JVM regardless, so + * an overflow is a hard, explanatory {@link IllegalArgumentException} + * pointing the caller at streaming dispatch — never a silent corruption. + */ + static int wireTotalLength(int headerLen, int bodyLen) { + long total = 4L + headerLen + bodyLen; + if (total > Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "wire request exceeds 2 GiB (4 + headerLen=" + headerLen + + " + bodyLen=" + bodyLen + " = " + total + + " bytes); use streaming dispatch for payloads this large"); + } + return (int) total; + } + + /** + * Decode and validate the u32 BE header-length prefix at bytes {@code 0..4} + * of a heap wire frame — the single source of truth for + * the frame split shared by {@link #decodeResponse} and the + * {@link VesperaProxyController} write/build paths, so the bounds contract + * can never drift between the (previously duplicated) call sites. + * + *

    The prefix is read from absolute bytes (big-endian, order-independent), + * never {@code ByteBuffer.getInt} which honours the buffer's current byte + * order. + * + * @return the header JSON length {@code N} (so the body is {@code wire[4+N..]}) + * @throws IllegalArgumentException if {@code wire} is shorter than the + * 4-byte prefix, or the decoded length is negative or overflows the frame + */ + static int readHeaderLength(byte[] wire) { + if (wire == null || wire.length < 4) { + throw new IllegalArgumentException( + "wire response too short: " + + (wire == null ? "null" : wire.length + " bytes")); + } + int headerLen = ((wire[0] & 0xFF) << 24) | ((wire[1] & 0xFF) << 16) + | ((wire[2] & 0xFF) << 8) | (wire[3] & 0xFF); + if (headerLen < 0 || 4L + headerLen > wire.length) { + throw new IllegalArgumentException( + "wire header_len " + headerLen + + " overflows response (" + wire.length + " bytes)"); + } + return headerLen; + } + + /** + * {@link ByteBuffer} sibling of {@link #readHeaderLength(byte[])} — decodes + * the u32 BE header-length prefix from absolute bytes {@code 0..4} of + * {@code wire} (honouring neither the buffer's position nor its byte order), + * validating against {@code wire.limit()}. + * + * @return the header JSON length {@code N} + * @throws IllegalArgumentException if the buffer is shorter than the 4-byte + * prefix, or the decoded length is negative or overflows the limit + */ + static int readHeaderLength(ByteBuffer wire) { + int limit = wire.limit(); + if (limit < 4) { + throw new IllegalArgumentException("wire response too short: " + limit + " bytes"); + } + int headerLen = ((wire.get(0) & 0xFF) << 24) + | ((wire.get(1) & 0xFF) << 16) + | ((wire.get(2) & 0xFF) << 8) + | (wire.get(3) & 0xFF); + if (headerLen < 0 || 4L + headerLen > limit) { + throw new IllegalArgumentException( + "wire header_len " + headerLen + " overflows response (" + limit + " bytes)"); + } + return headerLen; + } + + /** Internal: write {@code [u32 BE len | headerJson[0..headerLen] | body]} at position 0. */ + static int assembleInto(byte[] headerJson, int headerLen, byte[] body, ByteBuffer target) { + int total = wireTotalLength(headerLen, body.length); + if (target.capacity() < total) { + return -total; + } + if (target.isReadOnly()) { + throw new IllegalArgumentException("encode target buffer is read-only"); + } + target.clear(); + target.put((byte) (headerLen >>> 24)); + target.put((byte) (headerLen >>> 16)); + target.put((byte) (headerLen >>> 8)); + target.put((byte) headerLen); + target.put(headerJson, 0, headerLen); + if (body.length > 0) { + target.put(body); + } + return total; + } + + /** Internal: assemble a heap wire array from pre-serialised parts. */ + static byte[] assembleWire(byte[] headerJson, int headerLen, byte[] body) { + byte[] wire = new byte[wireTotalLength(headerLen, body.length)]; + // Write the u32 BE length prefix directly — avoids the + // HeapByteBuffer wrapper object that + // ByteBuffer.allocate(...).array() allocates per request; the + // arraycopy intrinsics handle the header + body. Byte-identical + // to the prior ByteBuffer path. + wire[0] = (byte) (headerLen >>> 24); + wire[1] = (byte) (headerLen >>> 16); + wire[2] = (byte) (headerLen >>> 8); + wire[3] = (byte) headerLen; + System.arraycopy(headerJson, 0, wire, 4, headerLen); + System.arraycopy(body, 0, wire, 4 + headerLen, body.length); + return wire; + } + + /** + * Internal: serialise the wire request header JSON + * byte-direct into the per-thread {@link #HEADER_BUF} + * — no Jackson generator (and its per-call object + scratch buffer) + * is allocated. Emits the same shape and field order the prior + * {@code JsonGenerator} path did ({@code v}, {@code method}, + * {@code path}, optional {@code query}/{@code headers}/{@code app}), + * with the same omission rules. String values are escaped + UTF-8 + * encoded by {@link #writeJsonString} using exactly the escape set + * Jackson's {@code UTF8JsonGenerator} produced (the quote, the + * backslash, and the C0 controls; {@code /} and non-ASCII pass + * through), so the bytes stay valid JSON the Rust {@code serde_json} + * side parses identically. + */ + static ExposedByteArrayOutputStream fillHeaderJson(String appName, String method, + String path, String query, Map headers) { + String normalizedAppName = normalizedAppName(appName); + ExposedByteArrayOutputStream buf = reusableHeaderBuffer(); + try { + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(path, "path"); + buf.putAscii("{\"v\":"); + // WIRE_VERSION is a single-digit constant; write its ASCII digit + // directly to avoid the per-request `Integer.toString(1)` allocation + // the old `writeAsciiInt` made on every encode. Byte-identical output. + buf.put((byte) ('0' + WIRE_VERSION)); + buf.putAscii(",\"method\":"); + writeJsonString(buf, method); + buf.putAscii(",\"path\":"); + writeCombinedPath(buf, path, query); + if (headers != null && !headers.isEmpty()) { + buf.putAscii(",\"headers\":{"); + boolean first = true; + for (Map.Entry e : headers.entrySet()) { + if (!first) { + buf.put(','); + } + first = false; + writeJsonString(buf, Objects.requireNonNull(e.getKey(), "header key")); + buf.put(':'); + writeJsonString(buf, Objects.requireNonNull(e.getValue(), "header value")); + } + buf.put('}'); + } + if (normalizedAppName != null) { + buf.putAscii(",\"app\":"); + writeJsonString(buf, normalizedAppName); + } + buf.put('}'); + return buf; + } catch (RuntimeException | Error failure) { + shrinkHeaderBufferIfOversized(buf); + throw failure; + } + } + + static ExposedByteArrayOutputStream fillHeaderJson(String appName, String method, + String path, String query, HeaderSource headers) { + String normalizedAppName = normalizedAppName(appName); + ExposedByteArrayOutputStream buf = reusableHeaderBuffer(); + try { + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(path, "path"); + buf.putAscii("{\"v\":"); + // WIRE_VERSION is a single-digit constant; write its ASCII digit + // directly to avoid the per-request `Integer.toString(1)` allocation + // the old `writeAsciiInt` made on every encode. Byte-identical output. + buf.put((byte) ('0' + WIRE_VERSION)); + buf.putAscii(",\"method\":"); + writeJsonString(buf, method); + buf.putAscii(",\"path\":"); + writeCombinedPath(buf, path, query); + if (headers != null) { + HeaderJsonSink sink = new HeaderJsonSink(buf); + headers.writeTo(sink); + if (sink.started) { + buf.put('}'); + } + } + if (normalizedAppName != null) { + buf.putAscii(",\"app\":"); + writeJsonString(buf, normalizedAppName); + } + buf.put('}'); + return buf; + } catch (RuntimeException | Error failure) { + shrinkHeaderBufferIfOversized(buf); + throw failure; + } + } + + static String normalizedAppName(String appName) { + if (appName == null) { + return null; + } + int start = 0; + int end = appName.length(); + while (start < end && Character.isWhitespace(appName.charAt(start))) { + start++; + } + while (end > start && Character.isWhitespace(appName.charAt(end - 1))) { + end--; + } + if (start == end) { + return null; + } + return start == 0 && end == appName.length() ? appName : appName.substring(start, end); + } + + private static ExposedByteArrayOutputStream reusableHeaderBuffer() { + ExposedByteArrayOutputStream buf = HEADER_BUF.get(); + if (buf.capacity() > HEADER_RETAIN_CAPACITY) { + buf = new ExposedByteArrayOutputStream(HEADER_INITIAL_CAPACITY); + HEADER_BUF.set(buf); + } else { + buf.reset(); + } + return buf; + } + + /** + * Proactively release a per-thread header buffer that one pathologically + * large header grew past {@link #HEADER_RETAIN_CAPACITY}. Called right + * after the header is built and consumed, so the oversized backing array + * is dropped immediately instead of staying pinned for the servlet + * thread's lifetime until that thread happens to encode another request. + * + *

    The bytes have already been consumed by the caller + * ({@code assembleWire} / {@code assembleInto} / {@code dispatchBytes}) + * before this runs, so replacing the buffer here is safe. + * {@link #reusableHeaderBuffer()} still keeps its lazy shrink as a + * defense-in-depth fallback for any path that does not call this. + */ + static void shrinkHeaderBufferIfOversized(ExposedByteArrayOutputStream buf) { + if (buf.capacity() > HEADER_RETAIN_CAPACITY) { + HEADER_BUF.set(new ExposedByteArrayOutputStream(HEADER_INITIAL_CAPACITY)); + } + } + + /** + * Append {@code s} as a quoted JSON string straight into {@code out} + * as UTF-8, escaping only the JSON-mandatory characters — the quote, + * the backslash, and the C0 controls (short {@code \b \t \n \f \r} + * forms, four-hex escapes otherwise) — exactly the set the prior + * Jackson {@code UTF8JsonGenerator} emitted (it does not escape + * {@code /} or non-ASCII). Single pass, no per-string {@code byte[]}: + * printable ASCII is written verbatim, the rest UTF-8 encoded inline + * (surrogate pairs become 4-byte sequences). + */ + private static void writeJsonString(ExposedByteArrayOutputStream out, String s) { + out.put('"'); + writeJsonStringBody(out, s); + out.put('"'); + } + + /** + * Write the {@code "path"} field VALUE as the full request target. When a + * query is present, emit {@code "path?query"} as ONE JSON string + * (byte-direct — no intermediate Java {@code String} concat); otherwise the + * escaped path alone. Folding the query into {@code path} drops the + * separate {@code query} wire field so the Rust dispatch side borrows the + * target for {@code Uri} parsing instead of re-joining {@code path + '?' + + * query}. Byte-equivalent to the prior two-field form after URI parsing + * (axum routes on the path component, the query is preserved verbatim). + */ + private static void writeCombinedPath( + ExposedByteArrayOutputStream out, String path, String query) { + if (query == null || query.isEmpty()) { + writeJsonString(out, path); + return; + } + out.put('"'); + writeJsonStringBody(out, path); + out.put('?'); + writeJsonStringBody(out, query); + out.put('"'); + } + + /** + * Write the escaped UTF-8 body of a JSON string — the same + * bytes {@link #writeJsonString} emits but WITHOUT the surrounding quotes — + * so a caller can concatenate several escaped segments inside ONE JSON + * string. Used to emit the request target {@code path?query} as a single + * {@code "path"} field (no separate {@code query} field), so the Rust + * dispatch side borrows the target directly instead of re-joining + * {@code path + '?' + query} (~4% per query-GET; see the Rust `query_path` + * bench and {@code wire_contract.rs}). + */ + private static void writeJsonStringBody(ExposedByteArrayOutputStream out, String s) { + int n = s.length(); + for (int i = 0; i < n; i++) { + char c = s.charAt(i); + if (c >= 0x20 && c < 0x80) { + if (c == '"' || c == '\\') { + out.put('\\'); + } + out.put(c); + } else if (c < 0x20) { + switch (c) { + case '\b' -> { + out.put('\\'); + out.put('b'); + } + case '\t' -> { + out.put('\\'); + out.put('t'); + } + case '\n' -> { + out.put('\\'); + out.put('n'); + } + case '\f' -> { + out.put('\\'); + out.put('f'); + } + case '\r' -> { + out.put('\\'); + out.put('r'); + } + default -> { + out.put('\\'); + out.put('u'); + out.put('0'); + out.put('0'); + out.put(HEX[(c >> 4) & 0xF]); + out.put(HEX[c & 0xF]); + } + } + } else if (c < 0x800) { + out.put(0xC0 | (c >> 6)); + out.put(0x80 | (c & 0x3F)); + } else if (Character.isHighSurrogate(c) + && i + 1 < n + && Character.isLowSurrogate(s.charAt(i + 1))) { + int cp = Character.toCodePoint(c, s.charAt(++i)); + out.put(0xF0 | (cp >> 18)); + out.put(0x80 | ((cp >> 12) & 0x3F)); + out.put(0x80 | ((cp >> 6) & 0x3F)); + out.put(0x80 | (cp & 0x3F)); + } else if (Character.isSurrogate(c)) { + // Unpaired UTF-16 surrogate (a lone high surrogate not + // followed by a low surrogate, or a lone low surrogate). + // UTF-8 must never encode surrogate code points, so emit a + // six-character JSON escape (backslash, u, four hex digits) + // instead of the invalid 3-byte sequence the BMP branch + // below would produce — this keeps the wire header valid + // UTF-8 / RFC 8259 JSON and round-trips losslessly through + // serde_json on the Rust side. + out.put('\\'); + out.put('u'); + out.put(HEX[(c >> 12) & 0xF]); + out.put(HEX[(c >> 8) & 0xF]); + out.put(HEX[(c >> 4) & 0xF]); + out.put(HEX[c & 0xF]); + } else { + out.put(0xE0 | (c >> 12)); + out.put(0x80 | ((c >> 6) & 0x3F)); + out.put(0x80 | (c & 0x3F)); + } + } + } + + // ── Decode ───────────────────────────────────────────────────────── + + /** + * Decode a wire-format response. + * + * @throws IllegalArgumentException if the wire bytes are malformed + */ + static DecodedResponse decodeResponse(byte[] wire) { + int headerLen = readHeaderLength(wire); + // Manual decode via the allocation-lean WireHeaderReader tokenizer + // (the same parser the DIRECT / streaming header callbacks use) + // instead of a Jackson JsonParser — drops the per-response parser + + // IOContext allocation. Output is shape-identical: status (default + // 500), headers (String | List), metadata (pre-sized), + // validation_errors, and unknown fields (incl. "v") skipped. + WireHeaderReader.Decoded d = WireHeaderReader.decode(wire, 4, headerLen); + ByteBuffer buf = ByteBuffer.wrap(wire); + buf.position(4 + headerLen).limit(wire.length); + ByteBuffer body = buf.slice().asReadOnlyBuffer(); + return new DecodedResponse( + d.status, + copyDecodedHeaders(d.headers), + d.metadata == null ? Map.of() : Map.copyOf(d.metadata), + body, + copyValidationErrors(d.validationErrors)); + } + + private static Map copyDecodedHeaders(Map headers) { + if (headers == null || headers.isEmpty()) { + return Map.of(); + } + java.util.LinkedHashMap copy = new java.util.LinkedHashMap<>(headers.size()); + headers.forEach((key, value) -> copy.put(key, + value instanceof java.util.List list ? java.util.List.copyOf(list) : value)); + return Map.copyOf(copy); + } + + private static java.util.List> copyValidationErrors( + java.util.List> validationErrors) { + if (validationErrors == null) { + return null; + } + java.util.ArrayList> copy = new java.util.ArrayList<>(validationErrors.size()); + for (Map error : validationErrors) { + copy.add(Map.copyOf(error)); + } + return java.util.List.copyOf(copy); + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/WireHeaderReader.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/WireHeaderReader.java new file mode 100644 index 00000000..16feadbd --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/WireHeaderReader.java @@ -0,0 +1,998 @@ +package com.devfive.vespera.bridge; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.IntConsumer; + +/** + * Zero-copy reader for the response wire header, used by the DIRECT + * dispatch path to apply {@code status} + {@code headers} straight from + * the pooled direct {@link ByteBuffer} — no intermediate {@code byte[]} + * copy, no {@code DecodedResponse} object graph (maps / metadata / body + * views), no per-call allocation beyond the header-value {@link String}s + * the servlet API itself requires. + * + *

    Reads bytes via absolute {@link ByteBuffer#get(int)} so a + * direct buffer (no backing array, which {@code Jackson.createParser} + * cannot consume without a copy) is parsed in place. + * + *

    Not a general JSON validator: it assumes the well-formed, + * fixed-schema header produced by the Rust {@code serde_json} side. Only + * the quote / backslash / control escapes and raw UTF-8 that + * {@code serde_json} emits are handled. Unknown fields ({@code v}, + * {@code metadata}, {@code validation_errors}, …) are skipped. + */ +final class WireHeaderReader { + + /** + * Drop this thread's direct-buffer string decode scratch. Intended for + * servlet-container shutdown/redeploy cleanup; do not call per request. + */ + static void clearCurrentThreadBuffers() { + WireHeaderStringSupport.clearCurrentThreadBuffers(); + } + + private final ByteBuffer buf; + private final byte[] array; + private int pos; + private final int end; + + private WireHeaderReader(ByteBuffer buf, int off, int len) { + this.buf = buf; + this.array = null; + this.pos = off; + this.end = off + len; + } + + private WireHeaderReader(byte[] array, int off, int len) { + this.buf = null; + this.array = array; + this.pos = off; + this.end = off + len; + } + + /** + * Parse the header JSON in {@code buf[off .. off+len]} and apply it: + * {@code statusSink} is invoked exactly once (default {@code 500} + * when the {@code status} field is absent, matching + * {@code decodeResponse}); {@code headerSink} is invoked once per + * header value (multiple times for multi-valued headers such as + * {@code set-cookie}). + */ + static void apply( + ByteBuffer buf, + int off, + int len, + IntConsumer statusSink, + BiConsumer headerSink) { + WireHeaderReader r = new WireHeaderReader(buf, off, len); + int status = 500; + r.requireObjectStart(); + r.beginObject(); + int seen = 0; + int key; + while ((key = r.nextRootKey()) != KEY_END) { + seen = r.rejectDuplicateRootKey(seen, key); + switch (key) { + case KEY_STATUS -> status = r.readStatusCode(); + case KEY_HEADERS -> { + if (r.isObjectStart()) { + r.beginObject(); + String k; + // Canonical keys reuse one shared String per common + // header name (content-type, content-length, …) — + // the same allocation-free path decode() uses, so + // the per-request DIRECT/streaming apply() no longer + // allocates a fresh key String for each header. + while ((k = r.nextKeyCanonical()) != null) { + if (r.isArrayStart()) { + r.beginArray(); + while (r.hasNextElement()) { + headerSink.accept(k, r.readString()); + } + } else { + headerSink.accept(k, r.readString()); + } + } + } else { + r.skipValue(); + } + } + // KEY_OTHER: "v", "metadata", "validation_errors", … — + // matched by bytes, value skipped, never materialised. + default -> r.skipValue(); + } + } + r.requireFullyConsumed(); + statusSink.accept(status); + } + + static void apply( + byte[] buf, + int off, + int len, + IntConsumer statusSink, + BiConsumer headerSink) { + WireHeaderReader r = new WireHeaderReader(buf, off, len); + int status = 500; + r.requireObjectStart(); + r.beginObject(); + int seen = 0; + int key; + while ((key = r.nextRootKey()) != KEY_END) { + seen = r.rejectDuplicateRootKey(seen, key); + switch (key) { + case KEY_STATUS -> status = r.readStatusCode(); + case KEY_HEADERS -> { + if (r.isObjectStart()) { + r.beginObject(); + String k; + while ((k = r.nextKeyCanonical()) != null) { + if (r.isArrayStart()) { + r.beginArray(); + while (r.hasNextElement()) { + headerSink.accept(k, r.readString()); + } + } else { + headerSink.accept(k, r.readString()); + } + } + } else { + r.skipValue(); + } + } + default -> r.skipValue(); + } + } + r.requireFullyConsumed(); + statusSink.accept(status); + } + + /** Decoded response-header components (see {@link #decode}). */ + static final class Decoded { + int status = 500; + Map headers; + // Defaults to the shared empty immutable map; overwritten by decode() + // when a metadata object is present — a single-entry Map.of for the + // common {"version":...} shape (no hash table), a LinkedHashMap only + // for the rare 2+ key case. + Map metadata = Map.of(); + List> validationErrors; + } + + /** + * Full decode of the response wire header for + * {@link VesperaBridge#decodeResponse(byte[])} — {@code status}, + * {@code headers} ({@link String} or {@link List}<String> for + * multi-valued names), {@code metadata}, and {@code validation_errors} + * — reusing this reader's tested tokenizer instead of allocating a + * Jackson {@code JsonParser} + {@code IOContext} per response. + * + *

    Output is shape-identical to the prior Jackson path for the + * well-formed, fixed-schema header the Rust {@code serde_json} side + * emits: status defaults to {@code 500} when absent; {@code headers} + * stays {@code null} when no header field is present; {@code metadata} + * is always a (possibly empty) map; {@code validationErrors} is + * {@code null} unless the {@code validation_errors} field is present; + * unknown fields (incl. {@code v}) are skipped without materialising. + */ + static Decoded decode(ByteBuffer buf, int off, int len) { + WireHeaderReader r = new WireHeaderReader(buf, off, len); + return r.decodeRoot(); + } + + static Decoded decode(byte[] buf, int off, int len) { + WireHeaderReader r = new WireHeaderReader(buf, off, len); + return r.decodeRoot(); + } + + private Decoded decodeRoot() { + Decoded out = new Decoded(); + requireObjectStart(); + beginObject(); + int seen = 0; + int key; + while ((key = nextRootKey()) != KEY_END) { + seen = rejectDuplicateRootKey(seen, key); + switch (key) { + case KEY_STATUS -> out.status = readStatusCode(); + case KEY_HEADERS -> { + if (isObjectStart()) { + beginObject(); + String k; + while ((k = nextKeyCanonical()) != null) { + if (out.headers == null) { + // Pre-size for a typical response header + // count (content-type, content-length, …). + out.headers = new LinkedHashMap<>(8); + } + if (isArrayStart()) { + beginArray(); + List list = new ArrayList<>(); + while (hasNextElement()) { + list.add(readString()); + } + out.headers.put(k, list); + } else { + out.headers.put(k, readString()); + } + } + } else { + skipValue(); + } + } + case KEY_METADATA -> { + if (isObjectStart()) { + beginObject(); + out.metadata = readStringMap(); + } else { + skipValue(); + } + } + case KEY_VALIDATION -> { + if (isArrayStart()) { + beginArray(); + out.validationErrors = new ArrayList<>(); + while (hasNextElement()) { + if (!isObjectStart()) { + // Fixed schema is an array of objects; a + // non-object element (only on malformed + // input) is skipped so the cursor still + // reaches the array end cleanly. + skipValue(); + continue; + } + beginObject(); + Map entry = new LinkedHashMap<>(4); + String k; + while ((k = nextKeyCanonical()) != null) { + entry.put(k, readPrimitiveValue()); + } + out.validationErrors.add(entry); + } + } else { + skipValue(); + } + } + // KEY_OTHER: "v" and any unknown field — value skipped, + // never materialised. + default -> skipValue(); + } + } + requireFullyConsumed(); + return out; + } + + /** + * Read a string→string object (the {@code metadata} shape) into the + * smallest map: {@link Map#of()} when empty, a single-entry immutable + * {@link Map#of(Object, Object)} for the overwhelmingly common one-key + * case ({@code {"version":...}}) — no hash table allocated — and a + * mutable {@link LinkedHashMap} only for the rare 2+ key case (which + * also tolerates duplicate keys, last-wins, like the prior map). + * Assumes the object was already entered ({@link #beginObject}). + */ + Map readStringMap() { + String k0 = nextKeyCanonical(); + if (k0 == null) { + return Map.of(); + } + String v0 = readString(); + String k1 = nextKeyCanonical(); + if (k1 == null) { + return Map.of(k0, v0); + } + Map m = new LinkedHashMap<>(8); + m.put(k0, v0); + m.put(k1, readString()); + String k; + while ((k = nextKeyCanonical()) != null) { + m.put(k, readString()); + } + return m; + } + + private void skipWs() { + while (pos < end) { + int c = byteAt(pos); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + pos++; + } else { + break; + } + } + } + + private int cur() { + return pos < end ? byteAt(pos) : -1; + } + + private int byteAt(int index) { + return array != null ? array[index] & 0xFF : buf.get(index) & 0xFF; + } + + private void requireFullyConsumed() { + skipWs(); + if (pos != end) { + throw err("trailing data after root object"); + } + } + + int peek() { + skipWs(); + return cur(); + } + + private IllegalArgumentException err(String what) { + return new IllegalArgumentException("wire header JSON: " + what + " at offset " + pos); + } + + private void requireObjectStart() { + if (peek() != '{') { + throw err("expected object"); + } + } + + private int rejectDuplicateRootKey(int seen, int key) { + if (key < 0) { + return seen; + } + int bit = 1 << key; + if ((seen & bit) != 0) { + throw err("duplicate root key"); + } + return seen | bit; + } + + private void expect(char c) { + skipWs(); + if (cur() != c) { + throw err("expected '" + c + "'"); + } + pos++; + } + + void beginObject() { + expect('{'); + } + + /** Next member key, or {@code null} at object end (stateless across nesting). */ + String nextKey() { + skipWs(); + int c = cur(); + if (c == ',') { + pos++; + skipWs(); + c = cur(); + } + if (c == '}') { + pos++; + return null; + } + String key = readString(); + expect(':'); + return key; + } + + /** + * Well-known response wire keys, kept as shared (interned string-literal) + * instances so the per-response header / metadata / validation maps reuse + * one canonical key String instead of allocating a fresh one each call — + * the allocation Jackson's symbol table used to elide. Plain ASCII by + * construction (HTTP field names + the fixed metadata / validation keys). + */ + /** + * If the upcoming quoted member key is a plain-ASCII canonical-key entry, + * consume it (key + closing quote) and return the shared instance; + * otherwise leave {@code pos} untouched and return {@code null} so the + * caller falls back to {@link #readString()} — escaped / non-ASCII / + * unknown keys still allocate exactly as before. + */ + private String peekCanonicalKey() { + if (cur() != '"') { + return null; + } + int p = pos + 1; + int start = p; + while (p < end) { + int b = byteAt(p); + if (b == '"') { + break; + } + if (b == '\\' || b >= 0x80) { + return null; + } + p++; + } + if (p >= end) { + return null; + } + String canon = canonicalKey(start, p - start); + if (canon != null) { + pos = p + 1; + return canon; + } + return null; + } + + /** + * {@link #nextKey()} that returns a shared canonical key for the common + * wire keys (allocation-free) and falls back to {@link #readString()} for + * the rest — used by {@link #decode} for the header / metadata / + * validation member keys. + */ + String nextKeyCanonical() { + skipWs(); + int c = cur(); + if (c == ',') { + pos++; + skipWs(); + c = cur(); + } + if (c == '}') { + pos++; + return null; + } + String canon = peekCanonicalKey(); + String key = (canon != null) ? canon : readString(); + expect(':'); + return key; + } + + // Root-member-key codes for the allocation-free root-key matcher used + // by apply(): the only root keys the reader acts on are "status" and + // "headers"; every other key ("v", "metadata", "validation_errors", …) + // is matched by length+bytes and its value skipped — never materialised + // as a String. + private static final int KEY_END = -2; + private static final int KEY_OTHER = -1; + private static final int KEY_STATUS = 0; + private static final int KEY_HEADERS = 1; + // Recognised additionally by the full decode() path (apply() skips these + // as KEY_OTHER); matched allocation-free by length + bytes like the rest. + private static final int KEY_METADATA = 2; + private static final int KEY_VALIDATION = 3; + + /** + * Advance past the next root member key WITHOUT allocating a String for + * it, returning a {@code KEY_*} code ({@code KEY_END} at object end). + * The allocation-free counterpart of {@link #nextKey()} for the fixed + * root schema; header keys (delivered to the sink) still use + * {@link #nextKey()}. + */ + int nextRootKey() { + skipWs(); + int c = cur(); + if (c == ',') { + pos++; + skipWs(); + c = cur(); + } + if (c == '}') { + pos++; + return KEY_END; + } + int code = matchRootKey(); + expect(':'); + return code; + } + + /** + * Consume a quoted root key, returning {@code KEY_STATUS} / + * {@code KEY_HEADERS} when its bytes equal those literals, else + * {@code KEY_OTHER} — all without allocating. An escaped key (never + * emitted for the fixed root field names) is consumed and reported as + * {@code KEY_OTHER}. + */ + private int matchRootKey() { + skipWs(); + if (cur() != '"') { + throw err("expected string"); + } + pos++; + int start = pos; + boolean simple = true; + while (pos < end) { + int b = byteAt(pos); + if (b == '"') { + break; + } + if (b == '\\') { + simple = false; + pos++; + if (pos < end) { + pos++; + } + continue; + } + pos++; + } + if (pos >= end) { + throw err("unterminated string"); + } + int contentLen = pos - start; + pos++; // consume closing quote + if (!simple) { + return KEY_OTHER; + } + if (contentLen == 6 && regionEquals(start, "status")) { + return KEY_STATUS; + } + if (contentLen == 7 && regionEquals(start, "headers")) { + return KEY_HEADERS; + } + if (contentLen == 8 && regionEquals(start, "metadata")) { + return KEY_METADATA; + } + if (contentLen == 17 && regionEquals(start, "validation_errors")) { + return KEY_VALIDATION; + } + return KEY_OTHER; + } + + private boolean regionEquals(int s, String lit) { + return array != null + ? WireHeaderStringSupport.regionEquals(array, s, lit) + : WireHeaderStringSupport.regionEquals(buf, s, lit); + } + + private String canonicalKey(int start, int len) { + return array != null + ? WireHeaderStringSupport.canonicalKey(array, start, len) + : WireHeaderStringSupport.canonicalKey(buf, start, len); + } + + void beginArray() { + expect('['); + } + + boolean hasNextElement() { + skipWs(); + int c = cur(); + if (c == ',') { + pos++; + skipWs(); + c = cur(); + } + if (c == ']') { + pos++; + return false; + } + return true; + } + + boolean isObjectStart() { + return peek() == '{'; + } + + boolean isArrayStart() { + return peek() == '['; + } + + String readString() { + skipWs(); + if (cur() != '"') { + throw err("expected string"); + } + pos++; + // Fast path: a plain run of ASCII bytes (no escape, no byte + // >= 0x80) — the overwhelmingly common shape for header names / + // values — is built in one bulk copy + String construction, + // skipping both the StringBuilder and the per-char escape / UTF-8 + // decode loop below. + int simpleLen = simpleAsciiRun(); + if (simpleLen >= 0) { + // `readAsciiString` already branches on `buf.hasArray()` itself: + // heap-backed buffers (SYNC / streaming / async, ByteBuffer.wrap) + // build the String straight from the backing array (one copy, no + // intermediate byte[]), while direct buffers (the DIRECT dispatch + // path) fall back to a pooled-scratch bulk-get — so this single + // call is already optimal for both buffer kinds. The previous outer + // `if (buf.hasArray()) ... else ...` invoked the identical call in + // both arms (dead branch); collapsed here. + String s = readAsciiString(pos, simpleLen); + pos += simpleLen + 1; // consume the run + the closing quote + return s; + } + StringBuilder sb = new StringBuilder(Math.min(end - pos, 256)); + while (pos < end) { + int b = byteAt(pos++); + if (b == '"') { + return sb.toString(); + } + if (b == '\\') { + if (pos >= end) { + throw err("dangling escape"); + } + int e = byteAt(pos++); + switch (e) { + case '"' -> sb.append('"'); + case '\\' -> sb.append('\\'); + case '/' -> sb.append('/'); + case 'b' -> sb.append('\b'); + case 'f' -> sb.append('\f'); + case 'n' -> sb.append('\n'); + case 'r' -> sb.append('\r'); + case 't' -> sb.append('\t'); + case 'u' -> appendUnicodeEscape(sb); + default -> throw err("bad escape"); + } + } else if (b < 0x80) { + sb.append((char) b); + } else if (b < 0xE0) { + if (b < 0xC2) { + throw err("bad UTF-8"); + } + sb.append((char) (((b & 0x1F) << 6) | nextCont())); + } else if (b < 0xF0) { + int c1 = nextContByte(); + if ((b == 0xE0 && c1 < 0xA0) || (b == 0xED && c1 >= 0xA0)) { + throw err("bad UTF-8"); + } + sb.append((char) (((b & 0x0F) << 12) | ((c1 & 0x3F) << 6) | nextCont())); + } else if (b < 0xF5) { + int c1 = nextContByte(); + if ((b == 0xF0 && c1 < 0x90) || (b == 0xF4 && c1 > 0x8F)) { + throw err("bad UTF-8"); + } + int cp = ((b & 0x07) << 18) | ((c1 & 0x3F) << 12) | (nextCont() << 6) | nextCont(); + sb.appendCodePoint(cp); + } else { + throw err("bad UTF-8"); + } + } + throw err("unterminated string"); + } + + /** + * Read the primitive JSON values allowed inside validation error maps. + * Strings keep the established shape; numbers, booleans, and null are + * accepted so future Rust-side hoisted fields do not make Java decoding + * fail. Containers are still outside this fixed schema and are skipped. + */ + Object readPrimitiveValue() { + int c = peek(); + return switch (c) { + case '"' -> readString(); + case 't' -> { + consumeLiteral("true"); + yield Boolean.TRUE; + } + case 'f' -> { + consumeLiteral("false"); + yield Boolean.FALSE; + } + case 'n' -> { + consumeLiteral("null"); + yield null; + } + case '{', '[' -> { + skipContainerRaw(); + yield null; + } + default -> { + if (c == '-' || (c >= '0' && c <= '9')) { + yield readNumberValue(); + } + throw err("unexpected primitive value"); + } + }; + } + + private Object readNumberValue() { + skipWs(); + int start = pos; + if (cur() == '-') { + pos++; + } + boolean anyDigit = readDigits(); + boolean floating = false; + if (cur() == '.') { + floating = true; + pos++; + if (!readDigits()) { + throw err("expected digit after decimal point"); + } + } + int c = cur(); + if (c == 'e' || c == 'E') { + floating = true; + pos++; + c = cur(); + if (c == '+' || c == '-') { + pos++; + } + if (!readDigits()) { + throw err("expected digit in exponent"); + } + } + if (!anyDigit) { + pos = start; + throw err("expected number"); + } + String token = asciiToken(start, pos - start); + try { + if (floating) { + return Double.valueOf(token); + } + return Long.valueOf(token); + } catch (NumberFormatException overflowOrNan) { + return Double.valueOf(token); + } + } + + private boolean readDigits() { + boolean any = false; + while (pos < end) { + int d = byteAt(pos); + if (d < '0' || d > '9') { + break; + } + pos++; + any = true; + } + return any; + } + + private String asciiToken(int start, int len) { + return readAsciiString(start, len); + } + + private String readAsciiString(int start, int len) { + return array != null + ? WireHeaderStringSupport.readAsciiString(array, start, len) + : WireHeaderStringSupport.readAsciiString(buf, start, len); + } + + /** + * If the string starting at {@code pos} (just past the opening quote) + * is a plain run of ASCII bytes — no backslash escape, no byte + * {@code >= 0x80} — terminated by a closing quote within bounds, + * return its byte length; otherwise {@code -1}, so the caller falls + * back to the full escape / UTF-8 decoder. Does not move {@code pos}. + */ + private int simpleAsciiRun() { + int p = pos; + while (p < end) { + int b = byteAt(p); + if (b == '"') { + return p - pos; + } + if (b == '\\' || b >= 0x80) { + return -1; + } + p++; + } + return -1; + } + + private int nextCont() { + return nextContByte() & 0x3F; + } + + private int nextContByte() { + if (pos >= end) { + throw err("truncated UTF-8"); + } + int b = byteAt(pos++); + if ((b & 0xC0) != 0x80) { + throw err("bad UTF-8 continuation"); + } + return b; + } + + private char readHex4() { + if (pos + 4 > end) { + throw err("truncated unicode escape"); + } + int v = 0; + for (int k = 0; k < 4; k++) { + int d = byteAt(pos++); + int h; + if (d >= '0' && d <= '9') { + h = d - '0'; + } else if (d >= 'a' && d <= 'f') { + h = d - 'a' + 10; + } else if (d >= 'A' && d <= 'F') { + h = d - 'A' + 10; + } else { + throw err("bad hex digit"); + } + v = (v << 4) | h; + } + return (char) v; + } + + private void appendUnicodeEscape(StringBuilder sb) { + char c = readHex4(); + if (Character.isHighSurrogate(c)) { + if (pos + 6 > end || byteAt(pos) != '\\' || byteAt(pos + 1) != 'u') { + throw err("unpaired unicode surrogate"); + } + pos += 2; + char low = readHex4(); + if (!Character.isLowSurrogate(low)) { + throw err("unpaired unicode surrogate"); + } + sb.appendCodePoint(Character.toCodePoint(c, low)); + return; + } + if (Character.isLowSurrogate(c)) { + throw err("unpaired unicode surrogate"); + } + sb.append(c); + } + + int readStatusCode() { + skipWs(); + int start = pos; + boolean neg = cur() == '-'; + if (neg) { + pos++; + } + boolean any = false; + long v = 0; + long limit = neg ? 2147483648L : Integer.MAX_VALUE; + while (pos < end) { + int d = byteAt(pos); + if (d < '0' || d > '9') { + break; + } + v = v * 10 + (d - '0'); + if (v > limit) { + throw err("integer overflow"); + } + pos++; + any = true; + } + if (pos < end) { + int c = cur(); + if (c == '.' || c == 'e' || c == 'E') { + // `status` is a protocol INTEGER field; a fraction/exponent + // (e.g. `200.9`, `2e2`) is malformed native output, NOT a + // value to silently truncate to its integer part. Unknown + // numeric fields stay permissive via `skipNumberRaw`. + throw err("status must be an integer (no fraction or exponent)"); + } + } + if (!any) { + pos = start; + throw err("expected number"); + } + int status = (int) (neg ? -v : v); + if (status < 100 || status > 999) { + throw err("status out of range"); + } + return status; + } + + private void skipNumberTail() { + while (pos < end) { + int d = byteAt(pos); + if ((d >= '0' && d <= '9') || d == '.' || d == 'e' || d == 'E' || d == '+' || d == '-') { + pos++; + } else { + break; + } + } + } + + /** + * Consume a JSON number token (sign, integer digits, optional fraction + * and exponent) WITHOUT parsing it to an {@code int}. The skip path + * discards unknown-field values, so an unknown numeric that is large + * (beyond {@code int} range) or a decimal must NOT fail decode the way + * {@link #readStatusCode} — used for the known, overflow-checked {@code status} + * field — would. Forward-compatibility for newer / custom wire headers. + */ + private void skipNumberRaw() { + skipWs(); + if (cur() == '-') { + pos++; + } + int digitsStart = pos; + skipNumberTail(); + if (pos == digitsStart) { + throw err("expected number"); + } + } + + void skipValue() { + int c = peek(); + switch (c) { + case '"' -> skipStringRaw(); + case '{', '[' -> skipContainerRaw(); + case 't', 'f', 'n' -> skipLiteral(); + default -> { + if (c == '-' || (c >= '0' && c <= '9')) { + skipNumberRaw(); + } else { + throw err("unexpected value"); + } + } + } + } + + /** + * Consume a JSON string token (pos at the opening quote) without + * allocating — the skip path never needs the decoded text, so unlike + * {@link #readString()} it builds no {@code String}. + */ + private void skipStringRaw() { + pos++; // opening quote (peek() guarantees cur() == '"') + while (pos < end) { + int b = byteAt(pos++); + if (b == '"') { + return; + } + if (b == '\\' && pos < end) { + pos++; // skip the escaped char (so \" is not seen as the close) + } + } + throw err("unterminated string"); + } + + /** + * Consume a balanced {@code {...}} / {@code [...]} (pos at the opening + * bracket), string-literal aware, without allocating — replaces the + * prior recursive skip that materialised every nested key and value of + * skipped fields ({@code metadata}, {@code validation_errors}, …). + */ + private void skipContainerRaw() { + int depth = 0; + while (pos < end) { + int b = byteAt(pos++); + switch (b) { + case '"' -> { + // Skip a nested string so its braces/brackets don't count. + while (pos < end) { + int x = byteAt(pos++); + if (x == '"') { + break; + } + if (x == '\\' && pos < end) { + pos++; + } + } + } + case '{', '[' -> depth++; + case '}', ']' -> { + depth--; + if (depth == 0) { + return; + } + } + default -> { + // ordinary byte inside the container — skip + } + } + } + throw err("unterminated container"); + } + + private void skipLiteral() { + int c = cur(); + if (c == 't') { + consumeLiteral("true"); + } else if (c == 'f') { + consumeLiteral("false"); + } else if (c == 'n') { + consumeLiteral("null"); + } else { + throw err("expected literal"); + } + } + + private void consumeLiteral(String literal) { + for (int i = 0; i < literal.length(); i++) { + if (pos + i >= end || byteAt(pos + i) != literal.charAt(i)) { + throw err("expected " + literal); + } + } + pos += literal.length(); + } +} diff --git a/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/WireHeaderStringSupport.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/WireHeaderStringSupport.java new file mode 100644 index 00000000..8bf4dda6 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/WireHeaderStringSupport.java @@ -0,0 +1,134 @@ +package com.devfive.vespera.bridge; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +/** Shared string/canonical-key helpers for {@link WireHeaderReader}. */ +final class WireHeaderStringSupport { + + private static final int DIRECT_STRING_SCRATCH_INITIAL = 256; + private static final int DIRECT_STRING_SCRATCH_MAX = 8 * 1024; + private static final ThreadLocal DIRECT_STRING_SCRATCH = + ThreadLocal.withInitial(() -> new byte[DIRECT_STRING_SCRATCH_INITIAL]); + + private WireHeaderStringSupport() {} + + static void clearCurrentThreadBuffers() { + DIRECT_STRING_SCRATCH.remove(); + } + + static String readAsciiString(ByteBuffer buf, int start, int len) { + if (buf.hasArray()) { + return new String( + buf.array(), + buf.arrayOffset() + start, + len, + StandardCharsets.US_ASCII); + } + if (len <= DIRECT_STRING_SCRATCH_MAX) { + byte[] scratch = directStringScratch(len); + buf.get(start, scratch, 0, len); + return new String(scratch, 0, len, StandardCharsets.US_ASCII); + } + byte[] tmp = new byte[len]; + buf.get(start, tmp, 0, len); + return new String(tmp, StandardCharsets.US_ASCII); + } + + static String readAsciiString(byte[] buf, int start, int len) { + return new String(buf, start, len, StandardCharsets.US_ASCII); + } + + static String canonicalKey(ByteBuffer buf, int start, int len) { + return switch (len) { + case 4 -> canonicalKeyLen4(buf, start); + case 7 -> canonicalKeyLen7(buf, start); + case 8 -> regionEquals(buf, start, "location") ? "location" : null; + case 10 -> regionEquals(buf, start, "set-cookie") ? "set-cookie" : null; + case 12 -> regionEquals(buf, start, "content-type") ? "content-type" : null; + case 13 -> regionEquals(buf, start, "cache-control") ? "cache-control" : null; + case 14 -> regionEquals(buf, start, "content-length") ? "content-length" : null; + case 16 -> regionEquals(buf, start, "content-encoding") ? "content-encoding" : null; + case 19 -> regionEquals(buf, start, "content-disposition") ? "content-disposition" : null; + case 27 -> regionEquals(buf, start, "access-control-allow-origin") + ? "access-control-allow-origin" : null; + default -> null; + }; + } + + static String canonicalKey(byte[] buf, int start, int len) { + return switch (len) { + case 4 -> canonicalKeyLen4(buf, start); + case 7 -> canonicalKeyLen7(buf, start); + case 8 -> regionEquals(buf, start, "location") ? "location" : null; + case 10 -> regionEquals(buf, start, "set-cookie") ? "set-cookie" : null; + case 12 -> regionEquals(buf, start, "content-type") ? "content-type" : null; + case 13 -> regionEquals(buf, start, "cache-control") ? "cache-control" : null; + case 14 -> regionEquals(buf, start, "content-length") ? "content-length" : null; + case 16 -> regionEquals(buf, start, "content-encoding") ? "content-encoding" : null; + case 19 -> regionEquals(buf, start, "content-disposition") ? "content-disposition" : null; + case 27 -> regionEquals(buf, start, "access-control-allow-origin") + ? "access-control-allow-origin" : null; + default -> null; + }; + } + + private static String canonicalKeyLen4(ByteBuffer buf, int start) { + if (regionEquals(buf, start, "etag")) return "etag"; + if (regionEquals(buf, start, "date")) return "date"; + if (regionEquals(buf, start, "vary")) return "vary"; + if (regionEquals(buf, start, "path")) return "path"; + if (regionEquals(buf, start, "code")) return "code"; + return null; + } + + private static String canonicalKeyLen4(byte[] buf, int start) { + if (regionEquals(buf, start, "etag")) return "etag"; + if (regionEquals(buf, start, "date")) return "date"; + if (regionEquals(buf, start, "vary")) return "vary"; + if (regionEquals(buf, start, "path")) return "path"; + if (regionEquals(buf, start, "code")) return "code"; + return null; + } + + private static String canonicalKeyLen7(ByteBuffer buf, int start) { + if (regionEquals(buf, start, "version")) return "version"; + if (regionEquals(buf, start, "message")) return "message"; + return null; + } + + private static String canonicalKeyLen7(byte[] buf, int start) { + if (regionEquals(buf, start, "version")) return "version"; + if (regionEquals(buf, start, "message")) return "message"; + return null; + } + + static boolean regionEquals(ByteBuffer buf, int start, String literal) { + for (int i = 0; i < literal.length(); i++) { + if ((buf.get(start + i) & 0xFF) != literal.charAt(i)) { + return false; + } + } + return true; + } + + static boolean regionEquals(byte[] buf, int start, String literal) { + for (int i = 0; i < literal.length(); i++) { + if ((buf[start + i] & 0xFF) != literal.charAt(i)) { + return false; + } + } + return true; + } + + private static byte[] directStringScratch(int required) { + byte[] scratch = DIRECT_STRING_SCRATCH.get(); + if (scratch.length < required) { + scratch = new byte[Math.min( + DIRECT_STRING_SCRATCH_MAX, + Math.max(required, scratch.length * 2))]; + DIRECT_STRING_SCRATCH.set(scratch); + } + return scratch; + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolverTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolverTest.java new file mode 100644 index 00000000..f6e039a0 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/BidirectionalStreamingDispatchModeResolverTest.java @@ -0,0 +1,54 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * Gating tests for the default resolver's bodyless fast path: + * provably bodyless requests skip the bidirectional request-pull + * plumbing (response-only STREAMING, ~3x cheaper); anything that may + * carry a body keeps full bidirectional streaming. + */ +class BidirectionalStreamingDispatchModeResolverTest { + + private final BidirectionalStreamingDispatchModeResolver resolver = + new BidirectionalStreamingDispatchModeResolver(); + + @Test + void bodylessGetHeadOptionsUseResponseOnlyStreaming() { + for (String method : new String[] {"GET", "HEAD", "OPTIONS"}) { + MockHttpServletRequest req = new MockHttpServletRequest(method, "/x"); + assertEquals(DispatchMode.STREAMING, resolver.resolveMode(req), method); + } + } + + @Test + void explicitZeroContentLengthUsesResponseOnlyStreamingForAnyMethod() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + req.setContent(new byte[0]); // Content-Length: 0 — provably empty. + assertEquals(DispatchMode.STREAMING, resolver.resolveMode(req)); + } + + @Test + void requestWithBodyKeepsBidirectionalStreaming() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + req.setContent(new byte[64]); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, resolver.resolveMode(req)); + } + + @Test + void lengthlessPostKeepsBidirectionalStreaming() { + // No Content-Length on a method that may carry a body. + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, resolver.resolveMode(req)); + } + + @Test + void chunkedGetKeepsBidirectionalStreaming() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("Transfer-Encoding", "chunked"); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, resolver.resolveMode(req)); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ConfigureStreamingTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ConfigureStreamingTest.java new file mode 100644 index 00000000..2c2dfaf6 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ConfigureStreamingTest.java @@ -0,0 +1,128 @@ +package com.devfive.vespera.bridge; + +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConfigureStreamingTest { + + @Test + void preInitConfigurationStoresPending() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(65536, 16)); + } + + @Test + void validChunkBytesAndCapacity() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(65536, 16)); + } + + @Test + void chunkBytesMinBoundary() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(4096, 16)); + } + + @Test + void chunkBytesMaxBoundary() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(8388608, 16)); + } + + @Test + void chunkBytesBelowMinThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(4095, 16)); + assertTrue(ex.getMessage().contains("4095")); + assertTrue(ex.getMessage().contains("[4096, 8388608]")); + } + + @Test + void chunkBytesAboveMaxThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(8388609, 16)); + assertTrue(ex.getMessage().contains("8388609")); + assertTrue(ex.getMessage().contains("[4096, 8388608]")); + } + + @Test + void chunkBytesZeroThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(0, 16)); + assertTrue(ex.getMessage().contains("0")); + } + + @Test + void chunkBytesNegativeThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(-1, 16)); + assertTrue(ex.getMessage().contains("-1")); + } + + @Test + void capacityMinBoundary() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(65536, 1)); + } + + @Test + void capacityMaxBoundary() { + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(65536, 1024)); + } + + @Test + void capacityBelowMinThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(65536, 0)); + assertTrue(ex.getMessage().contains("0")); + assertTrue(ex.getMessage().contains("[1, 1024]")); + } + + @Test + void capacityAboveMaxThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(65536, 1025)); + assertTrue(ex.getMessage().contains("1025")); + assertTrue(ex.getMessage().contains("[1, 1024]")); + } + + @Test + void capacityNegativeThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(65536, -1)); + assertTrue(ex.getMessage().contains("-1")); + } + + @Test + void bothParametersOutOfRangeThrowsForChunkBytes() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.configureStreaming(0, 0)); + assertTrue(ex.getMessage().contains("chunkBytes")); + } + + @Test + void postInitMissingOptionalNativeHookDoesNotThrowRawLinkageError() throws Exception { + Field loadedField = VesperaBridge.class.getDeclaredField("loaded"); + Field nameField = VesperaBridge.class.getDeclaredField("loadedLibraryName"); + loadedField.setAccessible(true); + nameField.setAccessible(true); + boolean prevLoaded = loadedField.getBoolean(null); + Object prevName = nameField.get(null); + try { + loadedField.setBoolean(null, true); + nameField.set(null, "older-native-without-configure-streaming"); + + assertDoesNotThrow(() -> VesperaBridge.configureStreaming(65536, 16)); + } finally { + loadedField.setBoolean(null, prevLoaded); + nameField.set(null, prevName); + } + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/DirectOverflowMemoryTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/DirectOverflowMemoryTest.java new file mode 100644 index 00000000..4f87ae61 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/DirectOverflowMemoryTest.java @@ -0,0 +1,64 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * C-1 adaptive DIRECT-overflow avoidance: a route that overflowed the pooled + * direct buffer once is streamed up front thereafter, so a known-large + * (download) route dispatches ONCE instead of paying the + * DIRECT-overflow-then-stream double dispatch on every request. + */ +class DirectOverflowMemoryTest { + + @Test + void emptyMemoryNeverAvoidsDirect() { + // The hot-path guard: until something overflows, shouldAvoidDirect is a + // single volatile read that returns false — non-overflowing apps pay + // nothing per DIRECT request. + DirectOverflowMemory mem = new DirectOverflowMemory(); + assertFalse(mem.shouldAvoidDirect("GET", "/anything")); + assertEquals(0, mem.size()); + } + + @Test + void recordedRouteAvoidsDirectExactlyForThatMethodAndPath() { + DirectOverflowMemory mem = new DirectOverflowMemory(); + mem.recordOverflow("GET", "/big"); + + assertTrue(mem.shouldAvoidDirect("GET", "/big")); + // A distinct path or method must NOT be downgraded. + assertFalse(mem.shouldAvoidDirect("GET", "/small")); + assertFalse(mem.shouldAvoidDirect("POST", "/big")); + assertEquals(1, mem.size()); + } + + @Test + void queryStringDoesNotBustOverflowMemoryKey() { + DirectOverflowMemory mem = new DirectOverflowMemory(); + mem.recordOverflow(null, "GET", "/big", "cacheBust=1"); + + assertTrue(mem.shouldAvoidDirect(null, "GET", "/big", "cacheBust=2")); + assertFalse(mem.shouldAvoidDirect("admin", "GET", "/big", "cacheBust=2")); + } + + @Test + void reachingTheCapClearsWholesaleThenKeepsLearning() { + DirectOverflowMemory mem = new DirectOverflowMemory(2); + mem.recordOverflow("GET", "/a"); + mem.recordOverflow("GET", "/b"); + assertEquals(2, mem.size()); + assertTrue(mem.shouldAvoidDirect("GET", "/a")); + + // Third insert hits the cap (size >= 2) → wholesale clear, then add. + mem.recordOverflow("GET", "/c"); + assertEquals(1, mem.size()); + assertTrue(mem.shouldAvoidDirect("GET", "/c")); + // The cleared entries are forgotten (re-learn on their next overflow). + assertFalse(mem.shouldAvoidDirect("GET", "/a")); + assertFalse(mem.shouldAvoidDirect("GET", "/b")); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/EncodeRequestIntoTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/EncodeRequestIntoTest.java new file mode 100644 index 00000000..5868aba3 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/EncodeRequestIntoTest.java @@ -0,0 +1,157 @@ +package com.devfive.vespera.bridge; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure-Java wire-equivalence tests: {@link VesperaBridge#encodeRequestInto} + * must produce byte-identical output to {@link VesperaBridge#encodeRequest} + * for the same inputs. No native library required. + */ +class EncodeRequestIntoTest { + + private static byte[] drain(ByteBuffer target, int len) { + byte[] out = new byte[len]; + target.get(0, out); + return out; + } + + private static void assertEquivalent( + String appName, String method, String path, String query, + Map headers, byte[] body) { + byte[] expected = VesperaBridge.encodeRequest( + appName, method, path, query, headers, body); + + ByteBuffer target = ByteBuffer.allocateDirect(expected.length + 16); + int written = VesperaBridge.encodeRequestInto( + appName, method, path, query, headers, body, target); + + assertEquals(expected.length, written, "written length"); + assertArrayEquals(expected, drain(target, written), + "encodeRequestInto must be byte-identical to encodeRequest"); + } + + private static VesperaBridge.HeaderSource sourceFrom(Map headers) { + return sink -> headers.forEach(sink::put); + } + + private static void assertHeaderSourceEquivalent( + String appName, String method, String path, String query, + Map headers, byte[] body) { + byte[] expected = VesperaBridge.encodeRequest( + appName, method, path, query, headers, body); + byte[] actual = VesperaBridge.encodeRequest( + appName, method, path, query, sourceFrom(headers), body); + assertArrayEquals(expected, actual, + "HeaderSource encodeRequest must be byte-identical to Map encodeRequest"); + } + + @Test + void typicalPostWithBodyAndHeaders() { + assertEquivalent(null, "POST", "/echo", "a=1&b=2", + Map.of("content-type", "application/json"), + "{\"k\":42}".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void multiAppGetWithoutBody() { + assertEquivalent("admin", "GET", "/dashboard", null, Map.of(), null); + } + + @Test + void emptyBodyAndNullQuery() { + assertEquivalent(null, "DELETE", "/items/9", null, + Map.of("x-custom", "v"), new byte[0]); + } + + @Test + void binaryBodySurvivesVerbatim() { + byte[] binary = new byte[257]; + for (int i = 0; i < binary.length; i++) { + binary[i] = (byte) i; + } + assertEquivalent(null, "POST", "/upload", null, + Map.of("content-type", "application/octet-stream"), binary); + } + + @Test + void tooSmallTargetReturnsNegativeRequiredAndWritesNothing() { + byte[] body = "payload".getBytes(StandardCharsets.UTF_8); + byte[] expected = VesperaBridge.encodeRequest(null, "POST", "/x", null, Map.of(), body); + + ByteBuffer tiny = ByteBuffer.allocateDirect(8); + tiny.put(0, (byte) 0x7F); // sentinel byte to prove nothing was written + int rc = VesperaBridge.encodeRequestInto(null, "POST", "/x", null, Map.of(), body, tiny); + + assertEquals(-expected.length, rc, "must report exact required size, negated"); + assertEquals((byte) 0x7F, tiny.get(0), "target must be untouched on failure"); + } + + @Test + void heapTargetAlsoSupported() { + // encodeRequestInto is buffer-kind-agnostic (only the JNI + // dispatch requires direct buffers). + byte[] expected = VesperaBridge.encodeRequest(null, "GET", "/h", null, Map.of(), null); + ByteBuffer heap = ByteBuffer.allocate(expected.length); + int written = VesperaBridge.encodeRequestInto(null, "GET", "/h", null, Map.of(), null, heap); + assertEquals(expected.length, written); + assertTrue(heap.hasArray()); + byte[] out = new byte[written]; + heap.get(0, out); + assertArrayEquals(expected, out); + } + + @Test + void headerSourceEmptyHeadersByteIdentical() { + assertHeaderSourceEquivalent(null, "GET", "/empty", null, Map.of(), null); + } + + @Test + void headerSourceOneHeaderByteIdentical() { + assertHeaderSourceEquivalent(null, "GET", "/one", null, + Map.of("accept", "application/json"), null); + } + + @Test + void headerSourceSeveralHeadersByteIdentical() { + Map headers = new LinkedHashMap<>(); + headers.put("host", "example.test"); + headers.put("content-type", "application/json"); + headers.put("x-custom-trace", "01HV2N3M4P5Q6R7S8T9V0W1X2Y"); + headers.put("accept-encoding", "gzip, br"); + assertHeaderSourceEquivalent(null, "POST", "/several", null, + headers, "{}".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void headerSourceSpecialHeaderValuesByteIdentical() { + Map headers = new LinkedHashMap<>(); + headers.put("x-quote", "a\"b\\c"); + headers.put("x-control", "line\n tab\t nul\u0000 end"); + headers.put("x-utf8", "안녕 🌙"); + assertHeaderSourceEquivalent(null, "GET", "/special", null, headers, null); + } + + @Test + void headerSourceAppNameAndQueryByteIdentical() { + Map headers = new LinkedHashMap<>(); + headers.put("accept", "application/json"); + headers.put("x-app", "admin"); + assertHeaderSourceEquivalent(" admin ", "GET", "/dashboard", "q=rust&sort=desc", + headers, null); + } + + @Test + void headerSourceNoAppNameWithQueryByteIdentical() { + assertHeaderSourceEquivalent(null, "GET", "/search", "q=vespera", + Map.of("accept", "application/json"), null); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/HeaderAppNameResolverTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/HeaderAppNameResolverTest.java new file mode 100644 index 00000000..164e7ae1 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/HeaderAppNameResolverTest.java @@ -0,0 +1,35 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +class HeaderAppNameResolverTest { + + private final HeaderAppNameResolver resolver = new HeaderAppNameResolver("X-Vespera-App"); + + @Test + void missingOrBlankHeaderReturnsNull() { + assertNull(resolver.resolveAppName(new MockHttpServletRequest("GET", "/x"))); + + MockHttpServletRequest blank = new MockHttpServletRequest("GET", "/x"); + blank.addHeader("X-Vespera-App", " \t "); + assertNull(resolver.resolveAppName(blank)); + } + + @Test + void nonBlankHeaderIsTrimmed() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("X-Vespera-App", " admin "); + assertEquals("admin", resolver.resolveAppName(req)); + } + + @Test + void unicodeWhitespaceIsStripped() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("X-Vespera-App", "\u2003admin\u2003"); + assertEquals("admin", resolver.resolveAppName(req)); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/JsonEncodingSurrogateTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/JsonEncodingSurrogateTest.java new file mode 100644 index 00000000..b41c4c65 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/JsonEncodingSurrogateTest.java @@ -0,0 +1,67 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * B3: the manual JSON encoder ({@code VesperaBridge.writeJsonString}, exercised + * here through {@link VesperaBridge#encodeRequest}) must escape unpaired + * UTF-16 surrogates as a {@code \\uXXXX} escape instead of emitting an invalid + * 3-byte UTF-8 sequence — otherwise the wire header is not valid UTF-8 / RFC 8259 + * JSON and the Rust {@code serde_json} side rejects it. No native library needed. + */ +class JsonEncodingSurrogateTest { + + /** Extract the JSON header region and assert it is strictly valid UTF-8. */ + private static String headerJson(byte[] wire) { + int len = ((wire[0] & 0xFF) << 24) + | ((wire[1] & 0xFF) << 16) + | ((wire[2] & 0xFF) << 8) + | (wire[3] & 0xFF); + var decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + assertDoesNotThrow( + () -> decoder.decode(ByteBuffer.wrap(wire, 4, len)), + "wire header must be valid UTF-8"); + return new String(wire, 4, len, StandardCharsets.UTF_8); + } + + @Test + void unpairedHighSurrogateInHeaderValueIsEscaped() { + byte[] wire = VesperaBridge.encodeRequest( + null, "GET", "/x", null, Map.of("x-test", "\uD800"), null); + String json = headerJson(wire); + assertTrue( + json.toLowerCase().contains("\\ud800"), + "lone high surrogate must be emitted as a \\u escape, got: " + json); + } + + @Test + void loneLowSurrogateInPathIsEscaped() { + byte[] wire = VesperaBridge.encodeRequest( + null, "GET", "/p\uDC00", null, Map.of(), null); + String json = headerJson(wire); + assertTrue( + json.toLowerCase().contains("\\udc00"), + "lone low surrogate must be emitted as a \\u escape, got: " + json); + } + + @Test + void validSurrogatePairStillBecomesFourByteUtf8() { + // U+1F600 GRINNING FACE = high \uD83D + low \uDE00 — must stay the real + // 4-byte UTF-8 character (NOT escaped), unchanged by the B3 fix. + byte[] wire = VesperaBridge.encodeRequest( + null, "GET", "/x", null, Map.of("x-emoji", "\uD83D\uDE00"), null); + String json = headerJson(wire); + assertTrue( + json.contains("\uD83D\uDE00"), + "valid surrogate pair must round-trip as the actual character"); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/PerfAllocBench.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/PerfAllocBench.java new file mode 100644 index 00000000..a2f10946 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/PerfAllocBench.java @@ -0,0 +1,478 @@ +package com.devfive.vespera.bridge; + +import com.sun.management.ThreadMXBean; +import java.lang.management.ManagementFactory; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.IntConsumer; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * Allocation microbenchmark for the per-request controller / wire-reader hot + * paths optimized by P3 (WireHeaderReader.apply canonical header-key reuse) and + * P1 (readBody bodyless skip). Uses the same + * {@code ThreadMXBean.getThreadAllocatedBytes} idiom as the demo-app + * {@code AllocationBenchTest} — allocation-per-op is deterministic (unlike + * timing), so it is the noise-free signal for these allocation-reduction wins. + * + *

    Opt-in (like the demo-app benches): run with + * {@code ./gradlew test --tests "*PerfAllocBench*" -Dvespera.bench=true}. + * Compare the printed {@code VESPERA_ALLOC} lines before vs after. + */ +@EnabledIfSystemProperty(named = "vespera.bench", matches = "true") +class PerfAllocBench { + + private static final int WARMUP = 5_000; + private static final int MEASURE = 100_000; + + /** Realistic response wire header: 5 canonical keys + 1 non-canonical. */ + private static final byte[] RESP_WIRE = buildRespWire(); + + private static byte[] buildRespWire() { + String json = + "{\"v\":1,\"status\":200,\"headers\":{" + + "\"content-type\":\"application/json\"," + + "\"content-length\":\"256\"," + + "\"cache-control\":\"no-store\"," + + "\"etag\":\"\\\"abc123\\\"\"," + + "\"vary\":\"accept-encoding\"," + + "\"x-request-id\":\"01HV2N3M4P5Q6R7S8T9V0W1X2Y\"" + + "},\"metadata\":{\"version\":\"0.1.0\"}}"; + byte[] hb = json.getBytes(StandardCharsets.UTF_8); + ByteBuffer buf = ByteBuffer.allocate(4 + hb.length); + buf.putInt(hb.length); + buf.put(hb); + return buf.array(); + } + + private static ThreadMXBean threadMx() { + java.lang.management.ThreadMXBean base = ManagementFactory.getThreadMXBean(); + Assumptions.assumeTrue( + base instanceof ThreadMXBean, + "non-HotSpot JVM — no com.sun.management.ThreadMXBean"); + ThreadMXBean tmx = (ThreadMXBean) base; + Assumptions.assumeTrue( + tmx.isThreadAllocatedMemorySupported(), "thread allocation not supported"); + if (!tmx.isThreadAllocatedMemoryEnabled()) { + tmx.setThreadAllocatedMemoryEnabled(true); + } + return tmx; + } + + @Test + void p3_apply_bytesPerOp() { + ThreadMXBean tmx = threadMx(); + long tid = Thread.currentThread().getId(); + int hb = RESP_WIRE.length - 4; + ByteBuffer buf = ByteBuffer.wrap(RESP_WIRE); + + long[] keyLenSink = {0}; + int[] statusSink = {0}; + IntConsumer onStatus = s -> statusSink[0] = s; + BiConsumer onHeader = (k, v) -> keyLenSink[0] += k.length() + v.length(); + + for (int i = 0; i < WARMUP; i++) { + WireHeaderReader.apply(buf, 4, hb, onStatus, onHeader); + } + long before = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < MEASURE; i++) { + WireHeaderReader.apply(buf, 4, hb, onStatus, onHeader); + } + long after = tmx.getThreadAllocatedBytes(tid); + long bytesPerOp = (after - before) / MEASURE; + System.out.printf( + "VESPERA_ALLOC p3_apply bytes_per_op=%d (6 headers: 5 canonical + 1 other;" + + " status=%d keyLenSink=%d)%n", + bytesPerOp, statusSink[0], keyLenSink[0]); + } + + @Test + void p1_readBody_bodylessGet_bytesPerOp() throws Exception { + ThreadMXBean tmx = threadMx(); + long tid = Thread.currentThread().getId(); + long sink = 0; + + // A fresh MockHttpServletRequest each iteration; its allocation is + // identical before vs after, so it cancels in the before/after delta — + // the delta isolates readBody's own allocation (getInputStream wrapper + + // readAllBytes buffers, which the bodyless fast path skips). + for (int i = 0; i < WARMUP; i++) { + sink += VesperaProxyController.readBody(new MockHttpServletRequest("GET", "/health")).length; + } + long before = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < MEASURE; i++) { + sink += VesperaProxyController.readBody(new MockHttpServletRequest("GET", "/health")).length; + } + long after = tmx.getThreadAllocatedBytes(tid); + long bytesPerOp = (after - before) / MEASURE; + System.out.printf( + "VESPERA_ALLOC p1_readBody_bodyless bytes_per_op=%d" + + " (incl. constant MockHttpServletRequest alloc; sink=%d)%n", + bytesPerOp, sink); + } + + @Test + void proxyHeaderEncode_bytesPerOp() { + ThreadMXBean tmx = threadMx(); + long tid = Thread.currentThread().getId(); + MockHttpServletRequest req = realisticHeaderRequest(); + long sink = 0; + + for (int i = 0; i < WARMUP; i++) { + Map headers = HeaderPolicy.collectHeaders(req); + sink += VesperaBridge.encodeRequest(null, "GET", "/x", null, headers, null).length; + } + long oldBefore = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < MEASURE; i++) { + Map headers = HeaderPolicy.collectHeaders(req); + sink += VesperaBridge.encodeRequest(null, "GET", "/x", null, headers, null).length; + } + long oldAfter = tmx.getThreadAllocatedBytes(tid); + long oldBytesPerOp = (oldAfter - oldBefore) / MEASURE; + + for (int i = 0; i < WARMUP; i++) { + sink += VesperaBridge.encodeRequest(null, "GET", "/x", null, + (VesperaBridge.HeaderSource) (s -> HeaderPolicy.forEachRequestHeader(req, s)), + null).length; + } + long newBefore = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < MEASURE; i++) { + sink += VesperaBridge.encodeRequest(null, "GET", "/x", null, + (VesperaBridge.HeaderSource) (s -> HeaderPolicy.forEachRequestHeader(req, s)), + null).length; + } + long newAfter = tmx.getThreadAllocatedBytes(tid); + long newBytesPerOp = (newAfter - newBefore) / MEASURE; + + System.out.printf( + "VESPERA_ALLOC proxy_header_encode_old bytes_per_op=%d (sink=%d)%n", + oldBytesPerOp, sink); + System.out.printf( + "VESPERA_ALLOC proxy_header_encode_new bytes_per_op=%d (sink=%d)%n", + newBytesPerOp, sink); + } + + /** Reusable per-thread scratch for the JVM-02 "after" path. */ + private static final ThreadLocal DIRECT_SCRATCH = ThreadLocal.withInitial(() -> new byte[0]); + + private static final int DIRECT_SCRATCH_CAP = 256 * 1024; + + /** + * JVM-02 before/after allocation A/B for the DIRECT response body + * write. {@code before} bridges the direct {@link ByteBuffer} to the + * servlet {@link java.io.OutputStream} via a fresh + * {@link java.nio.channels.Channels#newChannel} per call (which + * allocates a channel object + an internal heap transfer buffer every + * time); {@code after} copies through a reusable per-thread + * {@code byte[]} scratch. Allocation-per-op is the deterministic, + * noise-free signal for this allocation-removal win. + */ + @Test + void directResponseWrite_bytesPerOp() throws Exception { + ThreadMXBean tmx = threadMx(); + long tid = Thread.currentThread().getId(); + + int payload = 8 * 1024; + ByteBuffer src = ByteBuffer.allocateDirect(payload); + for (int i = 0; i < payload; i++) { + src.put((byte) (i & 0x7f)); + } + // Discarding sink — mirrors writing to a committed servlet + // OutputStream without measuring the servlet container itself. + java.io.OutputStream sink = + new java.io.OutputStream() { + @Override + public void write(int b) {} + + @Override + public void write(byte[] b, int off, int len) {} + + @Override + public void write(byte[] b) {} + }; + + for (int i = 0; i < WARMUP; i++) { + directWriteBefore(src, sink); + } + long ob = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < MEASURE; i++) { + directWriteBefore(src, sink); + } + long oa = tmx.getThreadAllocatedBytes(tid); + long beforeBpo = (oa - ob) / MEASURE; + + for (int i = 0; i < WARMUP; i++) { + directWriteAfter(src, sink); + } + long nb = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < MEASURE; i++) { + directWriteAfter(src, sink); + } + long na = tmx.getThreadAllocatedBytes(tid); + long afterBpo = (na - nb) / MEASURE; + + System.out.printf( + "VESPERA_ALLOC direct_resp_write_before bytes_per_op=%d (8 KiB direct body)%n", + beforeBpo); + System.out.printf( + "VESPERA_ALLOC direct_resp_write_after bytes_per_op=%d (8 KiB direct body)%n", + afterBpo); + } + + /** Model DIRECT heap-scratch churn before/after adaptive sizing. */ + @Test + void directScratchRetention_reallocations() { + final int beforeInitial = 256 * 1024; + final int afterInitial = 16 * 1024; + final int afterRetainCap = 256 * 1024; + final int afterIdleWrites = 8; + final int largeBody = 1024 * 1024; + final int writes = 50; + + int beforeCap = beforeInitial; + int beforeReallocs = 0; + for (int i = 0; i < writes; i++) { + if (beforeCap > afterRetainCap) { + beforeCap = beforeInitial; + } + if (beforeCap < largeBody) { + beforeCap = largeBody; + beforeReallocs++; + } + } + + int afterCap = afterInitial; + int afterReallocs = 0; + int afterIdle = 0; + for (int i = 0; i < writes; i++) { + if (afterCap < largeBody) { + afterCap = largeBody; + afterReallocs++; + } + afterIdle = largeBody <= afterRetainCap ? afterIdle + 1 : 0; + if (afterIdle >= afterIdleWrites && afterCap > afterRetainCap) { + afterCap = afterInitial; + afterIdle = 0; + } + } + + System.out.printf( + "VESPERA_ALLOC direct_scratch_reallocs_before count=%d (%d writes, %d KiB body)%n", + beforeReallocs, writes, largeBody / 1024); + System.out.printf( + "VESPERA_ALLOC direct_scratch_reallocs_after count=%d retained_bytes=%d%n", + afterReallocs, afterCap); + } + + private static void directWriteBefore(ByteBuffer src, java.io.OutputStream out) + throws Exception { + src.clear(); + java.nio.channels.WritableByteChannel ch = java.nio.channels.Channels.newChannel(out); + while (src.hasRemaining()) { + ch.write(src); + } + } + + private static void directWriteAfter(ByteBuffer src, java.io.OutputStream out) + throws Exception { + src.clear(); + int needed = Math.min(src.remaining(), DIRECT_SCRATCH_CAP); + byte[] scratch = DIRECT_SCRATCH.get(); + if (scratch.length < needed) { + scratch = new byte[needed]; + DIRECT_SCRATCH.set(scratch); + } + while (src.hasRemaining()) { + int chunk = Math.min(scratch.length, src.remaining()); + src.get(scratch, 0, chunk); + out.write(scratch, 0, chunk); + } + } + + /** + * JVM-04 before/after for the per-thread header-buffer retention. + * The buffer is a private heap {@code byte[]} only exercised through + * the native dispatch path, so this models the two retention + * policies over a representative request sequence and measures the + * RETAINED capacity (the memory-footprint signal, not allocation + * rate). Production constants verified: {@code HEADER_INITIAL=256}, + * {@code HEADER_RETAIN=32 KiB}. {@code before} grows and never + * shrinks; {@code after} drops back to 256 once it exceeds 32 KiB. + */ + @Test + void headerBufRetention_retainedBytes() { + final int initial = 256; + final int retainCap = 32 * 1024; + final int hugeHeader = 64 * 1024; // one fat cookie/header burst + final int normalHeader = 256; + final int normalRequests = 1000; + + // BEFORE: monotonic grow, never shrink — one fat request pins the + // backing array for the rest of that servlet thread's life. + int beforeCap = initial; + beforeCap = Math.max(beforeCap, hugeHeader); + for (int i = 0; i < normalRequests; i++) { + beforeCap = Math.max(beforeCap, normalHeader); + } + + // AFTER: reset to initial whenever capacity exceeds the retain cap. + int afterCap = initial; + afterCap = Math.max(afterCap, hugeHeader); + if (afterCap > retainCap) { + afterCap = initial; + } + for (int i = 0; i < normalRequests; i++) { + afterCap = Math.max(afterCap, normalHeader); + if (afterCap > retainCap) { + afterCap = initial; + } + } + + System.out.printf( + "VESPERA_ALLOC header_buf_retained_before bytes=%d (pinned after one 64 KiB header)%n", + beforeCap); + System.out.printf( + "VESPERA_ALLOC header_buf_retained_after bytes=%d (reset below 32 KiB cap)%n", + afterCap); + } + + /** + * JVM-05 before/after for the direct-buffer pool retention. The + * pooled buffers are off-heap direct {@link ByteBuffer}s only + * exercised through the native dispatch path, so this models the two + * policies over a repeated-large-response sequence and counts the + * multi-MiB direct (re)allocations — each {@code before} realloc also + * forces a Rust handler re-run on the overflow retry. Production + * constants verified: {@code DIRECT_INITIAL=64 KiB}, + * {@code DIRECT_SHRINK_IDLE_DISPATCHES=8}. {@code before} shrinks to + * initial at the start of every dispatch; {@code after} keeps the + * grown buffer while it stays in use. + */ + @Test + void directPoolRetention_reallocations() { + // Production defaults: DIRECT_INITIAL 64 KiB, DIRECT_RETAIN 2 MiB, + // DIRECT_MAX 4 MiB. The modelled response must exceed the retain + // cap (so the policies diverge) yet fit within the max cap (so it + // stays on the pooled direct path instead of the heap fallback) — + // 3 MiB satisfies both. + final int initial = 64 * 1024; + final int retainCap = 2 * 1024 * 1024; + final int reqSize = 3 * 1024 * 1024; // repeated 3 MiB idempotent response + final int dispatches = 50; + + // BEFORE: eager shrink at the start of each dispatch → every + // dispatch re-grows (reallocates) the big buffer AND re-runs the + // Rust handler on the overflow retry. + int beforeReallocs = 0; + int beforeRehandlers = 0; + int beforeCap = initial; + for (int i = 0; i < dispatches; i++) { + if (beforeCap > retainCap) { + beforeCap = initial; // eager shrink + } + if (beforeCap < reqSize) { + beforeCap = reqSize; + beforeReallocs++; + beforeRehandlers++; // overflow → retry re-runs the handler + } + } + + // AFTER: adaptive — keep the grown buffer while repeatedly used; + // shrink only after 8 consecutive under-retain dispatches. + int afterReallocs = 0; + int afterRehandlers = 0; + int afterCap = initial; + int idle = 0; + for (int i = 0; i < dispatches; i++) { + if (idle >= 8 && afterCap > retainCap) { + afterCap = initial; + idle = 0; + } + if (afterCap < reqSize) { + afterCap = reqSize; + afterReallocs++; + afterRehandlers++; + } + idle = (reqSize <= retainCap) ? idle + 1 : 0; + } + + System.out.printf( + "VESPERA_ALLOC direct_pool_reallocs_before count=%d handler_reruns=%d (%d dispatches, %d MiB each)%n", + beforeReallocs, beforeRehandlers, dispatches, reqSize / (1024 * 1024)); + System.out.printf( + "VESPERA_ALLOC direct_pool_reallocs_after count=%d handler_reruns=%d%n", + afterReallocs, afterRehandlers); + } + + /** Regression model for retaining steady medium responses below the cap. */ + @Test + void directPoolMediumRetention_reallocations() { + final int initial = 64 * 1024; + final int retainCap = 2 * 1024 * 1024; + final int respSize = 1024 * 1024; + final int dispatches = 50; + + int beforeReallocs = 0; + int beforeRehandlers = 0; + int beforeCap = initial; + int beforeIdle = 0; + for (int i = 0; i < dispatches; i++) { + if (beforeCap < respSize) { + beforeCap = respSize; + beforeReallocs++; + beforeRehandlers++; + } + beforeIdle = respSize <= retainCap ? beforeIdle + 1 : 0; + if (beforeIdle >= 8 && beforeCap > initial) { + beforeCap = initial; + beforeIdle = 0; + } + } + + int afterReallocs = 0; + int afterRehandlers = 0; + int afterCap = initial; + int afterIdle = 0; + for (int i = 0; i < dispatches; i++) { + if (afterCap < respSize) { + afterCap = respSize; + afterReallocs++; + afterRehandlers++; + } + afterIdle = respSize <= retainCap ? afterIdle + 1 : 0; + if (afterIdle >= 8 && afterCap > retainCap) { + afterCap = initial; + afterIdle = 0; + } + } + + System.out.printf( + "VESPERA_ALLOC direct_pool_medium_reallocs_before count=%d handler_reruns=%d (%d dispatches, %d KiB each)%n", + beforeReallocs, beforeRehandlers, dispatches, respSize / 1024); + System.out.printf( + "VESPERA_ALLOC direct_pool_medium_reallocs_after count=%d handler_reruns=%d retained_bytes=%d%n", + afterReallocs, afterRehandlers, afterCap); + } + + private static MockHttpServletRequest realisticHeaderRequest() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("Host", "api.example.test"); + req.addHeader("User-Agent", "Mozilla/5.0 vespera-bench"); + req.addHeader("Accept", "application/json"); + req.addHeader("Accept-Encoding", "gzip, br"); + req.addHeader("Accept-Language", "en-US,en;q=0.9"); + req.addHeader("Cache-Control", "no-cache"); + req.addHeader("Cookie", "sid=abc"); + req.addHeader("Cookie", "theme=dark"); + req.addHeader("X-Request-Id", "01HV2N3M4P5Q6R7S8T9V0W1X2Y"); + req.addHeader("X-Forwarded-For", "203.0.113.10"); + req.addHeader("X-Forwarded-Proto", "https"); + req.addHeader("X-Vespera-App", "admin"); + return req; + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ProxyControllerBodyHeaderTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ProxyControllerBodyHeaderTest.java new file mode 100644 index 00000000..e758260f --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ProxyControllerBodyHeaderTest.java @@ -0,0 +1,496 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.LinkedHashMap; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.server.ResponseStatusException; + +/** + * B4 (duplicate request-header joining, no longer silently dropped) and + * P1 (provably-bodyless requests skip the servlet InputStream read). + */ +class ProxyControllerBodyHeaderTest { + + // ── B4: collectHeaders joins repeated header values ────────────────── + + @Test + void duplicateHeadersAreCommaJoined() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("Accept", "text/html"); + req.addHeader("Accept", "application/json"); + Map headers = HeaderPolicy.collectHeaders(req); + assertEquals("text/html, application/json", headers.get("accept")); + } + + // ── C-2: async executor backpressure (AbortPolicy → 503) ───────────── + + @Test + void asyncRejectionMapsTo503AndOtherFailuresPropagate() { + // CompletableFuture delivers an executor rejection wrapped in a + // CompletionException. asyncFailureToResponse must turn that into a 503 + // backpressure response (instead of letting the heavy wire build run on + // a Rust Tokio worker, the CallerRunsPolicy hazard this replaces), while + // re-propagating every OTHER failure unchanged so Spring maps it as + // before. + Throwable rejected = new java.util.concurrent.CompletionException( + new java.util.concurrent.RejectedExecutionException("queue full")); + assertTrue(VesperaProxyController.isRejectedExecution(rejected)); + assertFalse(VesperaProxyController.isRejectedExecution(new RuntimeException("boom"))); + + ResponseEntity resp = VesperaProxyController.asyncFailureToResponse(rejected); + assertEquals(503, resp.getStatusCode().value()); + + assertThrows(java.util.concurrent.CompletionException.class, + () -> VesperaProxyController.asyncFailureToResponse(new RuntimeException("boom"))); + } + + // ── ASYNC buffered-response cap parity with SYNC ───────────────────── + + /** Build a wire response {@code [u32 BE headerLen | header JSON | body]}. */ + private static byte[] wireResponseWithBody(int bodyLen) { + String json = + "{\"v\":1,\"status\":200,\"headers\":{\"content-type\":\"application/json\"}," + + "\"metadata\":{\"version\":\"0.1.0\"}}"; + byte[] hb = json.getBytes(StandardCharsets.UTF_8); + byte[] body = new byte[bodyLen]; + java.util.Arrays.fill(body, (byte) 'x'); + ByteBuffer buf = ByteBuffer.allocate(4 + hb.length + bodyLen); + buf.putInt(hb.length); + buf.put(hb); + buf.put(body); + return buf.array(); + } + + @Test + void asyncResponseEnforcesMaxBufferedResponseCap() { + // A custom DispatchModeResolver returning ASYNC must honour the same + // max-buffered-response cap as SYNC (dispatchSync), or it heap-buffers + // an unbounded Rust response. The capped builder the async flow now + // uses rejects an oversized body with 413, lets a within-cap body + // through, and treats cap = 0 as unlimited (never rejects). + byte[] oversized = wireResponseWithBody(100); + ResponseStatusException tooLarge = assertThrows( + ResponseStatusException.class, + () -> VesperaProxyController.buildCappedResponseEntityFromWire(oversized, "GET", 10)); + assertEquals(413, tooLarge.getStatusCode().value()); + + byte[] small = wireResponseWithBody(5); + ResponseEntity ok = + VesperaProxyController.buildCappedResponseEntityFromWire(small, "GET", 1000); + assertEquals(200, ok.getStatusCode().value()); + + ResponseEntity unlimited = + VesperaProxyController.buildCappedResponseEntityFromWire(oversized, "GET", 0); + assertEquals(200, unlimited.getStatusCode().value()); + } + + @Test + void duplicateCookieHeadersAreSemicolonJoined() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("Cookie", "a=1"); + req.addHeader("Cookie", "b=2"); + Map headers = HeaderPolicy.collectHeaders(req); + // RFC 6265bis: Cookie joins with "; ", never ",". + assertEquals("a=1; b=2", headers.get("cookie")); + } + + @Test + void singleValuedHeaderIsUnchanged() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("X-Trace-Id", "abc123"); + Map headers = HeaderPolicy.collectHeaders(req); + assertEquals("abc123", headers.get("x-trace-id")); + } + + @Test + void requestHopByHopAndConnectionNominatedHeadersAreDropped() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + req.addHeader("Connection", "X-Internal-Hop, x-another-hop"); + req.addHeader("X-Internal-Hop", "secret"); + req.addHeader("X-Another-Hop", "secret2"); + req.addHeader("Transfer-Encoding", "chunked"); + req.addHeader("Content-Type", "application/json"); + req.addHeader("X-Trace-Id", "abc123"); + + Map headers = HeaderPolicy.collectHeaders(req); + + assertFalse(headers.containsKey("connection")); + assertFalse(headers.containsKey("x-internal-hop")); + assertFalse(headers.containsKey("x-another-hop")); + assertFalse(headers.containsKey("transfer-encoding")); + assertEquals("application/json", headers.get("content-type")); + assertEquals("abc123", headers.get("x-trace-id")); + } + + @Test + void streamingHeaderFastPathMatchesPreviousMergedMapBytesExactly() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("Connection", "X-Hop"); + req.addHeader("X-Hop", "drop-me"); + req.addHeader("Accept", "text/html"); + req.addHeader("accept", "application/json"); + req.addHeader("Cookie", "a=1"); + req.addHeader("cookie", "b=2"); + req.addHeader("X-Trace-Id", "abc123"); + + Map previous = previousLinkedHashMapCollect(req); + byte[] expected = VesperaBridge.encodeRequest(null, "GET", "/x", null, previous, null); + byte[] actual = VesperaBridge.encodeRequest(null, "GET", "/x", null, + (VesperaBridge.HeaderSource) sink -> HeaderPolicy.forEachRequestHeader(req, sink), + null); + + assertArrayEquals(expected, actual); + assertEquals("text/html, application/json", previous.get("accept")); + assertEquals("a=1; b=2", previous.get("cookie")); + } + + private static Map previousLinkedHashMapCollect(MockHttpServletRequest req) { + Map merged = new LinkedHashMap<>(32); + java.util.Enumeration names = req.getHeaderNames(); + java.util.Set connectionTokens = null; + java.util.Enumeration connections = req.getHeaders("Connection"); + while (connections.hasMoreElements()) { + connectionTokens = HeaderPolicy.addConnectionTokens(connectionTokens, connections.nextElement()); + } + while (names.hasMoreElements()) { + String name = names.nextElement(); + String lowerName = name.toLowerCase(java.util.Locale.ROOT); + if (!HeaderPolicy.isHopByHopResponseHeader(lowerName) + && !HeaderPolicy.isConnectionNominatedHeader(lowerName, connectionTokens)) { + String value = String.join( + lowerName.equals("cookie") ? "; " : ", ", + java.util.Collections.list(req.getHeaders(name))); + merged.merge(lowerName, value, (left, right) -> + left + (lowerName.equals("cookie") ? "; " : ", ") + right); + } + } + return merged; + } + + // ── P1: readBody skips the stream for provably bodyless requests ───── + + @Test + void bodylessGetWithoutContentLengthReadsEmpty() throws IOException { + // No Content-Length, no body — definitelyBodyless() is true, so the + // servlet InputStream is never touched. + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + assertEquals(0, VesperaProxyController.readBody(req).length); + } + + @Test + void contentLengthZeroReadsEmpty() throws IOException { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + req.setContent(new byte[0]); + assertEquals(0, VesperaProxyController.readBody(req).length); + } + + @Test + void postWithBodyIsReadFully() throws IOException { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + req.setContent("hello".getBytes(StandardCharsets.UTF_8)); + assertEquals( + "hello", + new String(VesperaProxyController.readBody(req), StandardCharsets.UTF_8)); + } + + @Test + void knownLengthOverBufferedCapIsRejected() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + req.setContent("hello".getBytes(StandardCharsets.UTF_8)); + + ResponseStatusException e = assertThrows( + ResponseStatusException.class, + () -> VesperaProxyController.readBody(req, 4)); + + assertEquals(413, e.getStatusCode().value()); + } + + @Test + void unknownLengthOverBufferedCapIsRejectedAfterCapPlusOneRead() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x") { + @Override + public long getContentLengthLong() { + return -1; + } + }; + req.setContent("hello".getBytes(StandardCharsets.UTF_8)); + + ResponseStatusException e = assertThrows( + ResponseStatusException.class, + () -> VesperaProxyController.readBody(req, 4)); + + assertEquals(413, e.getStatusCode().value()); + } + + @Test + void unknownLengthWithHugeConfiguredCapDoesNotAllocateHugeReadBuffer() throws IOException { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x") { + @Override + public long getContentLengthLong() { + return -1; + } + }; + req.setContent("hello".getBytes(StandardCharsets.UTF_8)); + + byte[] body = VesperaProxyController.readBody(req, Long.MAX_VALUE); + + assertEquals("hello", new String(body, StandardCharsets.UTF_8)); + } + + @Test + void bufferedCapZeroKeepsBackwardCompatibleUnlimitedRead() throws IOException { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + req.setContent("hello".getBytes(StandardCharsets.UTF_8)); + + assertEquals(5, VesperaProxyController.readBody(req, 0).length); + } + + @Test + void configuredBufferedCapRejectsUnknownLengthBodyAfterCapPlusOneRead() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x") { + @Override + public long getContentLengthLong() { + return -1; + } + }; + req.setContent(new byte[5]); + + ResponseStatusException e = assertThrows( + ResponseStatusException.class, + () -> VesperaProxyController.readBody(req, RequestShape.from(req), 4)); + + assertEquals(413, e.getStatusCode().value()); + } + + @Test + void conservativeDefaultBufferedCapRejectsKnownOversizedBodyBeforeRead() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x") { + @Override + public long getContentLengthLong() { + return VesperaProxyController.DEFAULT_MAX_BUFFERED_REQUEST_BYTES + 1; + } + }; + + ResponseStatusException e = assertThrows( + ResponseStatusException.class, + () -> VesperaProxyController.readBody( + req, + RequestShape.from(req), + VesperaProxyController.DEFAULT_MAX_BUFFERED_REQUEST_BYTES)); + + assertEquals(413, e.getStatusCode().value()); + } + + // ── Context-path stripping: Rust sees the context-relative path ────── + + @Test + void pathWithinApplicationStripsContextPath() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/health"); + req.setContextPath("/api"); + req.setRequestURI("/api/health"); + // A non-root deployment must forward `/health`, matching openapi.json. + assertEquals("/health", VesperaProxyController.pathWithinApplication(req)); + } + + @Test + void pathWithinApplicationRootContextUnchanged() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/health"); + req.setContextPath(""); + req.setRequestURI("/health"); + assertEquals("/health", VesperaProxyController.pathWithinApplication(req)); + } + + @Test + void pathWithinApplicationBareContextRootCollapsesToSlash() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api"); + req.setContextPath("/api"); + req.setRequestURI("/api"); + assertEquals("/", VesperaProxyController.pathWithinApplication(req)); + } + + @Test + void pathWithinApplicationDoesNotStripPartialSegmentMatch() { + // Context `/api` must NOT mis-strip a `/apixyz/...` URI. + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/apixyz/foo"); + req.setContextPath("/api"); + req.setRequestURI("/apixyz/foo"); + assertEquals("/apixyz/foo", VesperaProxyController.pathWithinApplication(req)); + } + + @Test + void directHeaderSynthesizesContentLengthWhenMissing() { + MockHttpServletResponse response = new MockHttpServletResponse(); + ByteBuffer wire = directWire("{\"status\":200,\"headers\":{}}", "hello"); + + int bodyLen = VesperaProxyController.applyDirectHeaderAndPositionBody(wire, response); + + assertEquals(5, bodyLen); + assertEquals(5, response.getContentLength()); + assertEquals(4 + "{\"status\":200,\"headers\":{}}".getBytes(StandardCharsets.UTF_8).length, + wire.position()); + } + + @Test + void directHeaderOwnsContentLengthWhenWireDisagrees() { + MockHttpServletResponse response = new MockHttpServletResponse(); + ByteBuffer wire = directWire( + "{\"status\":200,\"headers\":{\"content-length\":\"123\"}}", + "hello"); + + int bodyLen = VesperaProxyController.applyDirectHeaderAndPositionBody(wire, response); + + assertEquals(5, bodyLen); + assertEquals(5, response.getContentLength()); + assertEquals("5", response.getHeader("Content-Length")); + } + + @Test + void directHeaderSuppressesNoBodyStatusBodyAndLength() { + MockHttpServletResponse response = new MockHttpServletResponse(); + ByteBuffer wire = directWire( + "{\"status\":204,\"headers\":{\"content-length\":\"123\"}}", + "hello"); + + int bodyLen = VesperaProxyController.applyDirectHeaderAndPositionBody(wire, response); + + assertEquals(0, bodyLen); + assertEquals(0, response.getContentLength()); + assertEquals("0", response.getHeader("Content-Length")); + } + + @Test + void directHeaderSuppressesHeadResponseBody() { + MockHttpServletResponse response = new MockHttpServletResponse(); + ByteBuffer wire = directWire("{\"status\":200,\"headers\":{}}", "hello"); + + int bodyLen = VesperaProxyController.applyDirectHeaderAndPositionBody( + wire, response, "HEAD"); + + assertEquals(0, bodyLen); + assertEquals(5, response.getContentLength()); + } + + @Test + void asyncResponseEntityAdvertisesHeadRepresentationLengthAndSuppressesHeadBody() throws IOException { + byte[] wire = heapWire( + "{\"status\":200,\"headers\":{\"content-length\":\"123\"}}", + "hello"); + + ResponseEntity entity = VesperaProxyController.buildResponseEntityFromWire(wire, "HEAD"); + + assertEquals(5, entity.getHeaders().getContentLength()); + Resource body = (Resource) entity.getBody(); + assertEquals(0, body.contentLength()); + try (InputStream in = body.getInputStream()) { + assertEquals(-1, in.read()); + } + } + + @Test + void responseConnectionNominatedHeadersAreDropped() { + byte[] wire = heapWire( + "{\"status\":200,\"headers\":{\"connection\":\"x-internal-hop\"," + + "\"x-internal-hop\":\"secret\",\"x-visible\":\"ok\"}}", + "hello"); + + ResponseEntity entity = VesperaProxyController.buildResponseEntityFromWire(wire, "GET"); + + assertFalse(entity.getHeaders().containsKey("connection")); + assertFalse(entity.getHeaders().containsKey("x-internal-hop")); + assertEquals("ok", entity.getHeaders().getFirst("x-visible")); + } + + @Test + void streamingHeaderDropsContentLengthAndBodyGateSuppressesNoBodyStatus() throws IOException { + byte[] header = heapWire( + "{\"status\":304,\"headers\":{\"content-length\":\"123\"," + + "\"content-type\":\"text/plain\"}}", + ""); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean permits = VesperaProxyController.applyDecodedHeader(header, response, "GET"); + + assertFalse(permits); + assertFalse(response.containsHeader("content-length")); + assertEquals("text/plain", response.getHeader("content-type")); + + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + VesperaProxyController.BodyPermittingOutputStream out = + new VesperaProxyController.BodyPermittingOutputStream(sink, "GET"); + out.applyPermitsBody(permits); + out.write("hello".getBytes(StandardCharsets.UTF_8)); + assertEquals(0, sink.size()); + } + + @Test + void directHeaderDropsHopByHopHeaders() { + MockHttpServletResponse response = new MockHttpServletResponse(); + // Wire response carries hop-by-hop `transfer-encoding` / `connection` + // (which desync framing if forwarded) alongside a normal `content-type`. + ByteBuffer wire = directWire( + "{\"status\":200,\"headers\":{\"transfer-encoding\":\"chunked\"," + + "\"connection\":\"keep-alive\",\"content-type\":\"application/json\"}}", + "hi"); + + int bodyLen = VesperaProxyController.applyDirectHeaderAndPositionBody(wire, response); + + // Hop-by-hop headers are owned by the proxy and never forwarded. + assertFalse(response.containsHeader("transfer-encoding")); + assertFalse(response.containsHeader("connection")); + // Normal application headers pass through unchanged. + assertEquals("application/json", response.getHeader("content-type")); + // The proxy still synthesises Content-Length from the body. + assertEquals(2, bodyLen); + assertEquals(2, response.getContentLength()); + } + + @Test + void isHopByHopResponseHeaderClassifiesCaseInsensitively() { + assertTrue(HeaderPolicy.isHopByHopResponseHeader("Transfer-Encoding")); + assertTrue(HeaderPolicy.isHopByHopResponseHeader("connection")); + assertTrue(HeaderPolicy.isHopByHopResponseHeader("UPGRADE")); + // content-length is not hop-by-hop, but addServletResponseHeader treats + // it as proxy-owned framing and drops it separately. + assertFalse(HeaderPolicy.isHopByHopResponseHeader("content-length")); + assertFalse(HeaderPolicy.isHopByHopResponseHeader("content-type")); + } + + private static ByteBuffer directWire(String headerJson, String body) { + byte[] header = headerJson.getBytes(StandardCharsets.UTF_8); + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + ByteBuffer buf = ByteBuffer.allocateDirect(4 + header.length + bodyBytes.length); + buf.putInt(header.length); + buf.put(header); + buf.put(bodyBytes); + buf.flip(); + return buf.asReadOnlyBuffer(); + } + + private static byte[] heapWire(String headerJson, String body) { + byte[] header = headerJson.getBytes(StandardCharsets.UTF_8); + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + byte[] wire = new byte[4 + header.length + bodyBytes.length]; + wire[0] = (byte) (header.length >>> 24); + wire[1] = (byte) (header.length >>> 16); + wire[2] = (byte) (header.length >>> 8); + wire[3] = (byte) header.length; + System.arraycopy(header, 0, wire, 4, header.length); + System.arraycopy(bodyBytes, 0, wire, 4 + header.length, bodyBytes.length); + return wire; + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ResponseBodyBuildTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ResponseBodyBuildTest.java new file mode 100644 index 00000000..4542703c --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ResponseBodyBuildTest.java @@ -0,0 +1,272 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse; +import java.lang.management.ManagementFactory; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; + +/** + * Lever 1 gate: the controller builds the response body straight from the wire + * buffer ({@code Arrays.copyOfRange(wire, bodyOff, end)}) instead of {@code + * decoded.bodyBytes()}. Since the controller now unifies on {@code + * ResponseEntity} for every content type, the text helpers below + * ({@code new String(wire, off, len)}) remain as a byte-identity proof of the + * extraction offsets across the content/charset matrix — they are no longer the + * controller's delivery path, which slices to {@code byte[]} uniformly and so + * drops both the intermediate {@code byte[]} and the prior text-only UTF-8 + * decode→re-encode round-trip. + */ +class ResponseBodyBuildTest { + + /** Assemble a wire response {@code [u32 len | header | body]}. */ + private static byte[] wire(String contentType, byte[] body) { + String header = + contentType == null + ? "{\"v\":1,\"status\":200,\"headers\":{},\"metadata\":{\"version\":\"0.0.0\"}}" + : "{\"v\":1,\"status\":200,\"headers\":{\"content-type\":\"" + + contentType + + "\"},\"metadata\":{\"version\":\"0.0.0\"}}"; + byte[] hb = header.getBytes(StandardCharsets.UTF_8); + byte[] w = new byte[4 + hb.length + body.length]; + w[0] = (byte) (hb.length >>> 24); + w[1] = (byte) (hb.length >>> 16); + w[2] = (byte) (hb.length >>> 8); + w[3] = (byte) hb.length; + System.arraycopy(hb, 0, w, 4, hb.length); + System.arraycopy(body, 0, w, 4 + hb.length, body.length); + return w; + } + + // OLD: new String(decoded.bodyBytes(), UTF_8). NEW: new String(wire, off, len). + private static void assertTextEquivalent(byte[] body) { + byte[] w = wire("application/json", body); + DecodedResponse d = VesperaBridge.decodeResponse(w); + String oldStr = new String(d.bodyBytes(), StandardCharsets.UTF_8); + int bodyLen = d.body().remaining(); + int bodyOff = w.length - bodyLen; + String newStr = new String(w, bodyOff, bodyLen, StandardCharsets.UTF_8); + assertEquals(oldStr, newStr, "text body extraction must match the bodyBytes() path"); + } + + // OLD: decoded.bodyBytes(). NEW: Arrays.copyOfRange(wire, off, end). + private static void assertBinaryEquivalent(byte[] body) { + byte[] w = wire("application/octet-stream", body); + DecodedResponse d = VesperaBridge.decodeResponse(w); + byte[] oldB = d.bodyBytes(); + int bodyLen = d.body().remaining(); + int bodyOff = w.length - bodyLen; + byte[] newB = Arrays.copyOfRange(w, bodyOff, w.length); + assertArrayEquals(oldB, newB, "binary body extraction must match the bodyBytes() path"); + assertArrayEquals(body, newB, "binary body must round-trip exactly"); + } + + @Test + void textBodyMatrixIsByteIdentical() { + assertTextEquivalent("{\"ok\":true}".getBytes(StandardCharsets.UTF_8)); + assertTextEquivalent("plain ascii".getBytes(StandardCharsets.UTF_8)); + assertTextEquivalent("café — naïve — 日本語".getBytes(StandardCharsets.UTF_8)); + // 4-byte codepoint (emoji) — the multi-byte boundary case Metis flagged. + assertTextEquivalent("ok\uD83D\uDE80end".getBytes(StandardCharsets.UTF_8)); + assertTextEquivalent(new byte[0]); // empty + } + + @Test + void binaryBodyMatrixIsByteIdentical() { + byte[] allBytes = new byte[256]; + for (int i = 0; i < 256; i++) { + allBytes[i] = (byte) i; + } + assertBinaryEquivalent(allBytes); + assertBinaryEquivalent(new byte[0]); // empty + byte[] big = new byte[64 * 1024]; + new java.util.Random(7).nextBytes(big); + assertBinaryEquivalent(big); + } + + @Test + void isoLatin1BytesRoundTripViaUtf8DecodeUnchanged() { + // The controller decodes text as UTF-8 regardless of the charset + // parameter (pre-existing behavior). Confirm the new path preserves + // exactly that — same bytes in, same String out as the old path. + byte[] iso = {(byte) 0xE9, (byte) 0xE8, 'a', 'b'}; // é è in ISO-8859-1 + byte[] w = wire("text/plain; charset=ISO-8859-1", iso); + DecodedResponse d = VesperaBridge.decodeResponse(w); + String oldStr = new String(d.bodyBytes(), StandardCharsets.UTF_8); + int bodyLen = d.body().remaining(); + String newStr = new String(w, w.length - bodyLen, bodyLen, StandardCharsets.UTF_8); + assertEquals(oldStr, newStr); + } + + /** Allocation saving (bytes/op) — OLD bodyBytes()+String vs NEW direct String. */ + @Test + void allocationSavingScalesWithBodySize() throws Exception { + com.sun.management.ThreadMXBean tmx = + (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + long tid = Thread.currentThread().getId(); + StringBuilder report = new StringBuilder(); + for (int kb : new int[] {1, 64, 1024}) { + byte[] body = new byte[kb * 1024]; + new java.util.Random(1).nextBytes(body); + // keep it valid-ish text by masking to ASCII so both paths decode identically + for (int i = 0; i < body.length; i++) { + body[i] = (byte) (body[i] & 0x7F); + } + byte[] w = wire("application/json", body); + + int warm = 2000; + int iters = 20000; + long blackhole = 0; + for (int i = 0; i < warm; i++) { + blackhole += oldText(w); + blackhole += newText(w); + } + long b0 = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < iters; i++) blackhole += oldText(w); + long oldBytes = (tmx.getThreadAllocatedBytes(tid) - b0) / iters; + long b1 = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < iters; i++) blackhole += newText(w); + long newBytes = (tmx.getThreadAllocatedBytes(tid) - b1) / iters; + report.append( + String.format( + "VESPERA_L1ALLOC body_kb=%d old_bytes=%d new_bytes=%d saved=%d (bh %d)%n", + kb, oldBytes, newBytes, oldBytes - newBytes, blackhole & 1)); + } + Files.writeString(Path.of(System.getProperty("java.io.tmpdir"), "vespera_l1alloc.txt"), report); + } + + private static int oldText(byte[] w) { + DecodedResponse d = VesperaBridge.decodeResponse(w); + return new String(d.bodyBytes(), StandardCharsets.UTF_8).length(); + } + + private static int newText(byte[] w) { + DecodedResponse d = VesperaBridge.decodeResponse(w); + int bodyLen = d.body().remaining(); + return new String(w, w.length - bodyLen, bodyLen, StandardCharsets.UTF_8).length(); + } + + // ---- Lever 2: lean status+headers parse (WireHeaderReader) vs decodeResponse graph ---- + + private static int headerLen(byte[] w) { + return ((w[0] & 0xFF) << 24) | ((w[1] & 0xFF) << 16) | ((w[2] & 0xFF) << 8) | (w[3] & 0xFF); + } + + /** OLD: decodeResponse graph → iterate headers map into HttpHeaders. */ + private static HttpHeaders oldHeaders(byte[] w) { + DecodedResponse d = VesperaBridge.decodeResponse(w); + HttpHeaders h = new HttpHeaders(); + for (var e : d.headers().entrySet()) { + Object v = e.getValue(); + if (v instanceof java.util.List list) { + for (Object x : list) { + h.add(e.getKey(), String.valueOf(x)); + } + } else if (v != null) { + h.set(e.getKey(), String.valueOf(v)); + } + } + return h; + } + + /** NEW: lean WireHeaderReader straight into HttpHeaders. */ + private static HttpHeaders leanHeaders(byte[] w, int[] status) { + HttpHeaders h = new HttpHeaders(); + WireHeaderReader.apply( + java.nio.ByteBuffer.wrap(w), 4, headerLen(w), s -> status[0] = s, h::add); + return h; + } + + @Test + void leanStatusAndHeadersMatchDecodeResponse() { + // single-value header + byte[] w1 = wire("application/json", "{\"x\":1}".getBytes(StandardCharsets.UTF_8)); + DecodedResponse d1 = VesperaBridge.decodeResponse(w1); + int[] s1 = {-1}; + assertEquals(d1.status(), leanHeaders(w1, s1) == null ? -1 : s1[0]); + assertEquals(oldHeaders(w1), leanHeaders(w1, new int[1])); + // multi-value (set-cookie) + status + String hdr = + "{\"v\":1,\"status\":201,\"headers\":{\"set-cookie\":[\"a=1\",\"b=2\"]," + + "\"content-type\":\"application/json\"},\"metadata\":{\"version\":\"x\"}}"; + byte[] hb = hdr.getBytes(StandardCharsets.UTF_8); + byte[] w2 = new byte[4 + hb.length]; + w2[0] = (byte) (hb.length >>> 24); + w2[1] = (byte) (hb.length >>> 16); + w2[2] = (byte) (hb.length >>> 8); + w2[3] = (byte) hb.length; + System.arraycopy(hb, 0, w2, 4, hb.length); + int[] s2 = {-1}; + HttpHeaders lean2 = leanHeaders(w2, s2); + assertEquals(201, s2[0]); + assertEquals(oldHeaders(w2), lean2); + } + + /** OLD full response build (decodeResponse graph + bodyBytes+String). */ + private static int oldFull(byte[] w) { + DecodedResponse d = VesperaBridge.decodeResponse(w); + HttpHeaders h = new HttpHeaders(); + for (var e : d.headers().entrySet()) { + if (e.getValue() != null) { + h.add(e.getKey(), String.valueOf(e.getValue())); + } + } + return d.status() + h.size() + new String(d.bodyBytes(), StandardCharsets.UTF_8).length(); + } + + /** + * NEW full response build (lean reader + body-from-wire) — + * buildResponseEntityFromWire logic. Since the controller now unifies + * on {@code ResponseEntity} for every content type (dropping + * the text-only {@code new String} branch and its UTF-8 + * decode→re-encode round-trip), the body is modelled as the + * {@code Arrays.copyOfRange} slice the controller actually returns. + */ + private static int newFull(byte[] w) { + int hl = headerLen(w); + HttpHeaders h = new HttpHeaders(); + int[] st = {500}; + WireHeaderReader.apply(java.nio.ByteBuffer.wrap(w), 4, hl, s -> st[0] = s, h::add); + int bodyOff = 4 + hl; + return st[0] + h.size() + Arrays.copyOfRange(w, bodyOff, w.length).length; + } + + @Test + void combinedAllocationSaving() throws Exception { + com.sun.management.ThreadMXBean tmx = + (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + long tid = Thread.currentThread().getId(); + StringBuilder report = new StringBuilder(); + for (int kb : new int[] {0, 1, 64}) { + byte[] body = new byte[kb * 1024]; + for (int i = 0; i < body.length; i++) { + body[i] = (byte) ('a' + (i % 26)); + } + byte[] w = wire("application/json", body); + int warm = 2000; + int iters = 20000; + long bh = 0; + for (int i = 0; i < warm; i++) { + bh += oldFull(w); + bh += newFull(w); + } + long b0 = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < iters; i++) bh += oldFull(w); + long oldB = (tmx.getThreadAllocatedBytes(tid) - b0) / iters; + long b1 = tmx.getThreadAllocatedBytes(tid); + for (int i = 0; i < iters; i++) bh += newFull(w); + long newB = (tmx.getThreadAllocatedBytes(tid) - b1) / iters; + report.append( + String.format( + "VESPERA_L2ALLOC body_kb=%d old_bytes=%d new_bytes=%d saved=%d (bh %d)%n", + kb, oldB, newB, oldB - newB, bh & 1)); + } + Files.writeString(Path.of(System.getProperty("java.io.tmpdir"), "vespera_l2alloc.txt"), report); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/SmartDispatchModeResolverTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/SmartDispatchModeResolverTest.java new file mode 100644 index 00000000..703d7e0c --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/SmartDispatchModeResolverTest.java @@ -0,0 +1,167 @@ +package com.devfive.vespera.bridge; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** Pure-Java gating tests for {@link SmartDispatchModeResolver}. */ +class SmartDispatchModeResolverTest { + + private final SmartDispatchModeResolver resolver = new SmartDispatchModeResolver(); + + private static HttpServletRequest request(String method, long contentLength) { + MockHttpServletRequest req = new MockHttpServletRequest(method, "/x"); + if (contentLength >= 0) { + // MockHttpServletRequest derives getContentLengthLong() from + // the content array length, not the header. + req.setContent(new byte[(int) contentLength]); + } + return req; + } + + @Test + void smallSafeRequestUsesDirect() { + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("GET", 128))); + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("HEAD", 0))); + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("OPTIONS", + SmartDispatchModeResolver.DEFAULT_MAX_DIRECT_BYTES))); + } + + @Test + void smallUnsafeIdempotentRequestsUseSyncNeverDirect() { + assertEquals(DispatchMode.SYNC, + resolver.resolveMode(request("PUT", 128))); + assertEquals(DispatchMode.SYNC, + resolver.resolveMode(request("DELETE", 128))); + } + + @Test + void smallNonIdempotentRequestsUseSyncNeverDirect() { + // SYNC never re-runs the handler — safe for POST/PATCH, and + // 7.5x cheaper than bidirectional streaming for small bodies. + assertEquals(DispatchMode.SYNC, + resolver.resolveMode(request("POST", 128))); + assertEquals(DispatchMode.SYNC, + resolver.resolveMode(request("PATCH", 128))); + } + + @Test + void bodylessGetWithoutContentLengthUsesDirect() { + // The common GET shape: no body, no Content-Length header. + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + assertEquals(DispatchMode.DIRECT, resolver.resolveMode(req)); + } + + @Test + void chunkedTransferEncodingFallsBackToStreaming() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("Transfer-Encoding", "chunked"); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, resolver.resolveMode(req)); + } + + @Test + void lengthlessNonIdempotentFallsBackToStreaming() { + // POST without Content-Length: body cannot be ruled out. + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/x"); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, resolver.resolveMode(req)); + } + + @Test + void oversizedNonIdempotentFallsBackToStreaming() { + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + resolver.resolveMode(request("POST", + SmartDispatchModeResolver.DEFAULT_MAX_DIRECT_BYTES + 1))); + } + + @Test + void oversizedUnsafeIdempotentRequestsFallBackToStreaming() { + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + resolver.resolveMode(request("PUT", + SmartDispatchModeResolver.DEFAULT_MAX_SYNC_BYTES + 1))); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + resolver.resolveMode(request("DELETE", + SmartDispatchModeResolver.DEFAULT_MAX_SYNC_BYTES + 1))); + } + + @Test + void oversizedRequestFallsBackToStreaming() { + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + resolver.resolveMode(request("GET", + SmartDispatchModeResolver.DEFAULT_MAX_DIRECT_BYTES + 1))); + } + + @Test + void customCapIsHonoured() { + SmartDispatchModeResolver tight = new SmartDispatchModeResolver(64); + assertEquals(DispatchMode.DIRECT, tight.resolveMode(request("GET", 64))); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + tight.resolveMode(request("GET", 65))); + } + + @Test + void negativeCapRejected() { + assertThrows(IllegalArgumentException.class, + () -> new SmartDispatchModeResolver(-1)); + } + + @Test + void mediumSafeRequestUsesDirectAfterGateRaise() { + // Above the old 256 KiB gate, within the raised 1 MiB DIRECT gate: + // with the 2 MiB retain cap, DIRECT beats streaming through 1 MiB. + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("GET", 1024 * 1024))); + } + + @Test + void resolveModeDoesNotMutateRequestAttributesOnSafeHotPath() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + + assertEquals(DispatchMode.DIRECT, resolver.resolveMode(req, false)); + + assertNull(req.getAttribute( + "com.devfive.vespera.bridge.SmartDispatchModeResolver.currentThreadIsVirtual")); + } + + @Test + void mediumNonIdempotentStaysOnSyncGateThenStreams() { + // SYNC gate stays at 256 KiB (independent of the DIRECT gate): at the + // gate POST/PATCH use SYNC, above it they stream — SYNC's full on-heap + // response buffering loses to streaming for larger bodies. + assertEquals(DispatchMode.SYNC, + resolver.resolveMode( + request("POST", SmartDispatchModeResolver.DEFAULT_MAX_SYNC_BYTES))); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + resolver.resolveMode(request("POST", 512 * 1024))); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + resolver.resolveMode(request("PATCH", 512 * 1024))); + } + + @Test + void independentDirectAndSyncGatesAreHonoured() { + // DIRECT gate 600 KiB (safe), SYNC gate 100 KiB (unsafe). + SmartDispatchModeResolver split = + new SmartDispatchModeResolver(600 * 1024, 100 * 1024); + assertEquals(DispatchMode.DIRECT, split.resolveMode(request("GET", 600 * 1024))); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + split.resolveMode(request("GET", 600 * 1024 + 1))); + assertEquals(DispatchMode.SYNC, split.resolveMode(request("PUT", 100 * 1024))); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + split.resolveMode(request("PUT", 100 * 1024 + 1))); + assertEquals(DispatchMode.SYNC, split.resolveMode(request("POST", 100 * 1024))); + assertEquals(DispatchMode.BIDIRECTIONAL_STREAMING, + split.resolveMode(request("POST", 100 * 1024 + 1))); + } + + @Test + void negativeSyncCapRejected() { + assertThrows(IllegalArgumentException.class, + () -> new SmartDispatchModeResolver(256 * 1024, -1)); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfigurationTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfigurationTest.java new file mode 100644 index 00000000..4dd7284c --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfigurationTest.java @@ -0,0 +1,233 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** + * Autoconfigure branch tests for the dispatch-mode policy beans. + * + *

    The contract under test (0.2.0 default flip): the autoconfigured + * default is {@link SmartDispatchModeResolver} (DIRECT/SYNC fast paths + * for small bounded requests, measured 2.2–3.2 µs vs 24.1 µs); + * {@code vespera.bridge.dispatch-mode=bidirectional-streaming} opts out + * to {@link BidirectionalStreamingDispatchModeResolver} (pre-0.2.0 + * behavior); {@code vespera.bridge.dispatch-mode=smart} explicitly + * pins the new default; a user-supplied bean always wins over all of + * the above via {@code @ConditionalOnMissingBean}. + */ +class VesperaBridgeAutoConfigurationTest { + + // withConfiguration (not withUserConfiguration): autoconfigurations + // must be evaluated AFTER user configs so @ConditionalOnMissingBean + // sees user-supplied beans — same ordering as a real Boot app. + private final WebApplicationContextRunner runner = + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(VesperaBridgeAutoConfiguration.class)); + + @Test + void defaultResolverIsSmart() { + runner.run( + ctx -> + assertInstanceOf( + SmartDispatchModeResolver.class, + ctx.getBean(DispatchModeResolver.class), + "0.2.0: autoconfigured default flipped to SmartDispatchModeResolver")); + } + + @Test + void smartPropertyExplicitlyPinsSmartResolver() { + runner.withPropertyValues("vespera.bridge.dispatch-mode=smart") + .run( + ctx -> + assertInstanceOf( + SmartDispatchModeResolver.class, + ctx.getBean(DispatchModeResolver.class), + "explicit dispatch-mode=smart must keep the new default")); + } + + @Test + void bidirectionalStreamingPropertyOptsOutToStreamingResolver() { + runner.withPropertyValues("vespera.bridge.dispatch-mode=bidirectional-streaming") + .run( + ctx -> + assertInstanceOf( + BidirectionalStreamingDispatchModeResolver.class, + ctx.getBean(DispatchModeResolver.class), + "dispatch-mode=bidirectional-streaming must restore the" + + " pre-0.2.0 default")); + } + + @Test + void userBeanWinsOverDefault() { + runner.withUserConfiguration(CustomResolverConfig.class) + .run( + ctx -> + assertInstanceOf( + CustomResolver.class, + ctx.getBean(DispatchModeResolver.class), + "@ConditionalOnMissingBean: user bean must win over the" + + " autoconfigured smart default")); + } + + @Test + void userBeanWinsOverBidirectionalStreamingProperty() { + runner.withPropertyValues("vespera.bridge.dispatch-mode=bidirectional-streaming") + .withUserConfiguration(CustomResolverConfig.class) + .run( + ctx -> + assertInstanceOf( + CustomResolver.class, + ctx.getBean(DispatchModeResolver.class), + "@ConditionalOnMissingBean: user bean must win even when" + + " the opt-out property is set")); + } + + @Test + void controllerDisabledPropertyStillWorks() { + runner.withPropertyValues("vespera.bridge.controller-enabled=false") + .run(ctx -> assertTrue(ctx.getBeansOfType(VesperaProxyController.class).isEmpty())); + } + + @Test + void directRetryOnOverflowDefaultsToTrueAndCanBeDisabled() { + runner.run(ctx -> assertTrue(ctx.getBean(VesperaBridgeProperties.class).isDirectRetryOnOverflow())); + runner.withPropertyValues("vespera.bridge.direct-retry-on-overflow=false") + .run(ctx -> assertFalse( + ctx.getBean(VesperaBridgeProperties.class).isDirectRetryOnOverflow())); + } + + @Test + void perRequestThreadLocalCleanupFilterIsOptInAndClearsInFinally() { + runner.run(ctx -> assertTrue(ctx.getBeansOfType(FilterRegistrationBean.class).isEmpty())); + runner.withPropertyValues("vespera.bridge.clear-threadlocals-after-request=true") + .run(ctx -> { + FilterRegistrationBean bean = ctx.getBean(FilterRegistrationBean.class); + VesperaDirectBufferPool.directPoolForTest(); + assertTrue(VesperaDirectBufferPool.directPoolPresentForTest()); + + bean.getFilter().doFilter( + new MockHttpServletRequest("GET", "/x"), + new MockHttpServletResponse(), + new MockFilterChain()); + + assertFalse(VesperaDirectBufferPool.directPoolPresentForTest()); + assertTrue(ctx.getBean(VesperaBridgeProperties.class) + .isClearThreadlocalsAfterRequest()); + }); + } + + @Test + void maxBufferedRequestBytesDefaultsToConservativeCapAndCanBeConfigured() { + runner.run(ctx -> assertEquals(VesperaProxyController.DEFAULT_MAX_BUFFERED_REQUEST_BYTES, + ctx.getBean(VesperaBridgeProperties.class).getMaxBufferedRequestBytes())); + runner.withPropertyValues("vespera.bridge.max-buffered-request-bytes=12345") + .run(ctx -> assertEquals(12345L, + ctx.getBean(VesperaBridgeProperties.class).getMaxBufferedRequestBytes())); + } + + @Test + void asyncResponseExecutorBeanIsReplaceableByName() { + runner.withUserConfiguration(CustomExecutorConfig.class) + .run(ctx -> assertSame( + CustomExecutorConfig.EXECUTOR, + ctx.getBean("vesperaBridgeAsyncResponseExecutor", Executor.class))); + } + + @Test + void defaultAsyncResponseExecutorUsesNamedDaemonThread() { + runner.run(ctx -> { + Executor executor = ctx.getBean("vesperaBridgeAsyncResponseExecutor", Executor.class); + CountDownLatch done = new CountDownLatch(1); + String[] name = {null}; + boolean[] daemon = {false}; + + executor.execute(() -> { + Thread current = Thread.currentThread(); + name[0] = current.getName(); + daemon[0] = current.isDaemon(); + done.countDown(); + }); + + try { + assertTrue(done.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + assertTrue(name[0].startsWith("vespera-bridge-async-response-"), name[0]); + assertTrue(daemon[0]); + }); + } + + @Test + void defaultAsyncResponseExecutorUsesBoundedQueueWithAbortBackpressure() { + // The executor's tasks are submitted from the thread that completes the + // native dispatch future — a Rust Tokio worker. CallerRunsPolicy would + // run the heavy wire-response build on that worker under saturation, + // stealing native dispatch capacity; AbortPolicy rejects instead and the + // proxy maps the rejection to a 503 (see VesperaProxyController + // .asyncFailureToResponse). The bounded queue still absorbs bursts. + runner.run(ctx -> { + ThreadPoolExecutor executor = ctx.getBean( + "vesperaBridgeAsyncResponseExecutor", ThreadPoolExecutor.class); + + assertTrue(executor.getQueue().remainingCapacity() < Integer.MAX_VALUE); + assertInstanceOf(ThreadPoolExecutor.AbortPolicy.class, executor.getRejectedExecutionHandler()); + }); + } + + @Test + void unknownDispatchModeFailsFast() { + // A production typo must fail at bean creation instead of silently + // enabling the smart DIRECT/SYNC policy. + runner.withPropertyValues("vespera.bridge.dispatch-mode=not-a-real-mode") + .run(ctx -> { + assertTrue(ctx.getStartupFailure() instanceof org.springframework.beans.factory.BeanCreationException); + assertTrue(ctx.getStartupFailure().getMessage().contains("not-a-real-mode")); + assertTrue(ctx.getStartupFailure().getMessage().contains("smart")); + assertTrue(ctx.getStartupFailure().getMessage().contains("bidirectional-streaming")); + }); + } + + static final class CustomResolver implements DispatchModeResolver { + @Override + public DispatchMode resolveMode(jakarta.servlet.http.HttpServletRequest request) { + return DispatchMode.SYNC; + } + } + + @Configuration(proxyBeanMethods = false) + static class CustomResolverConfig { + @Bean + DispatchModeResolver customResolver() { + return new CustomResolver(); + } + } + + @Configuration(proxyBeanMethods = false) + static class CustomExecutorConfig { + static final Executor EXECUTOR = Runnable::run; + + @Bean("vesperaBridgeAsyncResponseExecutor") + Executor vesperaBridgeAsyncResponseExecutor() { + return EXECUTOR; + } + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaBridgeInitTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaBridgeInitTest.java new file mode 100644 index 00000000..9df5754d --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaBridgeInitTest.java @@ -0,0 +1,46 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; + +/** + * Q6: {@link VesperaBridge#init(String)} called a second time with a + * different native library name must fail loudly instead of silently + * keeping the first library and dispatching to the wrong Rust app; the same + * name stays a no-op. + * + *

    The mismatch guard runs before any native {@code loadLibrary}, so + * this test simulates the "already initialised" state via reflection and needs + * no cdylib. It restores the static state afterwards so it cannot leak into + * other tests. + */ +class VesperaBridgeInitTest { + + @Test + void reInitWithDifferentLibraryThrowsAndSameNameIsNoOp() throws Exception { + Field loadedField = VesperaBridge.class.getDeclaredField("loaded"); + Field nameField = VesperaBridge.class.getDeclaredField("loadedLibraryName"); + loadedField.setAccessible(true); + nameField.setAccessible(true); + boolean prevLoaded = loadedField.getBoolean(null); + Object prevName = nameField.get(null); + try { + loadedField.setBoolean(null, true); + nameField.set(null, "libA"); + + assertDoesNotThrow( + () -> VesperaBridge.init("libA"), + "re-init with the same library name must be a no-op"); + assertThrows( + IllegalStateException.class, + () -> VesperaBridge.init("libB"), + "re-init with a different library name must throw"); + } finally { + loadedField.setBoolean(null, prevLoaded); + nameField.set(null, prevName); + } + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaDirectWrapperTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaDirectWrapperTest.java new file mode 100644 index 00000000..88e24e56 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaDirectWrapperTest.java @@ -0,0 +1,144 @@ +package com.devfive.vespera.bridge; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure-Java tests for the {@code dispatchDirect} wrapper's pre-JNI + * validation — no native library is loaded. Every rejection asserted + * here MUST happen before the native method is invoked; if validation + * regressed and the call crossed JNI, these tests would fail with + * {@link UnsatisfiedLinkError} instead of the expected exception. + */ +class VesperaDirectWrapperTest { + + private static final ByteBuffer DIRECT = ByteBuffer.allocateDirect(64); + private static final ByteBuffer HEAP = ByteBuffer.allocate(64); + + @Test + void heapInBufferRejectedBeforeJni() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.dispatchDirect(HEAP, 4, DIRECT)); + assertTrue(e.getMessage().contains("direct"), e.getMessage()); + } + + @Test + void heapOutBufferRejectedBeforeJni() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.dispatchDirect(DIRECT, 4, HEAP)); + assertTrue(e.getMessage().contains("direct"), e.getMessage()); + } + + @Test + void nullBuffersRejected() { + assertThrows(NullPointerException.class, + () -> VesperaBridge.dispatchDirect(null, 0, DIRECT)); + assertThrows(NullPointerException.class, + () -> VesperaBridge.dispatchDirect(DIRECT, 0, null)); + } + + @Test + void negativeInLenRejected() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.dispatchDirect(DIRECT, -1, DIRECT)); + assertTrue(e.getMessage().contains("inLen"), e.getMessage()); + } + + @Test + void inLenBeyondCapacityRejected() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.dispatchDirect(DIRECT, DIRECT.capacity() + 1, DIRECT)); + assertTrue(e.getMessage().contains("inLen"), e.getMessage()); + } + + @Test + void readOnlyOutBufferRejectedBeforeJni() { + // SEC-2: a read-only direct out buffer would crash the native + // write; the wrapper must reject it before crossing JNI. + ByteBuffer readOnlyOut = ByteBuffer.allocateDirect(64).asReadOnlyBuffer(); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.dispatchDirect(DIRECT, 4, readOnlyOut)); + assertTrue(e.getMessage().contains("writable"), e.getMessage()); + } + + @Test + void readOnlyInBufferRejectedBeforeJni() { + ByteBuffer readOnlyIn = ByteBuffer.allocateDirect(64).asReadOnlyBuffer(); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaBridge.dispatchDirect(readOnlyIn, 4, DIRECT)); + assertTrue(e.getMessage().contains("writable"), e.getMessage()); + } + + @Test + void bufferTooSmallExceptionCarriesRequiredSize() { + VesperaBridge.BufferTooSmallException e = + new VesperaBridge.BufferTooSmallException(123_456); + assertEquals(123_456, e.requiredSize()); + assertTrue(e.getMessage().contains("123456"), e.getMessage()); + assertTrue(e.getMessage().contains("re-run"), e.getMessage()); + } + + @Test + void integerMinValueDirectOverflowHasActionableMessage() { + IllegalStateException e = VesperaDirectBufferPool.responseExceedsTwoGiBException(); + + assertTrue(e.getMessage().contains("exceeds 2 GiB"), e.getMessage()); + assertTrue(e.getMessage().contains("streaming dispatch"), e.getMessage()); + } + + @Test + void directPoolKeepsBaselineBuffersAfterIdleStreak() { + VesperaDirectBufferPool.clearCurrentThreadBuffers(); + ByteBuffer[] pool = VesperaDirectBufferPool.directPoolForTest(); + + for (int i = 0; i < 8; i++) { + VesperaDirectBufferPool.recordDirectPoolUseForTest(pool, 1, 1); + } + + assertTrue(VesperaDirectBufferPool.directPoolPresentForTest()); + assertEquals(64 * 1024, pool[0].capacity()); + assertEquals(64 * 1024, pool[1].capacity()); + } + + @Test + void directPoolShrinksGrownBuffersAfterIdleStreak() { + VesperaDirectBufferPool.clearCurrentThreadBuffers(); + ByteBuffer[] pool = VesperaDirectBufferPool.directPoolForTest(); + pool[0] = ByteBuffer.allocateDirect(3 * 1024 * 1024); + pool[1] = ByteBuffer.allocateDirect(3 * 1024 * 1024); + + for (int i = 0; i < 8; i++) { + VesperaDirectBufferPool.recordDirectPoolUseForTest(pool, 1, 1); + } + + assertTrue(VesperaDirectBufferPool.directPoolPresentForTest()); + assertEquals(64 * 1024, pool[0].capacity()); + assertEquals(64 * 1024, pool[1].capacity()); + } + + @Test + void directPoolRetainsMediumResponseUnderRetainCapAfterIdleStreak() { + VesperaDirectBufferPool.clearCurrentThreadBuffers(); + ByteBuffer[] pool = VesperaDirectBufferPool.directPoolForTest(); + pool[1] = ByteBuffer.allocateDirect(1024 * 1024); + + for (int i = 0; i < 9; i++) { + VesperaDirectBufferPool.recordDirectPoolUseForTest(pool, 1, 1024 * 1024); + } + + assertTrue(VesperaDirectBufferPool.directPoolPresentForTest()); + assertEquals(64 * 1024, pool[0].capacity()); + assertEquals(1024 * 1024, pool[1].capacity()); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java index 9a6569bf..e2829a07 100644 --- a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java @@ -82,7 +82,11 @@ void encodeRequest_includes_query_and_headers_when_present() throws Exception { byte[] headerJson = new byte[headerLen]; System.arraycopy(wire, 4, headerJson, 0, headerLen); JsonNode h = MAPPER.readTree(headerJson); - assertEquals("page=1", h.path("query").asText()); + // The query is folded into the `path` field (the full request target) + // — there is no separate `query` field — so the Rust dispatch side + // borrows it for `Uri` parsing instead of re-joining `path+'?'+query`. + assertEquals("/users?page=1", h.path("path").asText()); + assertEquals("", h.path("query").asText()); assertEquals("application/json", h.path("headers").path("content-type").asText()); assertEquals("abc-123", h.path("headers").path("x-trace-id").asText()); @@ -92,6 +96,145 @@ void encodeRequest_includes_query_and_headers_when_present() throws Exception { assertEquals("{\"x\":1}", new String(body, StandardCharsets.UTF_8)); } + /** + * Canonical request header JSON — the shared cross-language + * golden that locks the Java encoder against the Rust + * {@code serde_json}/hand-rolled parser. The Rust counterpart + * ({@code crates/vespera_inprocess/tests/wire_contract.rs :: + * cross_language_request_golden_routes}) dispatches the byte-identical + * frame and asserts it routes, so the two independent hand-rolled wire + * implementations cannot silently drift: a change to either side's field + * order / escaping / structure breaks its own golden assertion. + * + *

    Field order is fixed by {@code VesperaWireCodec.fillHeaderJson}: + * {@code v, method, path, headers?, app?}. The query string is folded into + * {@code path} as the full request target ({@code /users?page=1}) — there + * is no separate {@code query} field — so the Rust dispatch side borrows the + * target directly instead of re-joining it (see {@code wire_contract.rs}). + */ + static final String CANONICAL_REQUEST_HEADER_JSON = + "{\"v\":1,\"method\":\"POST\",\"path\":\"/users?page=1\"," + + "\"headers\":{\"content-type\":\"application/json\"}}"; + + /** Canonical request body paired with {@link #CANONICAL_REQUEST_HEADER_JSON}. */ + static final byte[] CANONICAL_REQUEST_BODY = "{\"x\":1}".getBytes(StandardCharsets.UTF_8); + + @Test + void crossLanguage_request_golden_bytes_are_locked() { + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + + byte[] wire = VesperaBridge.encodeRequest( + "POST", "/users", "page=1", headers, CANONICAL_REQUEST_BODY); + + byte[] expectedHeader = + CANONICAL_REQUEST_HEADER_JSON.getBytes(StandardCharsets.UTF_8); + + // Length prefix == exact canonical header byte length (big-endian). + int headerLen = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN).getInt(); + assertEquals(expectedHeader.length, headerLen, + "encoded header length drifted from the cross-language golden"); + + // Header JSON bytes are byte-identical to the shared golden (locks the + // Java encoder's field order + structure the Rust parser is asserted + // to accept verbatim in wire_contract.rs). + byte[] headerJson = new byte[headerLen]; + System.arraycopy(wire, 4, headerJson, 0, headerLen); + assertArrayEquals(expectedHeader, headerJson, + "request header JSON drifted from the cross-language golden — WIRE FORMAT BREAK"); + + // Body follows the header verbatim. + byte[] body = new byte[wire.length - 4 - headerLen]; + System.arraycopy(wire, 4 + headerLen, body, 0, body.length); + assertArrayEquals(CANONICAL_REQUEST_BODY, body, "request body must follow header verbatim"); + } + + @Test + void encodeRequestRejectsNullMethodAndPathWithFieldName() { + NullPointerException method = assertThrows( + NullPointerException.class, + () -> VesperaBridge.encodeRequest(null, "/x", null, Map.of(), new byte[0])); + NullPointerException path = assertThrows( + NullPointerException.class, + () -> VesperaBridge.encodeRequest("GET", null, null, Map.of(), new byte[0])); + + assertEquals("method", method.getMessage()); + assertEquals("path", path.getMessage()); + } + + @Test + void encodeRequestRejectsNullHeaderKeyAndValueWithFieldName() { + Map nullKey = new HashMap<>(); + nullKey.put(null, "value"); + Map nullValue = new HashMap<>(); + nullValue.put("x", null); + + NullPointerException key = assertThrows( + NullPointerException.class, + () -> VesperaBridge.encodeRequest("GET", "/x", null, nullKey, new byte[0])); + NullPointerException value = assertThrows( + NullPointerException.class, + () -> VesperaBridge.encodeRequest("GET", "/x", null, nullValue, new byte[0])); + + assertEquals("header key", key.getMessage()); + assertEquals("header value", value.getMessage()); + } + + @Test + void oversizedHeaderBufferShrinksWhenHeaderSourceThrows() { + VesperaWireCodec.clearCurrentThreadBuffers(); + String huge = "x".repeat(40 * 1024); + + assertThrows(IllegalStateException.class, () -> VesperaBridge.encodeRequest( + "GET", + "/x", + null, + sink -> { + sink.put("x-big", huge); + throw new IllegalStateException("boom"); + }, + new byte[0])); + + assertEquals(256, VesperaWireCodec.currentHeaderBufferCapacityForTest()); + } + + @Test + void directPoolShrinksOversizedHeaderBufferWhenDispatchThrows() { + VesperaWireCodec.clearCurrentThreadBuffers(); + String huge = "x".repeat(40 * 1024); + + assertThrows(UnsatisfiedLinkError.class, () -> VesperaDirectBufferPool.dispatchDirectPooled( + null, + "GET", + "/x", + null, + sink -> sink.put("x-big", huge), + new byte[0], + false, + true)); + + assertEquals(256, VesperaWireCodec.currentHeaderBufferCapacityForTest()); + } + + @Test + void directPoolThrowPolicyRejectsHeapFallbackBeforeNativeDispatch() { + String previous = System.getProperty("vespera.direct.oversize-policy"); + System.setProperty("vespera.direct.oversize-policy", "throw"); + try { + VesperaBridge.BufferTooSmallException ex = assertThrows( + VesperaBridge.BufferTooSmallException.class, + () -> VesperaDirectBufferPool.dispatchDirectPooled(new byte[8], false, true)); + assertTrue(ex.getMessage().contains("vespera.direct.oversize-policy=throw")); + assertEquals(8, ex.requiredSize()); + } finally { + if (previous == null) { + System.clearProperty("vespera.direct.oversize-policy"); + } else { + System.setProperty("vespera.direct.oversize-policy", previous); + } + } + } + /** Build a synthetic wire response (mimics what Rust would emit). */ private static byte[] buildWireResponse(int status, String contentType, byte[] body) throws Exception { return buildWireResponseWithExtras(status, contentType, body, null); @@ -133,7 +276,11 @@ void decodeResponse_parses_status_headers_and_body() throws Exception { assertEquals("text/plain; charset=utf-8", decoded.headers().get("content-type")); assertEquals("0.1.51", decoded.metadata().get("version")); assertEquals("I'm a teapot", - new String(decoded.body(), StandardCharsets.UTF_8)); + new String(decoded.bodyBytes(), StandardCharsets.UTF_8)); + assertTrue(decoded.body().isReadOnly(), "body view must be read-only"); + assertEquals(0, decoded.body().position(), "body view position must start at 0"); + assertEquals("I'm a teapot".length(), decoded.body().limit(), + "body view limit must equal body length"); } @Test @@ -167,7 +314,7 @@ void roundtrip_preserves_binary_body_byte_for_byte() throws Exception { DecodedResponse decoded = VesperaBridge.decodeResponse(wire); assertEquals(200, decoded.status()); - assertArrayEquals(payload, decoded.body(), + assertArrayEquals(payload, decoded.bodyBytes(), "binary body must round-trip byte-for-byte"); } @@ -202,7 +349,7 @@ void decodeResponse_hoists_validation_errors_when_present() throws Exception { // Body still preserved alongside the hoisted header field: assertArrayEquals( "{\"errors\":[...]}".getBytes(StandardCharsets.UTF_8), - decoded.body(), + decoded.bodyBytes(), "body must be preserved verbatim even when errors are hoisted"); } @@ -233,6 +380,177 @@ void encode_decode_full_request_roundtrip_via_synthetic_response() throws Except byte[] respWire = buildWireResponse(200, "text/plain", echoedBody); DecodedResponse decoded = VesperaBridge.decodeResponse(respWire); - assertArrayEquals(reqBody, decoded.body()); + assertArrayEquals(reqBody, decoded.bodyBytes()); + } + + /** Build a wire response whose headers map is supplied verbatim (so a + * value may be a JSON array → multi-valued header). */ + private static byte[] buildWireResponseWithHeaders( + int status, Map headers, byte[] body) throws Exception { + Map headerMap = new LinkedHashMap<>(); + headerMap.put("v", 1); + headerMap.put("status", status); + headerMap.put("headers", headers); + Map metadata = new LinkedHashMap<>(); + metadata.put("version", "0.1.51"); + headerMap.put("metadata", metadata); + + byte[] headerJson = MAPPER.writeValueAsBytes(headerMap); + ByteBuffer buf = ByteBuffer.allocate(4 + headerJson.length + body.length) + .order(ByteOrder.BIG_ENDIAN); + buf.putInt(headerJson.length); + buf.put(headerJson); + buf.put(body); + return buf.array(); + } + + @Test + void decodeResponse_parses_multi_value_header_as_list() throws Exception { + // Repeated header names (e.g. set-cookie) arrive as a JSON array on + // the wire and must decode to a List, not a String. + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "text/plain"); + headers.put("set-cookie", List.of("a=1; Path=/", "b=2; HttpOnly")); + + byte[] wire = buildWireResponseWithHeaders( + 200, headers, "ok".getBytes(StandardCharsets.UTF_8)); + DecodedResponse decoded = VesperaBridge.decodeResponse(wire); + + assertEquals(200, decoded.status()); + assertEquals("text/plain", decoded.headers().get("content-type")); + Object setCookie = decoded.headers().get("set-cookie"); + assertTrue(setCookie instanceof List, "multi-valued header must decode to a List"); + assertEquals(List.of("a=1; Path=/", "b=2; HttpOnly"), setCookie); + } + + @Test + void decodeResponse_publicCollectionsAreImmutableCopies() throws Exception { + Map headers = new LinkedHashMap<>(); + headers.put("set-cookie", List.of("a=1", "b=2")); + List> errors = new ArrayList<>(); + Map error = new LinkedHashMap<>(); + error.put("path", "name"); + errors.add(error); + + DecodedResponse decoded = VesperaBridge.decodeResponse(buildWireResponseWithExtras( + 422, "application/json", new byte[0], errors)); + DecodedResponse multiHeader = VesperaBridge.decodeResponse( + buildWireResponseWithHeaders(200, headers, new byte[0])); + + assertThrows(UnsupportedOperationException.class, + () -> decoded.metadata().put("x", "y")); + assertThrows(UnsupportedOperationException.class, + () -> decoded.validationErrors().add(Map.of())); + assertThrows(UnsupportedOperationException.class, + () -> decoded.validationErrors().get(0).put("message", "changed")); + assertThrows(UnsupportedOperationException.class, + () -> multiHeader.headers().put("x", "y")); + @SuppressWarnings("unchecked") + List setCookie = (List) multiHeader.headers().get("set-cookie"); + assertThrows(UnsupportedOperationException.class, + () -> setCookie.add("c=3")); + } + + @Test + void decodeResponse_handles_escaped_and_non_ascii_header_values() throws Exception { + // The header value carries a JSON-escaped quote and multi-byte UTF-8, + // exercising the reader's escape + UTF-8 decode path (not the plain + // ASCII fast path). + Map headers = new LinkedHashMap<>(); + headers.put("x-note", "say \"hi\" 한글"); + + byte[] wire = buildWireResponseWithHeaders(200, headers, new byte[0]); + DecodedResponse decoded = VesperaBridge.decodeResponse(wire); + + assertEquals("say \"hi\" 한글", decoded.headers().get("x-note")); + } + + @Test + void encodeRequest_escapes_special_and_unicode_in_values() throws Exception { + // Lock the byte-direct encoder's escaping: quote, backslash, tab and + // newline (C0 short escapes), 3-byte UTF-8 (한글), and a 4-byte + // supplementary char via surrogate pair (😀, U+1F600) — in path, + // query, and header values. The produced bytes must be valid JSON + // that parses back to the exact originals (the contract the Rust + // serde_json side relies on). + Map headers = new LinkedHashMap<>(); + headers.put("x-quote", "a\"b\\c\td\ne"); + headers.put("x-unicode", "한글-😀"); + + byte[] wire = VesperaBridge.encodeRequest( + "POST", "/p\"a\\th/한글", "q=\"x\"&한=글", headers, new byte[0]); + + int headerLen = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN).getInt(); + byte[] headerJson = new byte[headerLen]; + System.arraycopy(wire, 4, headerJson, 0, headerLen); + JsonNode h = MAPPER.readTree(headerJson); + + assertEquals("POST", h.path("method").asText()); + // path and query are each JSON-escaped, then joined by a literal '?' + // into the single `path` request target — no separate `query` field. + // Independently re-parsed by Jackson, so a mis-escape here fails loudly. + assertEquals("/p\"a\\th/한글?q=\"x\"&한=글", h.path("path").asText()); + assertEquals("", h.path("query").asText()); + assertEquals("a\"b\\c\td\ne", h.path("headers").path("x-quote").asText()); + assertEquals("한글-😀", h.path("headers").path("x-unicode").asText()); + } + + @Test + void decodeResponse_canonical_and_custom_header_keys_both_parse() throws Exception { + // content-type is a canonical (interned, allocation-free) key; + // x-custom-trace is not and must still parse via the readString + // fallback — both values, and the canonical metadata "version" key, + // round-trip exactly. Guards the peek/consume cursor bookkeeping. + Map headers = new LinkedHashMap<>(); + headers.put("content-type", "application/json"); + headers.put("x-custom-trace", "abc-123"); + + byte[] wire = buildWireResponseWithHeaders( + 200, headers, "ok".getBytes(StandardCharsets.UTF_8)); + DecodedResponse decoded = VesperaBridge.decodeResponse(wire); + + assertEquals("application/json", decoded.headers().get("content-type")); + assertEquals("abc-123", decoded.headers().get("x-custom-trace")); + assertEquals("0.1.51", decoded.metadata().get("version")); + } + + @Test + void decodeResponse_multi_entry_metadata_parses_all_keys() throws Exception { + // Metadata with 2 keys (the rare path): canonical "version" plus a + // custom "build" key. Both must round-trip — exercises the + // LinkedHashMap fallback in readStringMap (single-entry uses Map.of). + Map headerMap = new LinkedHashMap<>(); + headerMap.put("v", 1); + headerMap.put("status", 200); + headerMap.put("headers", new LinkedHashMap<>()); + Map metadata = new LinkedHashMap<>(); + metadata.put("version", "0.1.51"); + metadata.put("build", "deadbeef"); + headerMap.put("metadata", metadata); + + byte[] headerJson = MAPPER.writeValueAsBytes(headerMap); + ByteBuffer buf = ByteBuffer.allocate(4 + headerJson.length).order(ByteOrder.BIG_ENDIAN); + buf.putInt(headerJson.length); + buf.put(headerJson); + + DecodedResponse decoded = VesperaBridge.decodeResponse(buf.array()); + assertEquals(2, decoded.metadata().size()); + assertEquals("0.1.51", decoded.metadata().get("version")); + assertEquals("deadbeef", decoded.metadata().get("build")); + } + + @Test + void decodeResponse_empty_headers_yields_empty_map() throws Exception { + // Headers object present but empty -> readHeaderMap returns null -> + // decodeResponse substitutes the shared empty map. (Single-header + // responses take the Map.of path, covered by the status/headers/body + // test; 2+ headers take the LinkedHashMap path, covered by the + // multi-value header test.) + byte[] wire = buildWireResponseWithHeaders( + 200, new LinkedHashMap<>(), "ok".getBytes(StandardCharsets.UTF_8)); + DecodedResponse decoded = VesperaBridge.decodeResponse(wire); + + assertEquals(200, decoded.status()); + assertTrue(decoded.headers().isEmpty(), "empty headers object yields an empty map"); } } diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/WireHeaderReaderTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/WireHeaderReaderTest.java new file mode 100644 index 00000000..c56815d0 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/WireHeaderReaderTest.java @@ -0,0 +1,260 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Correctness gate for the zero-copy DIRECT-path header reader. */ +class WireHeaderReaderTest { + + private record Captured(int status, List headers) {} + + /** + * Parse {@code headerJson} through BOTH a direct buffer (the DIRECT + * dispatch path, no backing array) and a heap buffer (the SYNC / + * streaming / async {@code ByteBuffer.wrap} paths, which hit + * {@code readString}'s backing-array fast path), asserting the two + * agree. Returns the (identical) result. + */ + private static Captured run(String headerJson) { + Captured direct = runWith(headerJson, true); + Captured heap = runWith(headerJson, false); + assertEquals(direct.status(), heap.status(), "direct vs heap status mismatch"); + assertEquals(direct.headers(), heap.headers(), "direct vs heap headers mismatch"); + return direct; + } + + private static Captured runWith(String headerJson, boolean direct) { + byte[] hb = headerJson.getBytes(StandardCharsets.UTF_8); + return runWith(hb, direct); + } + + private static Captured runWith(byte[] hb, boolean direct) { + ByteBuffer buf = + direct ? ByteBuffer.allocateDirect(4 + hb.length) : ByteBuffer.allocate(4 + hb.length); + buf.putInt(hb.length); + buf.put(hb); + int[] status = {-1}; + List headers = new ArrayList<>(); + WireHeaderReader.apply( + buf, 4, hb.length, s -> status[0] = s, (k, v) -> headers.add(k + "=" + v)); + return new Captured(status[0], headers); + } + + private static void assertRejected(byte[] headerBytes) { + assertThrows(IllegalArgumentException.class, () -> runWith(headerBytes, true)); + assertThrows(IllegalArgumentException.class, () -> runWith(headerBytes, false)); + } + + private static void assertDecodeRejected(String headerJson) { + byte[] hb = headerJson.getBytes(StandardCharsets.UTF_8); + ByteBuffer buf = ByteBuffer.allocate(4 + hb.length); + buf.putInt(hb.length); + buf.put(hb); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> WireHeaderReader.decode(buf, 4, hb.length)); + assertEquals("wire header JSON: expected object at offset 4", e.getMessage()); + } + + @Test + void parsesStatusAndSingleHeader() { + Captured c = + run( + "{\"v\":1,\"status\":200,\"headers\":{\"content-type\":\"text/plain\"}," + + "\"metadata\":{\"version\":\"0.1.0\"}}"); + assertEquals(200, c.status()); + assertEquals(List.of("content-type=text/plain"), c.headers()); + } + + @Test + void parsesMultiValuedHeaderArray() { + Captured c = + run( + "{\"v\":1,\"status\":201,\"headers\":{\"set-cookie\":[\"a=1\",\"b=2\"]," + + "\"x\":\"y\"}}"); + assertEquals(201, c.status()); + assertEquals(List.of("set-cookie=a=1", "set-cookie=b=2", "x=y"), c.headers()); + } + + @Test + void handlesEscapesAndUtf8InValues() { + Captured c = + run( + "{\"status\":200,\"headers\":{\"x-q\":\"a\\\"b\\\\c\\n\",\"x-u\":\"caf\u00e9\"," + + "\"x-emoji\":\"\uD83D\uDE80\"}}"); + assertEquals(200, c.status()); + assertEquals(List.of("x-q=a\"b\\c\n", "x-u=caf\u00e9", "x-emoji=\uD83D\uDE80"), c.headers()); + } + + @Test + void handlesEscapedUnicodeSurrogatePairInValues() { + Captured c = run("{\"status\":200,\"headers\":{\"x-emoji\":\"\\uD83D\\uDE00\"}}"); + + assertEquals(200, c.status()); + assertEquals(List.of("x-emoji=\uD83D\uDE00"), c.headers()); + } + + @Test + void rejectsLoneEscapedUnicodeSurrogates() { + assertRejected("{\"status\":200,\"headers\":{\"x\":\"\\uD800\"}}".getBytes(StandardCharsets.UTF_8)); + assertRejected("{\"status\":200,\"headers\":{\"x\":\"\\uDC00\"}}".getBytes(StandardCharsets.UTF_8)); + assertRejected("{\"status\":200,\"headers\":{\"x\":\"\\uD800\\u0041\"}}".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void rejectsStatusIntegerOverflow() { + assertRejected("{\"status\":2147483648}".getBytes(StandardCharsets.UTF_8)); + assertRejected("{\"status\":-2147483649}".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void rejectsDecimalOrExponentStatus() { + // `status` is a protocol INTEGER field; `200.9` / `2e2` are malformed + // native output and must be REJECTED, not silently truncated to the + // integer part. Unknown numeric fields stay permissive — see + // skipsUnknownLargeAndDecimalNumericFields. + assertRejected("{\"status\":200.9}".getBytes(StandardCharsets.UTF_8)); + assertRejected("{\"status\":2e2}".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void rejectsTrailingGarbageAfterRootObject() { + byte[] headerBytes = "{\"status\":200}junk".getBytes(StandardCharsets.UTF_8); + assertRejected(headerBytes); + + ByteBuffer buf = ByteBuffer.allocate(4 + headerBytes.length); + buf.putInt(headerBytes.length); + buf.put(headerBytes); + assertThrows(IllegalArgumentException.class, () -> WireHeaderReader.decode(buf, 4, headerBytes.length)); + } + + @Test + void rejectsStatusOutsideWireHttpRange() { + assertRejected("{\"status\":99}".getBytes(StandardCharsets.UTF_8)); + assertRejected("{\"status\":1000}".getBytes(StandardCharsets.UTF_8)); + assertRejected("{\"status\":-200}".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void rejectsMalformedUtf8ContinuationAndOverlongSequences() { + assertRejected(new byte[] { + '{', '"', 's', 't', 'a', 't', 'u', 's', '"', ':', '2', '0', '0', ',', + '"', 'h', 'e', 'a', 'd', 'e', 'r', 's', '"', ':', '{', '"', 'x', '"', ':', '"', + (byte) 0xC3, '(', '"', '}', '}' + }); + assertRejected(new byte[] { + '{', '"', 's', 't', 'a', 't', 'u', 's', '"', ':', '2', '0', '0', ',', + '"', 'h', 'e', 'a', 'd', 'e', 'r', 's', '"', ':', '{', '"', 'x', '"', ':', '"', + (byte) 0xC0, (byte) 0x80, '"', '}', '}' + }); + } + + @Test + void statusAbsentDefaultsTo500() { + Captured c = run("{\"v\":1,\"headers\":{\"a\":\"b\"}}"); + assertEquals(500, c.status()); + assertEquals(List.of("a=b"), c.headers()); + } + + @Test + void emptyHeadersAndEmptyMetadataDoNotCorruptParsing() { + // The exact shape (empty nested object before another field) that broke + // a prior stateful reader. + Captured c = run("{\"v\":1,\"status\":204,\"headers\":{},\"metadata\":{}}"); + assertEquals(204, c.status()); + assertEquals(List.of(), c.headers()); + } + + @Test + void skipsUnknownNestedAndArrayFields() { + Captured c = + run( + "{\"status\":422,\"validation_errors\":[{\"path\":\"a\",\"message\":\"m\"}]," + + "\"headers\":{\"content-type\":\"application/json\"}}"); + assertEquals(422, c.status()); + assertEquals(List.of("content-type=application/json"), c.headers()); + } + + @Test + void nonObjectHeaderIsSkipped() { + Captured c = run("{\"status\":200,\"headers\":null}"); + assertEquals(200, c.status()); + assertEquals(List.of(), c.headers()); + } + + @Test + void rejectsNonObjectRootHeader() { + byte[] headerBytes = "[]".getBytes(StandardCharsets.UTF_8); + assertRejected(headerBytes); + assertDecodeRejected("[]"); + } + + @Test + void rejectsDuplicateStatusRootKey() { + assertRejected("{\"status\":200,\"status\":201}".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void rejectsDuplicateHeadersRootKey() { + assertRejected( + ("{\"status\":200,\"headers\":{\"a\":\"b\"}," + + "\"headers\":{\"c\":\"d\"}}").getBytes(StandardCharsets.UTF_8)); + } + + @Test + void rejectsMalformedSkippedLiteral() { + assertRejected("{\"status\":200,\"unknown\":truth}".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void skipsUnknownLargeAndDecimalNumericFields() { + // Forward-compat: an UNKNOWN numeric field beyond int range, or a + // decimal / exponent, must be skipped as a raw token — NOT parsed + // and overflow-rejected like the known `status` field (which the + // prior readInt-based skip did, failing decode of an otherwise-valid + // header). The known `status` is still parsed normally. + Captured c = + run( + "{\"status\":200,\"ts\":1700000000000000000,\"ratio\":-3.14e2," + + "\"headers\":{\"content-type\":\"text/plain\"}}"); + assertEquals(200, c.status()); + assertEquals(List.of("content-type=text/plain"), c.headers()); + } + + /** + * P3: {@code apply()} now routes common header names through the shared + * canonical-key matcher (the same allocation-free path {@code + * decode()} uses), so the key String it hands back is the interned + * instance — not a freshly allocated one per request. Asserting identity + * ({@code assertSame}) against {@code decode()}'s key locks that in. + */ + @Test + void applyReusesCanonicalKeyInstances() { + String json = "{\"status\":200,\"headers\":{\"content-type\":\"x\"}}"; + byte[] hb = json.getBytes(StandardCharsets.UTF_8); + + ByteBuffer buf = ByteBuffer.allocate(4 + hb.length); + buf.putInt(hb.length); + buf.put(hb); + String[] applyKey = {null}; + WireHeaderReader.apply(buf, 4, hb.length, s -> {}, (k, v) -> applyKey[0] = k); + + ByteBuffer buf2 = ByteBuffer.allocate(4 + hb.length); + buf2.putInt(hb.length); + buf2.put(hb); + WireHeaderReader.Decoded decoded = WireHeaderReader.decode(buf2, 4, hb.length); + String decodeKey = decoded.headers.keySet().iterator().next(); + + assertSame( + decodeKey, + applyKey[0], + "apply() must hand back the same canonical key instance decode() uses"); + } +} diff --git a/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/WireTotalLengthTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/WireTotalLengthTest.java new file mode 100644 index 00000000..4b8dd7f7 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/WireTotalLengthTest.java @@ -0,0 +1,51 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * {@link VesperaWireCodec#wireTotalLength} int-overflow guard: a body near + * the 2 GiB Java array limit must fail loud rather than wrap the {@code + * 4 + headerLen + bodyLen} addition into a negative / small value that + * would corrupt capacity checks and array sizing downstream. + */ +class WireTotalLengthTest { + + @Test + void normalSizesAddUp() { + assertEquals(4, VesperaWireCodec.wireTotalLength(0, 0)); + assertEquals(114, VesperaWireCodec.wireTotalLength(10, 100)); + } + + @Test + void overflowThrowsInsteadOfWrapping() { + // 4 + 10 + Integer.MAX_VALUE overflows a plain `int` add to a + // negative value; the long-based guard must reject it explicitly. + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> VesperaWireCodec.wireTotalLength(10, Integer.MAX_VALUE)); + assertTrue( + e.getMessage().contains("2 GiB"), + "message should mention the 2 GiB limit: " + e.getMessage()); + } + + @Test + void exactlyAtIntMaxIsAccepted() { + // 4 + 0 + (Integer.MAX_VALUE - 4) == Integer.MAX_VALUE exactly — the + // largest representable wire request, must NOT throw. + assertEquals( + Integer.MAX_VALUE, + VesperaWireCodec.wireTotalLength(0, Integer.MAX_VALUE - 4)); + } + + @Test + void oneOverIntMaxThrows() { + // 4 + 1 + (Integer.MAX_VALUE - 4) == Integer.MAX_VALUE + 1 → reject. + assertThrows( + IllegalArgumentException.class, + () -> VesperaWireCodec.wireTotalLength(1, Integer.MAX_VALUE - 4)); + } +} diff --git a/package.json b/package.json index 6a841023..2b8facab 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "author": "devfive", "devDependencies": { "eslint-plugin-devup": "^2.0.19", - "oxlint": "^1.66.0", + "oxlint": "^1.69.0", "husky": "^9.1", "bun-test-env-dom": "^1.0", "@devup-ui/bun-plugin": "^1.0", @@ -23,7 +23,7 @@ "prelint:fix": "cargo clippy --fix --all-targets --all-features --allow-dirty && cargo clippy --fix --workspace --no-default-features --allow-dirty && cargo fmt", "lint:publish": "cargo publish --dry-run -p vespera_core && cargo publish --dry-run -p vespera_macro && cargo publish --dry-run -p vespera_inprocess && cargo publish --dry-run -p vespera_jni && cargo publish --dry-run -p vespera", "test": "bun test", - "posttest": "cargo tarpaulin --out xml --out stdout --out html --all-targets", + "posttest": "cargo test --workspace --doc && cargo tarpaulin --out xml --out stdout --out html --all-targets", "dev": "bun run --workspaces dev", "api": "cargo run", "prepare": "husky",