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..0c5cea33 --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,106 @@ +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, 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. + +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|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..ff28aac6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,8 @@ vespera/ │ ├── vespera_inprocess/ # In-process dispatch (transport-agnostic) │ │ └── src/lib.rs # dispatch(), register_app(), dispatch_from_bytes() │ └── 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 @@ -62,9 +63,9 @@ 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 | +| 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 | @@ -79,8 +80,15 @@ vespera/ | `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_inprocess/src/lib.rs` | ~85 | Crate root: module wiring + public re-exports (modularized — logic lives in the files below) | +| `vespera_inprocess/src/wire.rs` | ~429 | Binary wire encode/decode: split/parse, `Cow` borrowing request header, `HeaderMap`-direct response serialization, 422 validation-error hoisting | +| `vespera_inprocess/src/dispatch.rs` | ~290 | Public dispatch entry points: text envelope API, binary wire API, direct-write (`dispatch_into`) API | +| `vespera_inprocess/src/internal.rs` | ~335 | Request building + router oneshot + response collection (malformed path/header → 400) | +| `vespera_inprocess/src/streaming.rs` | ~462 | Response / header-callback / bidirectional streaming; `RequestChunk`/`StreamAbort` error-aware request body; bounded `ChannelBody` | +| `vespera_inprocess/src/registry.rs` | ~200 | App registration + lock-free default-app `OnceLock` + named-app `RwLock` | +| `vespera_jni/src/jni_impl.rs` | ~880 | JNI RUNTIME + jni_app! macro + 7 JNI symbols (incl. direct-buffer path) | +| `vespera_jni/src/streaming_closures.rs` | ~410 | 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` | ~210 | `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 | ## CRATE DEPENDENCY GRAPH @@ -134,6 +142,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 +179,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 +187,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`) — the wire byte layout is locked by `crates/vespera_inprocess/tests/wire_contract.rs`. ### Rust Public API (vespera_inprocess) @@ -190,7 +204,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 +243,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 +341,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 +355,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 +363,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 +401,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 +414,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..3071c1c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -404,9 +404,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 +436,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 +452,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 +480,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 +576,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 +609,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 +683,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 +701,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", @@ -724,12 +733,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 +891,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 +952,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 +1027,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 +1121,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 +1142,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 +1160,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 +1183,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 +1204,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 +1342,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 +1420,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 +1428,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 +1442,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 +1461,7 @@ dependencies = [ "http", "httpdate", "mime", - "sha1", + "sha1 0.10.6", ] [[package]] @@ -1466,36 +1493,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 +1554,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 +1755,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 +1851,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 +1936,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 +1978,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" @@ -1998,19 +2001,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 +2024,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 +2041,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 +2104,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 +2250,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 +2294,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 +2558,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 +2583,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 +2654,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 +2710,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 +2802,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 +2823,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 +2847,14 @@ dependencies = [ "serde", "serde_json", "sqlx", + "sqlx-core", "strum 0.28.0", "thiserror", "time", "tracing", "url", "uuid", + "web-time", ] [[package]] @@ -2952,9 +2870,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 +2886,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 +2901,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 +2915,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 +2925,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", @@ -3124,24 +3041,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 +3072,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 +3094,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 +3124,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 +3160,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 +3186,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 +3201,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 +3217,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 +3236,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 +3254,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 +3279,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 +3327,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 +3348,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 +3365,6 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", "thiserror", "time", @@ -3766,9 +3666,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", @@ -3873,9 +3773,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 +3860,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 +3884,13 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.51" +version = "0.2.0" dependencies = [ "axum", "axum-extra", "chrono", "garde", + "insta", "serde", "serde_json", "tempfile", @@ -4006,7 +3907,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.51" +version = "0.2.0" dependencies = [ "rstest", "serde", @@ -4015,14 +3916,16 @@ dependencies = [ [[package]] name = "vespera_inprocess" -version = "0.1.51" +version = "0.2.0" dependencies = [ "axum", "bytes", "criterion", + "futures-util", "http", "http-body", "http-body-util", + "mimalloc", "serde", "serde_json", "tokio", @@ -4031,18 +3934,21 @@ 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 = [ "insta", + "prettyplease", "proc-macro2", "quote", "rstest", @@ -4081,9 +3987,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 +4003,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 +4019,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 +4029,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 +4042,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 +4085,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 +4114,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 +4208,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 +4226,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 +4266,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 +4416,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 +4439,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 +4480,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..6987f42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,17 @@ 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): thin LTO + single codegen unit trade longer release-build +# time for faster/smaller production code. +# +# 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 = "thin" +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/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..755c1a78 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"] 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,8 @@ 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" [lints] workspace = true diff --git a/crates/vespera/src/multipart.rs b/crates/vespera/src/multipart.rs index 6f8f8ea3..8e9375a7 100644 --- a/crates/vespera/src/multipart.rs +++ b/crates/vespera/src/multipart.rs @@ -325,34 +325,48 @@ 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<'_>, limit: Option, ) -> Result<(String, Vec), TypedMultipartError> { let field_name = field.name().unwrap_or_default().to_string(); - 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, - }); - } + // Pre-size up to 64 KiB when a limit is known: avoids repeated + // doubling reallocations for typical fields without reserving huge + // buffers for large limits. Unbounded fields start empty and grow + // on demand, so a tiny scalar field never over-allocates. + let mut buf = limit.map_or_else(Vec::new, |limit| Vec::with_capacity(limit.min(64 * 1024))); + while let Some(chunk) = field.chunk().await? { + if let Some(limit) = limit + && buf.len().saturating_add(chunk.len()) > limit + { + // Reject BEFORE copying the over-limit chunk into the + // buffer — same acceptance condition (total <= limit), + // no wasted copy. + return Err(TypedMultipartError::FieldTooLarge { + field_name, + limit_bytes: limit, + }); } - buf - } else { - field.bytes().await?.to_vec() - }; + buf.extend_from_slice(&chunk); + } - Ok((field_name, data)) + Ok((field_name, buf)) } /// Parse a string as a boolean using clap-style conventions. @@ -360,10 +374,14 @@ async fn read_field_data( /// 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"]; + 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 } } @@ -477,13 +495,35 @@ impl TryFromFieldWithState for tempfile::NamedTempFile { _state: &S, ) -> Result { let field_name = field.name().unwrap_or_default().to_string(); - let mut temp = Self::new().map_err(|e| TypedMultipartError::Other { + + // 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 mut total = 0usize; while let Some(chunk) = field.chunk().await? { - total += chunk.len(); + // `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 let Some(limit) = limit_bytes && total > limit { @@ -492,12 +532,17 @@ impl TryFromFieldWithState for tempfile::NamedTempFile { limit_bytes: limit, }); } - 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) } diff --git a/crates/vespera/src/serve.rs b/crates/vespera/src/serve.rs index e1f126c9..ea087599 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 diff --git a/crates/vespera/src/validated.rs b/crates/vespera/src/validated.rs index c447f31e..adb4ed86 100644 --- a/crates/vespera/src/validated.rs +++ b/crates/vespera/src/validated.rs @@ -115,29 +115,46 @@ where /// /// 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 + #[derive(serde::Serialize)] + struct ValidationErrorOut { + message: String, + path: String, + } + + #[derive(serde::Serialize)] + struct ValidationEnvelope { + errors: Vec, + } + + let errors: Vec = report .iter() - .map(|(path, err)| { - ::serde_json::json!({ - "path": path.to_string(), - "message": err.message(), - }) + .map(|(path, err)| ValidationErrorOut { + message: err.message().to_string(), + path: path.to_string(), }) .collect(); - let envelope = ::serde_json::json!({ "errors": errors }); - let body = envelope.to_string(); + + // 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. + let body = ::serde_json::to_vec(&ValidationEnvelope { errors }) + .unwrap_or_else(|_| br#"{"errors":[]}"#.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/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/validated_extractor.rs b/crates/vespera/tests/validated_extractor.rs index 9cf81856..ce5f56f9 100644 --- a/crates/vespera/tests/validated_extractor.rs +++ b/crates/vespera/tests/validated_extractor.rs @@ -29,20 +29,31 @@ 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 +63,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 +85,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 +99,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 +118,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) — @@ -219,12 +205,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 +293,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 +368,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 (path before message, 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..6de86d0b 100644 --- a/crates/vespera_core/src/openapi.rs +++ b/crates/vespera_core/src/openapi.rs @@ -132,7 +132,7 @@ pub struct OpenApi { 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,17 +141,36 @@ 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) = 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); + } +} + 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) for (path, item) in other.paths { self.paths.entry(path).or_insert(item); } - // Merge components + // 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 { let self_components = self.components.get_or_insert(Components { schemas: None, @@ -163,30 +182,43 @@ impl OpenApi { 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); - } - } + 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 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 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 (deduplicate by name) + // Merge tags (deduplicate by name). A HashSet of seen names makes + // this O(existing + incoming) instead of O(existing × incoming); + // insertion order — and thus the merged tag order — is preserved + // because tags are still pushed in `other_tags` iteration order. if let Some(other_tags) = other.tags { let self_tags = self.tags.get_or_insert_with(Vec::new); + let mut seen: std::collections::HashSet = + self_tags.iter().map(|t| t.name.clone()).collect(); for tag in other_tags { - if !self_tags.iter().any(|t| t.name == tag.name) { + if seen.insert(tag.name.clone()) { self_tags.push(tag); } } @@ -232,6 +264,7 @@ mod tests { request_body: None, responses: BTreeMap::new(), security: None, + deprecated: None, }), ..Default::default() } @@ -338,7 +371,7 @@ mod tests { #[test] fn test_merge_security_schemes() { let mut base = create_base_openapi(); - let mut base_security_schemes = HashMap::new(); + let mut base_security_schemes = BTreeMap::new(); base_security_schemes.insert( "bearerAuth".to_string(), SecurityScheme { @@ -361,7 +394,7 @@ mod tests { }); let mut other = create_base_openapi(); - let mut other_security_schemes = HashMap::new(); + let mut other_security_schemes = BTreeMap::new(); other_security_schemes.insert( "apiKey".to_string(), SecurityScheme { @@ -468,4 +501,115 @@ mod tests { 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_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..d2266fc2 100644 --- a/crates/vespera_core/src/route.rs +++ b/crates/vespera_core/src/route.rs @@ -1,7 +1,7 @@ //! Route-related structure definitions use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use crate::SchemaRef; @@ -38,16 +38,28 @@ 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 upper-cased 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_uppercase())) } } } @@ -110,7 +122,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 +148,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,7 +192,10 @@ 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, } /// Path Item definition (all HTTP methods for a specific path) @@ -321,6 +336,7 @@ mod tests { request_body: None, responses: BTreeMap::new(), security: None, + deprecated: None, }; // Test setting GET operation @@ -391,6 +407,7 @@ mod tests { request_body: None, responses: BTreeMap::new(), security: None, + deprecated: None, }; let operation2 = Operation { @@ -402,6 +419,7 @@ mod tests { request_body: None, responses: BTreeMap::new(), security: None, + deprecated: None, }; // Set first operation diff --git a/crates/vespera_core/src/schema.rs b/crates/vespera_core/src/schema.rs index c9e6b6fb..2c22dbcd 100644 --- a/crates/vespera_core/src/schema.rs +++ b/crates/vespera_core/src/schema.rs @@ -1,7 +1,7 @@ //! Schema-related structure definitions use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; /// Schema reference or inline schema #[derive(Debug, Clone, Serialize, Deserialize)] @@ -31,7 +31,13 @@ impl Reference { /// Create a component schema reference #[must_use] pub fn schema(name: &str) -> Self { - Self::new(format!("#/components/schemas/{name}")) + // 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) } } @@ -59,10 +65,21 @@ where { match value { Some(v) if v.fract() == 0.0 => { - // Practical OpenAPI constraints are well within i64 range + // 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; - serializer.serialize_some(&int_val) + // 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(), @@ -345,30 +362,33 @@ pub struct Components { pub schemas: Option>, /// Response definitions #[serde(skip_serializing_if = "Option::is_none")] - pub responses: Option>, + pub responses: Option>, /// Parameter definitions #[serde(skip_serializing_if = "Option::is_none")] - pub parameters: Option>, + pub parameters: Option>, /// Example definitions #[serde(skip_serializing_if = "Option::is_none")] - pub examples: Option>, + pub examples: Option>, /// Request body definitions #[serde(skip_serializing_if = "Option::is_none")] - pub request_bodies: Option>, + pub request_bodies: Option>, /// Header definitions #[serde(skip_serializing_if = "Option::is_none")] - pub headers: Option>, + pub headers: Option>, /// Security scheme definitions #[serde(skip_serializing_if = "Option::is_none")] - pub security_schemes: Option>, + pub security_schemes: Option>, } /// Security scheme type -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[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, OAuth2, OpenIdConnect, @@ -503,6 +523,29 @@ mod tests { ); } + #[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 { diff --git a/crates/vespera_inprocess/Cargo.toml b/crates/vespera_inprocess/Cargo.toml index e6999cff..da7dd546 100644 --- a/crates/vespera_inprocess/Cargo.toml +++ b/crates/vespera_inprocess/Cargo.toml @@ -19,7 +19,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..3a821460 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_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,26 +137,60 @@ fn make_envelope(body_kb: usize) -> RequestEnvelope { } } +/// 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 +} + /// 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(); - let header = serde_json::json!({ - "v": 1, - "method": "POST", - "path": "/echo", - "headers": {"content-type": "application/json"}, - }); - 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()); - wire.extend_from_slice(&header_len.to_be_bytes()); - wire.extend_from_slice(&header_bytes); - wire.extend_from_slice(body_bytes); - wire + 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 ─────────────────────────────────────────────────────── @@ -160,8 +265,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 +287,372 @@ 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"); + + 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 +/// `RwLock` slow path. 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"), &[]); + group.bench_function("named_rwlock_slow_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` goes through the `RwLock`. Under reader pressure +/// the RwLock path can park threads — the delta between the two +/// captures exactly what the single-threaded `resolve_path` group +/// cannot. 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_rwlock", 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(); +} + +/// 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); +} + criterion_group!( benches, 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_headers_path, + bench_streaming_path, + bench_async_spawn_pattern ); criterion_main!(benches); diff --git a/crates/vespera_inprocess/src/config.rs b/crates/vespera_inprocess/src/config.rs new file mode 100644 index 00000000..b8bd1298 --- /dev/null +++ b/crates/vespera_inprocess/src/config.rs @@ -0,0 +1,209 @@ +//! 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 absent or unparseable. +fn parse_config_value(raw: Option<&str>, default: usize, min: usize, max: usize) -> usize { + raw.and_then(|s| s.trim().parse::().ok()) + .map_or(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( + 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( + 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() +} + +// ── 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 allocated. The **streaming** +/// entry points are intentionally exempt — they are `O(chunk)` RAM and +/// are the correct path for legitimately large payloads. +#[must_use] +#[inline] +pub fn max_request_bytes() -> usize { + *MAX_REQUEST_BYTES.get_or_init(|| { + std::env::var("VESPERA_MAX_REQUEST_BYTES") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(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(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(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(Some("131072"), 262_144, 4096, 8 << 20), + 131_072 + ); + assert_eq!(parse_config_value(Some(" 64 "), 16, 1, 1024), 64); + } + + #[test] + fn out_of_range_values_are_clamped() { + assert_eq!(parse_config_value(Some("1"), 262_144, 4096, 8 << 20), 4096); + assert_eq!( + parse_config_value(Some("999999999"), 262_144, 4096, 8 << 20), + 8 << 20 + ); + } +} diff --git a/crates/vespera_inprocess/src/dispatch.rs b/crates/vespera_inprocess/src/dispatch.rs new file mode 100644 index 00000000..d7a35f47 --- /dev/null +++ b/crates/vespera_inprocess/src/dispatch.rs @@ -0,0 +1,354 @@ +//! 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_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_VERSION, error_wire, parse_wire_header, split_wire_request, to_wire_bytes, + write_wire_header_into_slice, +}; + +// ── 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 doing any further work. Unlimited by + // default (see `max_request_bytes`); streaming paths are exempt. + if crate::config::request_exceeds_limit(input.len()) { + return error_wire( + 413, + &format!( + "request size {} bytes exceeds configured maximum of {} bytes", + input.len(), + crate::config::max_request_bytes() + ), + ); + } + // Wire-level checks next: malformed input must report parse + // errors regardless of whether an app is registered. + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => return error_wire(400, &msg), + }; + let header = match parse_wire_header(&header_bytes) { + Ok(h) => h, + 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.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + body_bytes, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return error_wire(status, &msg), + }; + to_wire_bytes(parts) +} + +/// 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 body stream is still drained (counting, +/// not writing) so [`DirectWriteResult::Overflow`] reports the +/// **exact** required size. 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 crate::config::request_exceeds_limit(input.len()) { + return write_wire_into( + out, + &error_wire( + 413, + &format!( + "request size {} bytes exceeds configured maximum of {} bytes", + input.len(), + crate::config::max_request_bytes() + ), + ), + ); + } + 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 = match parse_wire_header(&header_bytes) { + Ok(h) => h, + Err(msg) => return write_wire_into(out, &error_wire(400, &msg)), + }; + if header.v != WIRE_VERSION { + return write_wire_into( + out, + &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 write_wire_into(out, &wire), + }; + + // Mirror dispatch_parts' Content-Type defaulting (body present, no + // content-type → application/json) so the direct-write path is + // request-compatible with dispatch_from_bytes. The body's + // emptiness is known here (unlike the streaming callers), so the + // default is applied on the request builder — no map insert, no + // String allocations. + let default_json_content_type = !body_bytes.is_empty() + && !header + .headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("content-type")); + + let (status, headers, metadata, mut body) = match dispatch_and_split( + router, + &header.method, + &header.path, + &header.query, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + Body::from(body_bytes), + default_json_content_type, + ) + .await + { + Ok(parts) => parts, + Err((status, msg)) => return write_wire_into(out, &error_wire(status, &msg)), + }; + + if status == 422 { + // Materialise to preserve validation_errors hoisting in the + // wire header — identical bytes to dispatch_from_bytes. + let body_bytes = body + .collect() + .await + .map(http_body_util::Collected::to_bytes) + .unwrap_or_default(); + 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; + + 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). + if written == required && written + len <= out.len() { + out[written..written + len].copy_from_slice(data); + written += len; + } + required += 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..1b8cab62 --- /dev/null +++ b/crates/vespera_inprocess/src/internal.rs @@ -0,0 +1,373 @@ +//! 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::{Method, Request}; +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 = router + .oneshot(request) + .await + .expect("router error is Infallible"); + + Ok(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. +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) + } +} + +/// 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 +/// that `http`'s builder rejects, upholding the "every failure returns a wire +/// response" contract. `#[inline]` so the two call sites keep the previous +/// inlined single-pass 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 mut builder = request_builder(http_method, path, query); + // 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 { + 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}"))) +} + +/// 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 = router + .oneshot(request) + .await + .expect("router error is Infallible"); + + 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() + { + break; + } + } + 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. +async fn collect_response_parts(response: axum::response::Response) -> ResponseParts { + let (parts, body) = response.into_parts(); + + let body_bytes = body + .collect() + .await + .map(http_body_util::Collected::to_bytes) + .unwrap_or_default(); + + ( + 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_content_type` adds `content-type: application/json` +/// to the outgoing request (mirroring [`dispatch_parts`]'s defaulting) +/// — only [`dispatch_into_async`] sets it, because streaming callers +/// hand this function an opaque [`Body`] whose emptiness is +/// unknowable up front. +pub async fn dispatch_and_split<'h>( + router: Router, + method_str: &str, + path: &str, + query: &str, + headers: impl Iterator, + body: Body, + default_json_content_type: 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"), + )); + }; + + let mut builder = request_builder(http_method, path, query); + for (name, value) in headers { + builder = builder.header(name, value); + } + if default_json_content_type { + builder = builder.header("content-type", "application/json"); + } + + // Same contract as dispatch_parts: a malformed path/header must + // surface as a 400 wire response, not a panic. + let request = match builder.body(body) { + Ok(req) => req, + Err(e) => return Err((400, format!("invalid request: {e}"))), + }; + + let response = router + .oneshot(request) + .await + .expect("router error is Infallible"); + + 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", + "", + 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..c138fb94 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): @@ -56,1200 +68,33 @@ //! [`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(), - }, - }; - } - }; - 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)) -} +pub use config::{ + DEFAULT_STREAMING_CHANNEL_CAPACITY, DEFAULT_STREAMING_CHUNK_BYTES, 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_owned, dispatch_typed, +}; +pub use envelope::{ + HeaderValue, RequestEnvelope, ResponseEnvelope, ResponseMetadata, error_envelope, +}; +pub use registry::{DEFAULT_APP_NAME, register_app, register_app_named}; +pub use streaming::{ + RequestChunk, StreamAbort, 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; diff --git a/crates/vespera_inprocess/src/registry.rs b/crates/vespera_inprocess/src/registry.rs new file mode 100644 index 00000000..a3404e98 --- /dev/null +++ b/crates/vespera_inprocess/src/registry.rs @@ -0,0 +1,211 @@ +//! App registry: named `Router` factories with a lock-free +//! `OnceLock` fast path for the default app. + +use std::collections::HashMap; +use std::sync::{LazyLock, OnceLock, RwLock}; + +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"`). +/// +/// 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())); + +/// 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` costs an `RwLock` read acquisition per +/// request, which parks threads under high concurrency. This +/// `OnceLock` mirror is set (exactly once, inside the registration +/// write lock so it can never diverge from the map) by the first +/// successful `_default` registration and read with a single atomic +/// load + `Router::clone` (`Arc` refcount bump) on every dispatch. +/// +/// Named apps keep using the `RwLock` — they are the rare +/// multi-app case and can be registered at any time. +static DEFAULT_ROUTER: OnceLock = OnceLock::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 is_default = name == DEFAULT_APP_NAME; + 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. + let stored = map.entry(name).or_insert(router); + if is_default { + // Mirror the default app into the lock-free fast path. Done + // under the write lock with the *stored* router (not our local + // candidate) so the mirror always equals the map's first-wins + // winner, even when two threads race the registration. + let _ = DEFAULT_ROUTER.set(stored.clone()); + } +} + +/// 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 RwLock acquisition. + if name == DEFAULT_APP_NAME + && let Some(router) = DEFAULT_ROUTER.get() + { + return Ok(router.clone()); + } + { + let map = APP_ROUTERS + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(router) = map.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..cc57b46c --- /dev/null +++ b/crates/vespera_inprocess/src/streaming.rs @@ -0,0 +1,669 @@ +//! 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::streaming_channel_capacity; +use crate::internal::{dispatch_and_split, dispatch_response_streaming}; +use crate::registry::resolve_app_router; +use crate::wire::{ + WIRE_HEADER_RESERVE, WIRE_VERSION, build_wire_header_bytes, error_wire, parse_wire_header, + 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. +/// +/// `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<()>, +{ + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => return error_wire(400, &msg), + }; + let header = match parse_wire_header(&header_bytes) { + Ok(h) => h, + 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.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: the 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. + build_wire_header_bytes(status, &headers, &metadata) +} + +/// **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]) -> ControlFlow<()>, +{ + let (header_bytes, body_bytes) = match split_wire_request(input) { + Ok(parts) => parts, + Err(msg) => { + on_header(&error_wire(400, &msg)); + return; + } + }; + let header = match parse_wire_header(&header_bytes) { + Ok(h) => h, + 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.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + Body::from(body_bytes), + false, + ) + .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(frame_result) = body.frame().await { + match frame_result { + Ok(frame) => { + if let Some(data) = frame.data_ref() + && !data.is_empty() + && on_chunk(data.as_ref()).is_break() + { + break; + } + } + // Known limitation: after the header is committed, a body-stream error cannot be signalled cleanly. + Err(_) => break, + } + } +} + +/// **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 on_header = |h: &[u8]| header_bytes.extend_from_slice(h); + bidirectional_streaming_inner(input_header, pull_chunk, on_chunk, on_header, request_close) + .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. +/// +/// 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, +) 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, +) 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, +) where + P: FnMut() -> RequestChunk + Send + 'static, + F: FnMut(&[u8]) -> ControlFlow<()>, + H: FnMut(&[u8]), + C: FnOnce(), +{ + let (header_bytes, _ignored_body) = match split_wire_request(input_header) { + Ok(parts) => parts, + Err(msg) => { + on_header(&error_wire(400, &msg)); + return; + } + }; + let header = match parse_wire_header(&header_bytes) { + Ok(h) => h, + 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 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); + + let (status, headers, metadata, mut response_body) = match dispatch_and_split( + router, + &header.method, + &header.path, + &header.query, + header.headers.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), + body, + false, + ) + .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; + } + }; + + on_header(&build_wire_header_bytes(status, &headers, &metadata)); + + while let Some(frame_result) = response_body.frame().await { + match frame_result { + Ok(frame) => { + if let Some(data) = frame.data_ref() + && !data.is_empty() + && on_chunk(data.as_ref()).is_break() + { + break; + } + } + // Known limitation: after the header is committed, a body-stream error cannot be signalled cleanly. + Err(_) => break, + } + } + + // 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; +} + +/// 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 { + match producer_handle.lock() { + Ok(guard) => guard.is_some(), + Err(poisoned) => poisoned.into_inner().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. + fn close_if_started(&mut self) { + if let Some(close) = self.close.take() + && producer_was_started(&self.producer_handle) + { + 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), + capacity: 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; + loop { + match pull() { + 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; + if tx.blocking_send(Ok(Bytes::from(chunk))).is_err() { + 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<()>, +) { + match producer_handle.lock() { + Ok(mut guard) => *guard = Some(handle), + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + *guard = Some(handle); + } + } +} + +async fn await_request_producer(producer_handle: &RequestProducerHandle) { + let handle = match producer_handle.lock() { + Ok(mut guard) => guard.take(), + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + guard.take() + } + }; + + if let Some(handle) = handle { + let _ = handle.await; + } +} diff --git a/crates/vespera_inprocess/src/wire.rs b/crates/vespera_inprocess/src/wire.rs new file mode 100644 index 00000000..f0faf547 --- /dev/null +++ b/crates/vespera_inprocess/src/wire.rs @@ -0,0 +1,563 @@ +//! 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; +use serde::{Deserialize, Serialize}; + +use crate::envelope::ResponseMetadata; +use crate::internal::ResponseParts; + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use super::{parse_wire_header, split_wire_request}; + + /// 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") + ); + } +} + +/// 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, deserialized **borrowing from the input +/// buffer**: every string field is a `Cow` that points straight into +/// the wire bytes (zero allocation) unless the JSON value contains +/// escape sequences, in which case deserialization transparently +/// falls back to an owned copy. +/// +/// Direct `Cow` fields borrow via serde-derive's `borrow` +/// special-casing; `headers` and `app` need the custom +/// [`de_cow_map`] / [`de_opt_cow`] deserializers because serde's +/// stock `Cow` impl inside containers always copies. +#[derive(Debug, Deserialize)] +pub struct WireRequestHeader<'a> { + /// Wire protocol version; clients MUST send 1. + #[serde(default)] + pub v: u8, + #[serde(borrow)] + pub method: Cow<'a, str>, + #[serde(borrow)] + pub path: Cow<'a, str>, + #[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). + #[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. + #[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. +struct BorrowableCow<'a>(Cow<'a, str>); + +impl<'de> 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. +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. +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 { + BorrowableCow::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. +#[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) +struct WireHeaders<'a>(&'a http::HeaderMap); + +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. +struct WireHeaderValues<'a>(&'a http::HeaderMap, &'a str); + +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 view **directly into the output buffer** — no +/// intermediate `Vec` and no second memcpy of the header JSON. +/// +/// Typical wire headers are well under this reservation, so the +/// serializer usually writes without reallocating. +pub const WIRE_HEADER_RESERVE: usize = 192; + +fn write_wire_header_into(out: &mut Vec, view: &WireResponseHeader<'_, H>) { + out.extend_from_slice(&[0u8; 4]); + let start = out.len(); + serde_json::to_writer(&mut *out, view).expect("WireResponseHeader serialization is infallible"); + let header_len = + u32::try_from(out.len() - start).expect("response header JSON exceeds u32::MAX bytes"); + out[start - 4..start].copy_from_slice(&header_len.to_be_bytes()); +} + +/// 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, +} + +/// 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(); + let parts = ( + status, + headers, + Bytes::copy_from_slice(msg.as_bytes()), + metadata, + ); + to_wire_bytes(parts) +} + +/// 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 { + try_hoist_validation_errors(&headers, &body_bytes) + } else { + None + }; + let header = WireResponseHeader { + v: WIRE_VERSION, + status, + headers: &WireHeaders(&headers), + metadata: &metadata, + validation_errors, + }; + let mut out = Vec::with_capacity(4 + WIRE_HEADER_RESERVE + body_bytes.len()); + write_wire_header_into(&mut out, &header); + 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. +pub fn build_wire_header_bytes( + status: u16, + headers: &http::HeaderMap, + metadata: &ResponseMetadata, +) -> Vec { + let view = WireResponseHeader { + v: WIRE_VERSION, + status, + headers: &WireHeaders(headers), + metadata, + validation_errors: None, + }; + let mut out = Vec::with_capacity(4 + WIRE_HEADER_RESERVE); + write_wire_header_into(&mut out, &view); + 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()`). +struct SliceWriter<'a> { + buf: &'a mut [u8], + pos: usize, +} + +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(); + } +} + +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`**, +/// 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 (same [`WireResponseHeader`] +/// serialization). +/// +/// 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 view = WireResponseHeader { + v: WIRE_VERSION, + status, + headers: &WireHeaders(headers), + metadata, + validation_errors: None, + }; + let header_total = { + let mut writer = SliceWriter::new(out); + // Reserve the 4-byte length prefix, then serialize the JSON body + // straight after it; backfilled below once the length is known. + 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 +} + +/// 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: &http::HeaderMap, + body_bytes: &Bytes, +) -> Option> { + // First content-type value decides (matches the previous + // first-of-Multi behaviour). Comparisons are case-insensitive + // in place — no lowercased copy. + let is_json = headers + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|s| { + let mime = s.split(';').next().unwrap_or("").trim(); + mime.eq_ignore_ascii_case("application/json") + || (mime.len() >= 5 && mime[mime.len() - 5..].eq_ignore_ascii_case("+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) } +} + +/// 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; + 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)) +} + +/// Deserialize the wire request header, borrowing every string from +/// `header_json` where possible (see [`WireRequestHeader`]). +#[inline] +pub fn parse_wire_header(header_json: &[u8]) -> Result, String> { + serde_json::from_slice(header_json).map_err(|e| format!("wire header JSON parse error: {e}")) +} diff --git a/crates/vespera_inprocess/tests/binary_wire.rs b/crates/vespera_inprocess/tests/binary_wire.rs index 5a40cc9a..4d251727 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 = @@ -405,8 +594,8 @@ async fn dispatch_bidirectional_streaming_large_request_body() { 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 +611,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..f50aef5a --- /dev/null +++ b/crates/vespera_inprocess/tests/dispatch_into.rs @@ -0,0 +1,233 @@ +//! 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, 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" + ); +} 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/request_size_cap.rs b/crates/vespera_inprocess/tests/request_size_cap.rs new file mode 100644 index 00000000..f8fa558f --- /dev/null +++ b/crates/vespera_inprocess/tests/request_size_cap.rs @@ -0,0 +1,68 @@ +//! 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 serde_json::Value; +use tokio::runtime::Builder; +use vespera_inprocess::{dispatch_from_bytes, 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); +} + +fn dispatch(wire: Vec) -> Value { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("build runtime"); + let resp = dispatch_from_bytes(wire, &runtime); + 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 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" + ); +} diff --git a/crates/vespera_inprocess/tests/streaming_with_header.rs b/crates/vespera_inprocess/tests/streaming_with_header.rs index 98598beb..953a8ea7 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, 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,314 @@ 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 response_streaming_stops_draining_when_chunk_callback_breaks() { + 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(200)); + assert!(header_body.is_empty()); + assert_eq!(body_buf.lock().unwrap().as_slice(), b"first"); +} + +#[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..7ddbedfb --- /dev/null +++ b/crates/vespera_inprocess/tests/wire_contract.rs @@ -0,0 +1,191 @@ +//! **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; +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() +} + +fn install_router() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + register_app(|| Router::new().route("/contract", get(contract_headers))); + }); +} + +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" + ); +} + +/// 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..e8bfd0d4 --- /dev/null +++ b/crates/vespera_jni/src/daemon_env.rs @@ -0,0 +1,246 @@ +//! 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 on the exact 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; +//! * 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, + 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(); + if slot.is_none() { + let (env_ptr, owned) = resolve_current_env(jvm)?; + *slot = Some(CachedEnv { + env_ptr, + 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..20abc75c --- /dev/null +++ b/crates/vespera_jni/src/jni_buf.rs @@ -0,0 +1,86 @@ +//! 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::with_capacity(len); + if len == 0 { + return Ok(vec); + } + // `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 `len` never exceeds that array's length (it is + // the array length for the buffered path, and `min(chunk_size, n)` + // for the streaming pull path, where the Java buffer is + // `chunk_size` bytes). + // * The destination is `vec`'s reserved-but-uninitialised capacity + // (`with_capacity(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..b4ec8e44 --- /dev/null +++ b/crates/vespera_jni/src/jni_impl.rs @@ -0,0 +1,1001 @@ +use std::{cell::RefCell, future::Future, sync::LazyLock}; + +use futures_util::FutureExt; +use jni::EnvUnowned; +use jni::errors::ThrowRuntimeExAndDefault; +use jni::objects::{Global, JByteArray, JByteBuffer, JClass, JObject}; +use jni::sys::{jbyteArray, jint}; + +use crate::daemon_env::with_cached_daemon_env; +use crate::streaming_closures::{ + call_header_consumer, close_input_stream, complete_future, complete_future_local, + make_pull_closure, make_push_closure, +}; + +/// 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") +}); + +const MIN_RUNTIME_WORKERS: usize = 1; +const MAX_RUNTIME_WORKERS: usize = 1024; + +static RUNTIME_WORKER_THREADS: std::sync::OnceLock> = std::sync::OnceLock::new(); + +thread_local! { + static SYNC_RUNTIME: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to create per-thread Tokio runtime"); + static STREAMING_PULL_BUFFER: RefCell> = const { RefCell::new(None) }; + static STREAMING_PUSH_BUFFER: RefCell> = const { RefCell::new(None) }; +} + +/// 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 + } +} + +type StreamingChunkBuffer = Global>; + +#[derive(Clone, Copy)] +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 explicitly only after the streaming future returns normally. If a +// panic unwinds through a bidirectional dispatch while the request producer may +// still be in `InputStream.read`, the cache stays checked out and future +// dispatches allocate fresh buffers instead of aliasing the Java array. +struct StreamingChunkBufferLease { + role: StreamingBufferRole, +} + +impl StreamingChunkBufferLease { + const fn new(role: StreamingBufferRole) -> Self { + Self { role } + } + + fn mark_reusable(self) { + self.role.with_cache(|cache| { + if let Some(cached) = cache.borrow_mut().as_mut() { + cached.checked_out = false; + } + }); + } +} + +fn new_streaming_chunk_buffer( + env: &mut jni::Env<'_>, + size: usize, +) -> jni::errors::Result { + let local = env.new_byte_array(size)?; + env.new_global_ref(&local) +} + +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(); + let replace_cached = slot + .as_ref() + .is_none_or(|cached| cached.size != size && !cached.checked_out); + + if replace_cached { + *slot = Some(CachedStreamingChunkBuffer { + size, + array: new_streaming_chunk_buffer(env, size)?, + checked_out: false, + }); + } + + let Some(cached) = slot.as_mut() else { + return Ok((new_streaming_chunk_buffer(env, size)?, None)); + }; + + if cached.size != size || cached.checked_out { + return Ok((new_streaming_chunk_buffer(env, size)?, None)); + } + + let cached_array: &JByteArray<'static> = cached.array.as_ref(); + let dispatch_array = env.new_global_ref(cached_array)?; + cached.checked_out = true; + Ok((dispatch_array, Some(StreamingChunkBufferLease::new(role)))) + }) +} + +fn mark_streaming_buffer_reusable(lease: Option) { + if let Some(lease) = lease { + lease.mark_reusable(); + } +} + +/// Worker thread count for the shared [`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); + } + }); +} + +/// `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 input = { + let len = request_bytes.len(env).unwrap_or(0); + // 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 Ok(env.byte_array_from_slice(&err)?.into()); + } + // Read straight into uninitialised capacity — no zero-fill + // that `get_region` would immediately overwrite. + let Ok(buf) = crate::jni_buf::read_byte_array_region(env, &request_bytes, len) + else { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + return Ok(env.byte_array_from_slice(&err)?.into()); + }; + buf + }; + + let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + block_on_sync_runtime(vespera_inprocess::dispatch_from_bytes_async(input)) + })) + .unwrap_or_else(|_| vespera_inprocess::error_wire(500, "panic in Rust engine")); + + Ok(env.byte_array_from_slice(&response)?.into()) + }) + .resolve::() + .into_raw() +} + +/// 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). +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 + // guarantees `out_addr..out_addr+out_cap` is writable. + // Source and destination cannot overlap: `response` is a + // Rust-owned Vec, the destination is 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 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** `ByteBuffer`s. The +/// Java wrapper enforces this before crossing JNI; non-direct +/// buffers reaching this symbol produce a thrown +/// `RuntimeException` (the jni crate surfaces a null direct +/// address as `Err`). +/// * 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`] — the intermediate +/// response `Vec`: on the success path the wire header and each +/// body frame are written straight into `out_buf`. One plain +/// native memcpy remains on the request side (axum's `Body` +/// requires `'static` ownership), plus the per-frame copies of the +/// response body. `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 slice is copied into a Rust-owned `Vec` *before* +/// dispatch, so nothing borrowed from the buffer outlives the +/// read. +#[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 { + unowned_env + .with_env(|env| -> jni::errors::Result { + // Err here (null address ⇒ heap buffer, or JVM trouble) + // is thrown as RuntimeException via the resolve below — + // defense in depth behind the Java-side isDirect() check. + let in_addr = env.get_direct_buffer_address(&in_buf)?; + let in_cap = env.get_direct_buffer_capacity(&in_buf)?; + let out_addr = env.get_direct_buffer_address(&out_buf)?; + let out_cap = env.get_direct_buffer_capacity(&out_buf)?; + + // 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 input = match usize::try_from(in_len) { + Ok(len) if len <= in_cap => { + // SAFETY: invariants 1–3 above; `len <= in_cap` + // bounds the read inside the direct buffer. + unsafe { std::slice::from_raw_parts(in_addr, len) }.to_vec() + } + _ => { + let err = vespera_inprocess::error_wire( + 400, + "invalid in_len (negative or exceeds buffer capacity)", + ); + return Ok(write_response_to_out(out_addr, out_cap, &err)); + } + }; + + let dispatched = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: invariants 1–2 above — `out_addr` points + // to `out_cap` writable bytes of a direct buffer + // pinned by the live `out_buf` local ref; the Java + // caller is blocked for the whole call, so the + // region is exclusively ours; the slice never + // escapes this closure. + let out = unsafe { std::slice::from_raw_parts_mut(out_addr, out_cap) }; + block_on_sync_runtime(vespera_inprocess::dispatch_into_async(input, out)) + })); + + let code = match dispatched { + Ok(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) + } + Ok(vespera_inprocess::DirectWriteResult::Overflow(required)) => { + jint::try_from(required).map_or(DIRECT_UNREPRESENTABLE, |r| -r) + } + Err(_) => { + let err = vespera_inprocess::error_wire(500, "panic in Rust engine"); + write_response_to_out(out_addr, out_cap, &err) + } + }; + Ok(code) + }) + .resolve::() +} + +/// `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>, +) { + // 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. + let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> { + // 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 = { + let len = request_bytes.len(env).unwrap_or(0); + // Ingress cap: complete the future with 413 BEFORE allocating + // the Rust-side body copy if the request exceeds the limit. + if let Some(err) = oversized_request_wire(len) { + let _ = complete_future_local(env, &future_obj, &err); + return Ok(()); + } + // Read straight into uninitialised capacity — no zero-fill + // that `get_region` would immediately overwrite. + let Ok(buf) = crate::jni_buf::read_byte_array_region(env, &request_bytes, len) else { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + let _ = complete_future_local(env, &future_obj, &err); + return Ok(()); + }; + buf + }; + + // 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"), + ); + 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"), + ); + 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")); + + let _ = with_cached_daemon_env(&jvm, |env| -> jni::errors::Result<()> { + complete_future(env, &future_for_task, &response) + }); + }); + })); + if scheduled.is_err() { + let _ = complete_future_local( + env, + &future_obj, + &vespera_inprocess::error_wire(500, "failed to schedule Rust dispatch"), + ); + } + + 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 input = { + let len = request_bytes.len(env).unwrap_or(0); + if let Some(err) = oversized_request_wire(len) { + return Ok(env.byte_array_from_slice(&err)?.into()); + } + let Ok(buf) = crate::jni_buf::read_byte_array_region(env, &request_bytes, len) + else { + let err = vespera_inprocess::error_wire( + 400, + "invalid input byte array (JNI conversion failed)", + ); + return Ok(env.byte_array_from_slice(&err)?.into()); + }; + buf + }; + + // 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()?; + + // One per-thread reusable Java chunk buffer for the whole stream. + let (push_buf, push_buf_lease) = + checkout_streaming_chunk_buffer(env, StreamingBufferRole::Push)?; + + let header_bytes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.block_on(vespera_inprocess::dispatch_streaming_async( + input, + make_push_closure(jvm, stream_global, push_buf), + )) + })); + let header_bytes = header_bytes.map_or_else( + |_| vespera_inprocess::error_wire(500, "panic in Rust engine"), + |header_bytes| { + mark_streaming_buffer_reusable(push_buf_lease); + header_bytes + }, + ); + + 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)?; + // A second InputStream ref for the post-response close — the + // first is moved into the pull closure (a `Global` is not + // `Clone`); both are independent GC roots to the same stream. + let input_for_close: Global> = 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, so each + // direction checks out its own per-thread cached buffer. + 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); + } + }; + + // Closures capture clones of the JavaVM and Globals; + // both types are Send+Sync. + let pull_jvm = jvm.clone(); + let pull_global = input_global; + let close_jvm = jvm.clone(); + 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_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) + }); + }, + )) + })); + let header_response = header_response.map_or_else( + |_| vespera_inprocess::error_wire(500, "panic in Rust engine"), + |header_response| { + mark_streaming_buffer_reusable(pull_buf_lease); + mark_streaming_buffer_reusable(push_buf_lease); + header_response + }, + ); + + 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 input = { + let len = request_bytes.len(env).unwrap_or(0); + if let Some(err) = oversized_request_wire(len) { + let _ = call_header_consumer(env, &env.new_global_ref(&header_consumer)?, &err); + return Ok(()); + } + let Ok(buf) = crate::jni_buf::read_byte_array_region(env, &request_bytes, len) 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(()); + }; + buf + }; + + 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)?; + + // 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 leaves Spring's response partially + // committed — unrecoverable, but the contract is already met. + let header_sent = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let header_sent_cb = std::sync::Arc::clone(&header_sent); + let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let header_for_cb = header_global; + let jvm_for_cb = jvm.clone(); + let push = make_push_closure(jvm, stream_global, push_buf); + RUNTIME.block_on(vespera_inprocess::dispatch_streaming_with_header_async( + input, + |header_bytes: &[u8]| { + if 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() + { + header_sent_cb.store(true, std::sync::atomic::Ordering::SeqCst); + } + }, + push, + )); + })); + if panic_result.is_ok() { + mark_streaming_buffer_reusable(push_buf_lease); + } else if !header_sent.load(std::sync::atomic::Ordering::SeqCst) + && let Ok(fallback) = env.new_global_ref(&header_consumer) + { + let err = vespera_inprocess::error_wire(500, "panic in Rust engine"); + let _ = call_header_consumer(env, &fallback, &err); + } + + 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)?; + // Second InputStream ref for the post-response close (the first is + // moved into the pull closure; `Global` is not `Clone`). + let input_for_close: Global> = 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. + 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); + } + }; + + let pull_jvm = jvm.clone(); + let pull_global = input_global; + let push_jvm = jvm.clone(); + let push_global = output_global; + let close_jvm = jvm.clone(); + let header_jvm = jvm; + let header_for_cb = header_global; + + // See dispatchStreamingWithHeader: `header_sent` lets us honour + // the "header consumer invoked exactly once on every code path" + // contract — if a panic unwinds before the header callback fires + // (e.g. the handler panicked before producing status/headers), + // we fire the consumer once with a 500 below instead of leaving + // the Java caller hanging. + let header_sent = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let header_sent_cb = std::sync::Arc::clone(&header_sent); + let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RUNTIME.block_on( + vespera_inprocess::dispatch_bidirectional_streaming_with_header_closing( + header_input, + make_pull_closure(pull_jvm, pull_global, pull_buf), + make_push_closure(push_jvm, push_global, push_buf), + |header_bytes: &[u8]| { + if with_cached_daemon_env( + &header_jvm, + |env: &mut jni::Env<'_>| -> jni::errors::Result<()> { + call_header_consumer(env, &header_for_cb, header_bytes) + }, + ) + .is_ok() + { + header_sent_cb.store(true, std::sync::atomic::Ordering::SeqCst); + } + }, + // Close the InputStream once the response is fully + // streamed, to unblock a producer parked in a blocking + // read so the dispatch cannot hang on a stuck upload. + move || { + let _ = with_cached_daemon_env(&close_jvm, |env| { + close_input_stream(env, &input_for_close) + }); + }, + ), + ); + })); + if panic_result.is_ok() { + mark_streaming_buffer_reusable(pull_buf_lease); + mark_streaming_buffer_reusable(push_buf_lease); + } else if !header_sent.load(std::sync::atomic::Ordering::SeqCst) + && let Ok(fallback) = env.new_global_ref(&header_consumer) + { + let err = vespera_inprocess::error_wire(500, "panic in Rust engine"); + let _ = call_header_consumer(env, &fallback, &err); + } + + Ok(()) + }); +} + +#[cfg(test)] +#[path = "jni_impl_runtime_config_tests.rs"] +mod runtime_config_tests; + +#[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..c4c45004 --- /dev/null +++ b/crates/vespera_jni/src/jni_impl_direct_tests.rs @@ -0,0 +1,37 @@ +use super::write_response_to_out; + +#[test] +fn response_fits_returns_len_and_writes_bytes() { + let mut out = vec![0u8; 16]; + let response = b"hello wire"; + let n = 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 = 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 = 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 = 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..405539fe --- /dev/null +++ b/crates/vespera_jni/src/jni_impl_runtime_config_tests.rs @@ -0,0 +1,18 @@ +use super::{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/lib.rs b/crates/vespera_jni/src/lib.rs index f5f95de2..16ebfa2f 100644 --- a/crates/vespera_jni/src/lib.rs +++ b/crates/vespera_jni/src/lib.rs @@ -17,6 +17,18 @@ 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. @@ -94,570 +106,10 @@ macro_rules! jni_apps { // 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(()) - } -} +mod daemon_env; +#[cfg(not(tarpaulin_include))] +mod jni_buf; +#[cfg(not(tarpaulin_include))] +mod jni_impl; +#[cfg(not(tarpaulin_include))] +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..e11411bc --- /dev/null +++ b/crates/vespera_jni/src/streaming_closures.rs @@ -0,0 +1,471 @@ +//! 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::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"), + )?, + }) + }) + } +} + +static METHOD_CACHE: OnceLock = OnceLock::new(); + +fn method_cache(env: &mut jni::Env<'_>) -> Option<&'static MethodCache> { + if let Some(cache) = METHOD_CACHE.get() { + return Some(cache); + } + + 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(); + } + return None; + }; + + let _ = METHOD_CACHE.set(cache); + METHOD_CACHE.get() +} + +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: Global>, + buf: Global>, +) -> impl FnMut() -> vespera_inprocess::RequestChunk + Send + 'static { + use vespera_inprocess::RequestChunk; + let chunk_size = streaming_chunk_size(); + 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)?; + if env.exception_check() { + env.exception_clear(); + } + // 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 { + return Ok(RequestChunk::End); + } + if n == 0 { + return Ok(RequestChunk::Data(Vec::new())); + } + let n = usize::try_from(n).expect("positive read length fits usize"); + let n = n.min(chunk_size); + // 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(); + 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: Global>, +) -> impl FnMut(&[u8]) -> ControlFlow<()> + Send + 'static { + let chunk_size = streaming_chunk_size(); + // 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(); + 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)?; + let len = i32::try_from(seg.len()) + .expect("segment length bounded by streaming_chunk_size"); + call_output_stream_write(env, &stream, &buf, len)?; + // 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(()) + }); + 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(); + call_consumer_accept(env, consumer, &arr_obj)?; + if env.exception_check() { + env.exception_clear(); + } + 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<()> { + 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)], + )?; + if env.exception_check() { + env.exception_clear(); + } + 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<()> { + env.call_method(stream, jni_str!("close"), jni_sig!("()V"), &[])?; + 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. +pub 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(); + call_future_complete(env, future, &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(()) +} diff --git a/crates/vespera_macro/Cargo.toml b/crates/vespera_macro/Cargo.toml index a0534ac2..6d854b66 100644 --- a/crates/vespera_macro/Cargo.toml +++ b/crates/vespera_macro/Cargo.toml @@ -29,7 +29,8 @@ serde_json = "1.0" [dev-dependencies] rstest = "0.26" -insta = "1.47" +insta = "1.48" +prettyplease = "0.2" tempfile = "3" serial_test = "3" diff --git a/crates/vespera_macro/src/args.rs b/crates/vespera_macro/src/args.rs index 67f2e8c3..a5b8b777 100644 --- a/crates/vespera_macro/src/args.rs +++ b/crates/vespera_macro/src/args.rs @@ -1,19 +1,41 @@ 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 { + #[allow(clippy::too_many_lines)] 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 responses: Option = None; + let mut success_status: Option = None; let mut tags: Option = None; + let mut security: Option = None; + let mut headers: Option> = None; + let mut operation_id: Option = None; + let mut summary: Option = None; + let mut request_example: Option = None; + let mut response_example: Option = None; + let mut deprecated = false; let mut description: Option = None; // Parse comma-separated list of arguments @@ -34,10 +56,49 @@ impl syn::parse::Parse for RouteArgs { input.parse::()?; let array: syn::ExprArray = input.parse()?; error_status = Some(array); + } else if ident_str == "responses" { + input.parse::()?; + let array: syn::ExprArray = input.parse()?; + responses = Some(array); + } else if ident_str == "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).", + )); + } + success_status = Some(code); } else if ident_str == "tags" { input.parse::()?; let array: syn::ExprArray = input.parse()?; tags = Some(array); + } else if ident_str == "security" { + input.parse::()?; + let array: syn::ExprArray = input.parse()?; + security = Some(array); + } else if ident_str == "headers" { + headers = Some(parse_header_values(input)?); + } else if ident_str == "operation_id" { + input.parse::()?; + let lit: syn::LitStr = input.parse()?; + operation_id = Some(lit); + } else if ident_str == "summary" { + input.parse::()?; + let lit: syn::LitStr = input.parse()?; + summary = Some(lit); + } else if ident_str == "request_example" { + input.parse::()?; + let lit: syn::LitStr = input.parse()?; + request_example = Some(lit); + } else if ident_str == "response_example" { + input.parse::()?; + let lit: syn::LitStr = input.parse()?; + response_example = Some(lit); + } else if ident_str == "deprecated" { + deprecated = true; } else if ident_str == "description" { input.parse::()?; let lit: syn::LitStr = input.parse()?; @@ -61,12 +122,89 @@ impl syn::parse::Parse for RouteArgs { method, path, error_status, + responses, + success_status, tags, + security, + headers, + operation_id, + summary, + request_example, + response_example, + deprecated, description, }) } } +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 description: Option = None; + + while !content.is_empty() { + let ident: syn::Ident = content.parse()?; + let ident_str = ident.to_string(); + content.parse::()?; + + match ident_str.as_str() { + "name" => name = Some(content.parse::()?.value()), + "required" => required = content.parse::()?.value, + "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)] mod tests { use rstest::rstest; @@ -276,4 +414,279 @@ 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![])] + #[case("responses = [(404)]", true, vec![])] + 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..3674a771 100644 --- a/crates/vespera_macro/src/collector.rs +++ b/crates/vespera_macro/src/collector.rs @@ -5,6 +5,11 @@ 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}, @@ -28,18 +33,41 @@ pub fn collect_metadata( 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)))?; + collect_metadata_from_files(&files, folder_path, folder_name, route_storage) +} + +/// [`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( + files: &[std::path::PathBuf], + folder_path: &Path, + folder_name: &str, + route_storage: &[StoredRouteInfo], +) -> MacroResult<(CollectedMetadata, HashMap)> { + let mut metadata = CollectedMetadata::new(); let mut file_asts = HashMap::with_capacity(files.len()); - // 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(); + // 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 +78,8 @@ pub fn collect_metadata( continue; } - let file_path = file.display().to_string(); + let mut file_path = file.display().to_string(); - // 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,18 +92,22 @@ 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(&normalize_path_key(&file_path, &cwd)) { + 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('/')) @@ -85,22 +116,44 @@ pub fn collect_metadata( }; 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)) - }); + // `#[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_item_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,78 +162,73 @@ 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('_', "-"); - - // Description priority: route attribute > doc comment - let description = route_info - .description - .clone() - .or_else(|| extract_doc_comment(&fn_item.attrs)); - - 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, - }); + route_entries.push((fn_item, route_info)); } } - } - } - Ok((metadata, file_asts)) -} + 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 = route_path.replace('_', "-"); -/// 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; + // 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: 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, + }); + } } - 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) + + Ok((metadata, file_asts)) } #[cfg(test)] @@ -210,8 +258,6 @@ mod tests { assert!(metadata.routes.is_empty()); assert!(metadata.structs.is_empty()); - - drop(temp_dir); } #[rstest] @@ -220,11 +266,11 @@ mod tests { vec![( "users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, )], "get", "/users", @@ -236,11 +282,11 @@ pub fn get_users() -> String { vec![( "create_user.rs", r#" -#[route(post)] -pub fn create_user() -> String { + #[route(post)] + pub fn create_user() -> String { "created".to_string() -} -"#, + } + "#, )], "post", "/create-user", @@ -252,11 +298,11 @@ pub fn create_user() -> String { vec![( "users.rs", r#" -#[route(get, path = "/api/users")] -pub fn get_users() -> String { + #[route(get, path = "/api/users")] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, )], "get", "/users/api/users", @@ -268,11 +314,11 @@ pub fn get_users() -> String { vec![( "users.rs", r#" -#[route(get, error_status = [400, 404])] -pub fn get_users() -> String { + #[route(get, error_status = [400, 404])] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, )], "get", "/users", @@ -284,11 +330,11 @@ pub fn get_users() -> String { vec![( "api/users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, )], "get", "/api/users", @@ -300,11 +346,11 @@ pub fn get_users() -> String { vec![( "api/v1/users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, )], "get", "/api/v1/users", @@ -339,8 +385,6 @@ pub fn get_users() -> String { .contains(first_filename.split('/').next().unwrap()) ); } - - drop(temp_dir); } #[test] @@ -351,8 +395,6 @@ pub fn get_users() -> String { let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 0); - - drop(temp_dir); } #[test] @@ -364,19 +406,17 @@ pub fn get_users() -> String { &temp_dir, "user.rs", r" -pub struct User { + 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] @@ -388,19 +428,19 @@ pub struct User { &temp_dir, "user.rs", r#" -use vespera::Schema; + use vespera::Schema; -#[derive(Schema)] -pub struct User { + #[derive(Schema)] + pub struct User { pub id: i32, pub name: String, -} + } -#[route(get)] -pub fn get_user() -> User { + #[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(); @@ -409,8 +449,6 @@ pub fn get_user() -> User { let route = &metadata.routes[0]; assert_eq!(route.function_name, "get_user"); - - drop(temp_dir); } #[test] @@ -422,27 +460,27 @@ pub fn get_user() -> User { &temp_dir, "users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} + } -#[route(post)] -pub fn create_users() -> 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 { + #[route(get)] + pub fn get_posts() -> String { "posts".to_string() -} -"#, + } + "#, ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); @@ -450,7 +488,6 @@ pub fn get_posts() -> String { 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() @@ -459,8 +496,6 @@ pub fn get_posts() -> String { assert!(function_names.contains(&"get_users")); assert!(function_names.contains(&"create_users")); assert!(function_names.contains(&"get_posts")); - - drop(temp_dir); } #[test] @@ -472,35 +507,33 @@ pub fn get_posts() -> String { &temp_dir, "user.rs", r" -use vespera::Schema; + use vespera::Schema; -#[derive(Schema)] -pub struct User { + #[derive(Schema)] + pub struct User { pub id: i32, pub name: String, -} -", + } + ", ); create_temp_file( &temp_dir, "post.rs", r" -use vespera::Schema; + use vespera::Schema; -#[derive(Schema)] -pub struct Post { + #[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] @@ -512,11 +545,11 @@ pub struct Post { &temp_dir, "mod.rs", r#" -#[route(get)] -pub fn index() -> String { + #[route(get)] + pub fn index() -> String { "index".to_string() -} -"#, + } + "#, ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); @@ -526,8 +559,6 @@ pub fn index() -> String { assert_eq!(route.function_name, "index"); assert_eq!(route.path, "/"); assert_eq!(route.module_path, "routes::"); - - drop(temp_dir); } #[test] @@ -539,11 +570,11 @@ pub fn index() -> String { &temp_dir, "users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); @@ -551,8 +582,6 @@ pub fn get_users() -> String { assert_eq!(metadata.routes.len(), 1); let route = &metadata.routes[0]; assert_eq!(route.module_path, "users"); - - drop(temp_dir); } #[test] @@ -564,11 +593,11 @@ pub fn get_users() -> String { &temp_dir, "users.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[route(get)] + pub fn get_users() -> String { "users".to_string() -} -"#, + } + "#, ); create_temp_file(&temp_dir, "config.txt", "some config content"); @@ -577,11 +606,8 @@ pub fn get_users() -> String { 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] @@ -593,21 +619,18 @@ pub fn get_users() -> String { &temp_dir, "valid.rs", r#" -#[route(get)] -pub fn get_users() -> String { + #[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] @@ -619,11 +642,11 @@ pub fn get_users() -> String { &temp_dir, "users.rs", r#" -#[route(get, error_status = [400, 404, 500])] -pub fn get_users() -> String { + #[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(); @@ -637,8 +660,6 @@ pub fn get_users() -> String { assert!(error_status.contains(&400)); assert!(error_status.contains(&404)); assert!(error_status.contains(&500)); - - drop(temp_dir); } #[test] @@ -650,27 +671,27 @@ pub fn get_users() -> String { &temp_dir, "routes.rs", r#" -#[route(get)] -pub fn get_handler() -> String { "get".to_string() } + #[route(get)] + pub fn get_handler() -> String { "get".to_string() } -#[route(post)] -pub fn post_handler() -> String { "post".to_string() } + #[route(post)] + pub fn post_handler() -> String { "post".to_string() } -#[route(put)] -pub fn put_handler() -> String { "put".to_string() } + #[route(put)] + pub fn put_handler() -> String { "put".to_string() } -#[route(patch)] -pub fn patch_handler() -> String { "patch".to_string() } + #[route(patch)] + pub fn patch_handler() -> String { "patch".to_string() } -#[route(delete)] -pub fn delete_handler() -> String { "delete".to_string() } + #[route(delete)] + pub fn delete_handler() -> String { "delete".to_string() } -#[route(head)] -pub fn head_handler() -> String { "head".to_string() } + #[route(head)] + pub fn head_handler() -> String { "head".to_string() } -#[route(options)] -pub fn options_handler() -> String { "options".to_string() } -"#, + #[route(options)] + pub fn options_handler() -> String { "options".to_string() } + "#, ); let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); @@ -685,19 +706,15 @@ pub fn options_handler() -> String { "options".to_string() } 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")); @@ -706,7 +723,6 @@ pub fn options_handler() -> String { "options".to_string() } #[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; @@ -714,20 +730,18 @@ pub fn options_handler() -> String { "options".to_string() } 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 { + #[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"); @@ -743,19 +757,14 @@ pub fn get_users() -> String { 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] @@ -779,50 +788,39 @@ pub fn get_users() -> String { // 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() } -"#, + #[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 @@ -834,41 +832,32 @@ pub fn get() -> String { "ok".to_string() } // - 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 { + #[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"; @@ -876,24 +865,20 @@ pub fn get_users() -> String { &temp_dir, "user.rs", r" -pub struct User { + 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"; @@ -901,210 +886,16 @@ pub struct User { &temp_dir, "user.rs", r" -#[derive(Debug, Clone)] -pub struct User { + #[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); } } 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..e2e8f7b3 --- /dev/null +++ b/crates/vespera_macro/src/collector/path_scan.rs @@ -0,0 +1,503 @@ +//! 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}; + +/// Normalize a path string into a comparison key **without touching +/// the filesystem** (an earlier `fs::canonicalize` version cost one +/// syscall per lookup — ~130ms for a 300-file project on Windows). +/// +/// `#[route]` records `Span::local_file()`, which rustc reports +/// relative to its invocation directory, while the collector walks +/// `{CARGO_MANIFEST_DIR}/src/{folder}` producing absolute paths with +/// platform separators. This key makes both comparable: +/// - relative paths are absolutized against `cwd` (the same process +/// working directory rustc resolved the span path from) +/// - `.`/`..` 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 = std::path::PathBuf::new(); + for comp in abs.components() { + match comp { + Component::CurDir => {} + Component::ParentDir => { + folded.pop(); + } + other => folded.push(other), + } + } + let mut key = folded.display().to_string().replace('\\', "/"); + if let Some(stripped) = key.strip_prefix("//?/") { + key = stripped.to_owned(); + } + if cfg!(windows) { + key.make_ascii_lowercase(); + } + 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)] +#[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_item_str: "pub async fn get_users() -> String { String::new() }".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_item_str: "pub async fn list_items() -> String { String::new() }".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_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]), + 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_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, + 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_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_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_item_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_item_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_item_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_item_str: "pub 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_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/file_utils.rs b/crates/vespera_macro/src/file_utils.rs index b5981249..4c00c57b 100644 --- a/crates/vespera_macro/src/file_utils.rs +++ b/crates/vespera_macro/src/file_utils.rs @@ -4,17 +4,46 @@ use std::{ }; 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 mtimes (secs 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) +} + +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() { + let mtime = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .map_or(0, |t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }); + 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 { 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..464d4f3d 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -120,6 +120,8 @@ 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 name = metadata.name.clone(); @@ -226,9 +228,10 @@ 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); @@ -296,10 +299,11 @@ 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() @@ -337,6 +341,8 @@ 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); let processed = process_vespera_input(input); let schema_storage = SCHEMA_STORAGE @@ -377,6 +383,8 @@ 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); let folder_name = dir .map(|d| d.value()) diff --git a/crates/vespera_macro/src/metadata.rs b/crates/vespera_macro/src/metadata.rs index 414816ad..54e949e4 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, 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..d513ccc4 --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/attrs.rs @@ -0,0 +1,370 @@ +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" { + return quote! { std::option::Option::None }; + } + if let Some(bytes) = parse_byte_unit(&s) { + return quote! { std::option::Option::Some(#bytes) }; + } + } + } + } + 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 +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() { + let attrs = parse_attrs(r#"#[form_data(limit = "unlimited")] pub x: String"#); + assert_eq!( + extract_limit_tokens(&attrs).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 expected = 10 * 1024 * 1024; + assert_eq!( + extract_limit_tokens(&attrs).to_string(), + format!("std :: option :: Option :: Some ({expected}usize)") + ); + } +} 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..74244c67 --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/fields.rs @@ -0,0 +1,297 @@ +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) => { + 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) + } + )?; + }); + } + } +} + +#[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..0608da8c --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/mod.rs @@ -0,0 +1,246 @@ +//! 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)? { + // 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_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")); + } +} 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..68e71438 --- /dev/null +++ b/crates/vespera_macro/src/multipart_impl/types.rs @@ -0,0 +1,184 @@ +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"]) +} + +/// 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_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..158f83c7 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -1,23 +1,31 @@ //! `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, 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. /// @@ -27,22 +35,35 @@ 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 { + 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, @@ -50,6 +71,9 @@ pub fn generate_openapi_doc_with_metadata( &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 { openapi: OpenApiVersion::V3_1_0, @@ -77,501 +101,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, +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() }) } -/// 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, - } -} - #[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 +158,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 +175,440 @@ 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()), + }, + )]); + 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_item_str: "pub async fn secure_route() -> &'static str { \"ok\" }".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())); - } - - #[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() - }); - - 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("User")); - } - - #[test] - fn test_generate_openapi_with_enum() { - let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Status".to_string(), - definition: "enum Status { Active, Inactive, Pending }".to_string(), - ..Default::default() - }); - - 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")); + insta::assert_snapshot!( + "openapi_security_schemes_and_route_security", + serde_json::to_string_pretty(&doc).unwrap() + ); } #[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() - }); + 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()), + }, + ), + ( + "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, + }, + ), + ( + "basicAuth".to_string(), + SecurityScheme { + r#type: SecuritySchemeType::Http, + description: None, + name: None, + r#in: None, + scheme: Some("basic".to_string()), + bearer_format: 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("Message")); + insta::assert_snapshot!( + "openapi_security_schemes_sorted_order", + 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 route_operation_metadata_snapshot() { let mut metadata = CollectedMetadata::new(); - metadata.structs.push(StructMetadata { - name: "Status".to_string(), - definition: "enum Status { Active, Inactive }".to_string(), - ..Default::default() - }); 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(), + 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, - tags: 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, &[]); - - // 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: 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_item_str: "pub async fn get_user() -> &'static str { \"ok\" }".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("Operation Metadata 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_route_operation_metadata", + 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 typed_route_responses_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( + "NotFoundError".to_string(), + "pub struct NotFoundError { pub message: String }".to_string(), + )); metadata.routes.push(RouteMetadata { - method: "GET".to_string(), - path: "/user".to_string(), + method: "get".to_string(), + path: "/users/{id}".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(), - error_status: None, - tags: None, + 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 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_item_str: "pub async fn get_user() -> &'static str { \"ok\" }".to_string(), + }]; + let doc = generate_openapi_doc_with_metadata( - Some("Test API".to_string()), + Some("Typed Responses 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_typed_route_responses", + 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 route_headers_and_examples_snapshot() { let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata::new( + "User".to_string(), + "pub struct User { pub name: String }".to_string(), + )); metadata.routes.push(RouteMetadata { - method: "GET".to_string(), + method: "post".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: "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, }); - 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(), + + 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, - 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, - }); + file_path: None, + fn_item_str: "pub async fn create_user(vespera::axum::Json(user): vespera::axum::Json) -> vespera::axum::Json { vespera::axum::Json(user) }".to_string(), + }]; - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + let doc = generate_openapi_doc_with_metadata( + Some("Headers API".to_string()), + Some("1.0.0".to_string()), + None, + None, + &metadata, + None, + &route_storage, + ); - 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()); + insta::assert_snapshot!( + "openapi_route_headers_and_examples", + serde_json::to_string_pretty(&doc).unwrap() + ); } - #[rstest] - // Test file read failures - #[case::route_file_read_failure( - None, - Some(RouteMetadata { - method: "GET".to_string(), + #[test] + fn tag_descriptions_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: "/nonexistent/route.rs".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, - }), - 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(), + }); + let route_storage = vec![StoredRouteInfo { + fn_name: "list_users".to_string(), + method: Some("get".to_string()), + 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, - }), - 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(), - method: Some("get".to_string()), - custom_path: None, - error_status: None, - tags: None, - description: None, - fn_item_str: "pub fn get_users() -> String { \"users\".to_string() }".to_string(), file_path: None, + fn_item_str: "pub async fn list_users() -> &'static str { \"ok\" }".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..0ab38230 --- /dev/null +++ b/crates/vespera_macro/src/openapi_generator/component_schemas.rs @@ -0,0 +1,455 @@ +//! 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, 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. +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, + struct_definitions: &HashMap, + file_cache: &HashMap, + struct_file_index: &HashMap, +) -> BTreeMap { + // 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>| + -> Option<(String, vespera_core::schema::Schema)> { + let parsed = syn::parse_str::(&struct_meta.definition).ok()?; + 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 None, + }; + if let syn::Item::Struct(struct_item) = &parsed { + process_default_functions( + struct_item, + file_ast, + &mut schema, + &struct_meta.field_defaults, + ); + } + 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) { + 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)) + }); + 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); + } + } + + 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, + }; + + 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(), + }); + + 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)); + } + + #[rstest] + #[case::non_struct_non_enum("Config", "const CONFIG: i32 = 42;")] + #[case::unparseable_definition("Invalid", "struct { invalid syntax {{{{")] + fn invalid_component_definitions_are_skipped(#[case] name: &str, #[case] definition: &str) { + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: name.to_string(), + definition: definition.to_string(), + include_in_openapi: true, + field_defaults: BTreeMap::new(), + }); + + 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 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")), + ]), + }); + 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..e1b4eb49 --- /dev/null +++ b/crates/vespera_macro/src/openapi_generator/defaults.rs @@ -0,0 +1,763 @@ +//! 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; + +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: Vec = Vec::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 + 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): 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.push(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.push(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> { + 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(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") { + // Try to parse vec![] as empty array + return Some(serde_json::Value::Array(vec![])); + } + 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::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("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 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..76fa243a --- /dev/null +++ b/crates/vespera_macro/src/openapi_generator/paths.rs @@ -0,0 +1,882 @@ +//! 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<&'a str, HashMap>; +type StorageFnStrs<'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, + 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 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_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(); + + // ROUTE_STORAGE-backed function sources (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 cwd = std::env::current_dir().unwrap_or_default(); + let storage_fn_strs = build_storage_fn_strs(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. + 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_str) = storage_fn_strs + .get(&storage_key) + .copied() + .flatten() + .or_else(|| storage_fn_strs.get(&legacy_storage_key).copied().flatten()) + { + parallel_jobs.push((idx, route_meta, fn_str)); + } else if let Some(fns) = fn_index.get(route_meta.file_path.as_str()) + && 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| + -> Option<(HttpMethod, vespera_core::route::Operation)> { + 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 + ); + return None; + }; + 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); + 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); + 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_insert_with(PathItem::default); + path_item.set_operation(method, operation); + } + + (paths, all_tags) +} + +fn build_storage_fn_strs<'a>( + route_storage: &'a [StoredRouteInfo], + fn_index: &FnIndex<'_>, + cwd: &std::path::Path, +) -> StorageFnStrs<'a> { + let mut storage = HashMap::with_capacity(route_storage.len()); + for s in route_storage { + 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 { + continue; + } + let key = ( + s.file_path + .as_deref() + .map(|path| normalize_path_key(path, cwd)), + s.fn_name.as_str(), + ); + storage + .entry(key) + .and_modify(|slot| *slot = None) + .or_insert(Some(s.fn_item_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 item 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, + ) -> Option<(HttpMethod, vespera_core::route::Operation)> + + 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<'_>, +) -> Vec { + parallel_filter_map(jobs, &|&(idx, route_meta, fn_str): &RouteJob<'_>| { + let fn_item = syn::parse_str::(fn_str).ok()?; + build_one(route_meta, &fn_item.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) -> Option + Sync), +) -> Vec { + 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(f).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 || chunk.iter().filter_map(f).collect())) + .collect(); + let mut results: Vec = Vec::with_capacity(jobs.len()); + for handle in handles { + let chunk_results: Vec = handle.join().expect("parallel macro worker panicked"); + results.extend(chunk_results); + } + results + }) +} + +#[cfg(test)] +mod tests { + 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 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. + 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_item_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_item_str: "pub fn get_users() -> String { \"users\".to_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_item_str: "pub fn list() -> String { String::new() }".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_item_str: "pub fn list() -> i32 { 1 }".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_item_str: "pub fn list() -> bool { true }".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_item_str: "pub fn list() -> bool { false }".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_skipped() { + 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 doc = generate_openapi_doc_with_metadata(None, None, None, None, &metadata, None, &[]); + + assert!(doc.paths.is_empty(), "unknown method should be skipped"); + } + + #[test] + fn unknown_method_skipped_valid_kept() { + 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 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.post.is_some(), "valid POST present"); + assert!(path_item.get.is_none(), "unknown method skipped"); + } +} 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..90cbf219 --- /dev/null +++ b/crates/vespera_macro/src/parser/extractor_validation.rs @@ -0,0 +1,640 @@ +//! 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::{BTreeSet, HashMap, HashSet}; +use std::path::Path; + +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. +pub fn validate_schema_backed_extractors(metadata: &CollectedMetadata) -> syn::Result<()> { + // Resolve each unique route file's AST once. The collector fast path can + // leave the generator's AST map empty (routes are rebuilt from ROUTE_STORAGE + // strings instead), so we read through the shared parsed-file cache — the + // same source `build_file_cache` relies on — rather than trusting a map that + // may be empty at this point. + let unique_paths: BTreeSet<&str> = metadata + .routes + .iter() + .map(|r| r.file_path.as_str()) + .collect(); + let mut file_cache: HashMap = 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)) { + file_cache.insert(path.to_string(), ast); + } + } + 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(); + + for route in &metadata.routes { + let Some(ast) = file_cache.get(&route.file_path) else { + continue; + }; + + // 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, + ); + + 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..5237090e 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,7 +7,8 @@ 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; +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, diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index bfedbd24..d62bd3b3 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -3,13 +3,35 @@ 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( @@ -17,20 +39,21 @@ pub fn build_operation_from_function( path: &str, known_schemas: &HashSet, struct_definitions: &std::collections::HashMap, - error_status: Option<&[u16]>, - tags: Option<&[String]>, + 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(); @@ -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,198 @@ 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), } +} - #[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(Box::new(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..12afc1e2 --- /dev/null +++ b/crates/vespera_macro/src/parser/operation/tests.rs @@ -0,0 +1,823 @@ +//! 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".to_string(), + "pub struct SearchParams { pub q: String }".to_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..0ce32e02 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -1,56 +1,16 @@ 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], @@ -61,376 +21,47 @@ pub fn parse_function_parameter( 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, - }; + let param_name = extract_param_name(pat.as_ref())?; + let ty = unwrap_validated_type(ty.as_ref()); - // 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(); - - // 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,7 +71,6 @@ 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::*; @@ -452,13 +82,7 @@ mod tests { known_schemas.insert("QueryParams".to_string()); struct_definitions.insert( "QueryParams".to_string(), - r" - pub struct QueryParams { - pub page: i32, - pub limit: Option, - } - " - .to_string(), + r"pub struct QueryParams { pub page: i32, pub limit: Option }".to_string(), ); } @@ -466,13 +90,7 @@ mod tests { known_schemas.insert("User".to_string()); struct_definitions.insert( "User".to_string(), - r" - pub struct User { - pub id: i32, - pub name: String, - } - " - .to_string(), + r"pub struct User { pub id: i32, pub name: String }".to_string(), ); } @@ -480,122 +98,26 @@ mod tests { } #[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>, @@ -634,56 +156,30 @@ 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; + let (mut known_schemas, mut struct_definitions) = setup_test_data(func_src); 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(); for (idx, arg) in func.sig.inputs.iter().enumerate() { @@ -700,585 +196,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..96e5e681 --- /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, + struct_definitions: &HashMap, +) -> 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, + struct_definitions: &HashMap, +) -> 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..f03a9641 --- /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, + struct_definitions: &HashMap, +) -> 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, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> 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 = path_params.iter().cloned().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..e94687c6 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters/query.rs @@ -0,0 +1,400 @@ +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, +}; + +pub(super) fn parse_query_extractor( + param_name: &str, + ty: &Type, + known_schemas: &HashSet, + struct_definitions: &HashMap, +) -> 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, + struct_definitions: &HashMap, +) -> 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) + && 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 = matches!( + field_type, + Type::Path(type_path) + if type_path + .path + .segments + .first() + .is_some_and(|s| s.ident == "Option") + ); + // #[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".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 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".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(); + 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".to_string(), + r"pub struct OptionalQuery { pub required: i32, pub optional: Option }" + .to_string(), + ); + 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".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, + &HashMap::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::new(), &HashMap::new()).is_none()); + } + + #[test] + fn schema_ref_to_inline_conversion_optional() { + let mut struct_definitions = HashMap::new(); + struct_definitions.insert( + "QueryWithOptional".to_string(), + r"pub struct QueryWithOptional { pub count: Option }".to_string(), + ); + + let ty: Type = syn::parse_str("QueryWithOptional").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &HashSet::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".to_string(), + r"pub struct QueryWithRef { pub item: RefType }".to_string(), + ); + known_schemas.insert("RefType".to_string()); + + 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".to_string(), + r"pub struct QueryWithNested { pub nested: NestedType }".to_string(), + ); + 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 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".to_string(), + r"pub struct FilterParams { pub status: Status, pub page: i32 }".to_string(), + ); + 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 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".to_string(), + r"pub struct Paged { + #[serde(default)] + pub page: i32, + pub q: String, + }" + .to_string(), + ); + let ty: Type = syn::parse_str("Paged").unwrap(); + let params = parse_query_struct_to_parameters(&ty, &HashSet::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".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 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..e7426796 --- /dev/null +++ b/crates/vespera_macro/src/parser/parameters/shared.rs @@ -0,0 +1,210 @@ +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, + struct_definitions: &HashMap, +) -> 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) || known_schemas.contains(&ident_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".to_string(), "pub struct User { id: i32 }".to_string()); + map + }, true)] + #[case("Product", { + let mut set = HashSet::new(); + set.insert("Product".to_string()); + 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, + #[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 + ); + } + + #[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..e87a257f 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 { @@ -28,7 +28,8 @@ pub fn parse_request_body( 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..6254e70c 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -90,7 +90,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 +106,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") + } +} + /// Analyze return type and convert to Responses map #[allow(clippy::too_many_lines)] pub fn parse_return_type( @@ -139,6 +217,7 @@ pub fn parse_return_type( ); } ReturnType::Type(_, ty) => { + let (ok_content_type, err_content_type) = response_content_types(ty); // Check if it's a Result if let Some((ok_ty, err_ty)) = extract_result_types(ty) { // Handle success response (200) @@ -155,7 +234,7 @@ pub fn parse_return_type( ); let mut content = BTreeMap::new(); content.insert( - "application/json".to_string(), + ok_content_type.to_string(), MediaType { schema: Some(ok_schema), example: None, @@ -185,7 +264,7 @@ pub fn parse_return_type( ); let mut err_content = BTreeMap::new(); err_content.insert( - "application/json".to_string(), + err_content_type.to_string(), MediaType { schema: Some(err_schema), example: None, @@ -212,7 +291,7 @@ pub fn parse_return_type( ); let mut err_content = BTreeMap::new(); err_content.insert( - "application/json".to_string(), + err_content_type.to_string(), MediaType { schema: Some(err_schema), example: None, @@ -245,7 +324,7 @@ pub fn parse_return_type( ); let mut c = BTreeMap::new(); c.insert( - "application/json".to_string(), + ok_content_type.to_string(), MediaType { schema: Some(schema), example: None, @@ -486,9 +565,7 @@ mod tests { .content .as_ref() .expect("ok content should exist"); - let media_type = content - .get("application/json") - .expect("ok media type 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); } @@ -511,7 +588,8 @@ mod tests { .as_ref() .expect("error content should exist"); let media_type = content - .get("application/json") + .values() + .next() .expect("error media type should exist"); let schema_ref = media_type .schema @@ -522,6 +600,42 @@ mod tests { } } + #[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] diff --git a/crates/vespera_macro/src/parser/schema/enum_schema.rs b/crates/vespera_macro/src/parser/schema/enum_schema.rs index c43a9520..17747557 100644 --- a/crates/vespera_macro/src/parser/schema/enum_schema.rs +++ b/crates/vespera_macro/src/parser/schema/enum_schema.rs @@ -1,97 +1,63 @@ -//! 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::collections::{HashMap, HashSet}; -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, ) -> 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( - enum_item, - enum_description, - rename_all.as_deref(), - known_schemas, - struct_definitions, - ), - SerdeEnumRepr::InternallyTagged { tag } => parse_internally_tagged_enum( + SerdeEnumRepr::ExternallyTagged => representations::parse_externally_tagged_enum( enum_item, enum_description, rename_all.as_deref(), - &tag, known_schemas, struct_definitions, ), - SerdeEnumRepr::AdjacentlyTagged { tag, content } => parse_adjacently_tagged_enum( + SerdeEnumRepr::InternallyTagged { tag } => representations::parse_internally_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 +67,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( @@ -704,7 +129,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); }); } @@ -798,7 +223,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); }); } @@ -1181,557 +606,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..a7083e02 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/enum_schema/representations.rs @@ -0,0 +1,934 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +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, + 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. +pub(super) 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. +pub(super) 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. +pub(super) 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 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..56e26716 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/enum_schema/variant.rs @@ -0,0 +1,148 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use syn::Type; +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, +}; + +/// 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, + 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) +pub(super) 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() + }))) + } + } +} diff --git a/crates/vespera_macro/src/parser/schema/serde_attrs.rs b/crates/vespera_macro/src/parser/schema/serde_attrs.rs index 1592d46d..f891ca14 100644 --- a/crates/vespera_macro/src/parser/schema/serde_attrs.rs +++ b/crates/vespera_macro/src/parser/schema/serde_attrs.rs @@ -1,2192 +1,18 @@ -//! 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, + extract_skip_serializing_if, +}; +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..63bd2e12 --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/extract.rs @@ -0,0 +1,450 @@ +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 _ = 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(); + + 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 _ = 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(); + 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 _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("skip") { + has_skip = true; + } else if meta.path.is_ident("skip_serializing") { + has_skip_serializing = true; + } else if meta.path.is_ident("skip_deserializing") { + has_skip_deserializing = true; + } + Ok(()) + }); + if has_skip || (has_skip_serializing && has_skip_deserializing) { + return true; + } + + 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 _ = 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(); + if contains_standalone_word(&tokens, "flatten") { + 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` +/// 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 +} + +#[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_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}"); + } + } +} 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..134f85de --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/serde_attrs/fallback.rs @@ -0,0 +1,733 @@ +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_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); + } +} 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..a6cfe384 100644 --- a/crates/vespera_macro/src/parser/schema/struct_schema.rs +++ b/crates/vespera_macro/src/parser/schema/struct_schema.rs @@ -430,6 +430,26 @@ mod tests { 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] diff --git a/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index 707e7c38..d83789b5 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -1,422 +1,21 @@ -//! 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}; +//! Type to JSON Schema conversion for OpenAPI generation. -/// 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; - -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::SchemaRef; use vespera_core::schema::SchemaType; + use super::conversion::{MAX_SCHEMA_RECURSION_DEPTH, SCHEMA_RECURSION_DEPTH}; use super::*; #[rstest] @@ -1193,313 +792,4 @@ mod tests { 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..9d8f159c --- /dev/null +++ b/crates/vespera_macro/src/parser/schema/type_schema/conversion.rs @@ -0,0 +1,728 @@ +//! 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. +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, +}; + +/// 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"), + // `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) { + 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 super::*; + // ========== 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/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..eaa16a73 100644 --- a/crates/vespera_macro/src/route_impl.rs +++ b/crates/vespera_macro/src/route_impl.rs @@ -34,7 +34,7 @@ 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,10 +53,28 @@ 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+). @@ -114,6 +132,70 @@ fn extract_tag_strings(arr: &syn::ExprArray) -> Option> { if tags.is_empty() { None } else { Some(tags) } } +/// Extract security scheme names from a `syn::ExprArray`. +/// +/// Unlike tags, an empty array is meaningful: `security = []` disables +/// inherited/global security for that operation in OpenAPI. +fn extract_security_strings(arr: &syn::ExprArray) -> Vec { + arr.elems + .iter() + .filter_map(|elem| { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = elem + { + Some(lit_str.value()) + } else { + None + } + }) + .collect() +} + +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 responses.is_empty() { + None + } else { + Some(responses) + } +} + /// Validate route function - must be pub and async pub fn validate_route_fn(item_fn: &syn::ItemFn) -> Result<(), syn::Error> { if !matches!(item_fn.vis, syn::Visibility::Public(_)) { @@ -145,11 +227,29 @@ pub fn process_route_attribute( 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), + typed_responses: route_args + .responses + .as_ref() + .and_then(extract_typed_responses), tags: route_args.tags.as_ref().and_then(extract_tag_strings), + security: route_args.security.as_ref().map(extract_security_strings), + 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() @@ -366,6 +466,7 @@ 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.headers.is_empty()); assert!(stored.fn_item_str.contains("get_user_test_storage")); } @@ -392,6 +493,7 @@ mod tests { assert_eq!(stored.tags, None); assert_eq!(stored.description, None); assert_eq!(stored.error_status, None); + assert!(stored.headers.is_empty()); } #[test] 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..802d1f85 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/docs.rs @@ -0,0 +1,87 @@ +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, + 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()) + })) + ) + } +} + +#[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..495e7bd6 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/export.rs @@ -0,0 +1,93 @@ +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" => { + 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"); + } +} 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..d1fe0241 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/generator.rs @@ -0,0 +1,1007 @@ +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! { + __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); + } + + #[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() { + // 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(), + 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(); + + // 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(), + 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}" + ); + // Invalid route should be skipped + 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..62cc9bf0 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/input.rs @@ -0,0 +1,603 @@ +use proc_macro2::Span; +use std::collections::{BTreeMap, HashMap}; +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; + + 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)?); + } + "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; + + while !content.is_empty() { + let ident: syn::Ident = content.parse()?; + let ident_str = ident.to_string(); + 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; + + while !content.is_empty() { + let (field_name, span) = parse_security_field_name(&content)?; + 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()), + _ => { + return Err(syn::Error::new( + span, + format!( + "unknown security scheme field: `{field_name}`. Expected `name`, `type`, `description`, `header_name`, `in`, `scheme`, or `bearer_format`" + ), + )); + } + } + + 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.", + ) + })?; + + Ok(SecuritySchemeConfig { + name, + scheme: SecurityScheme { + r#type: scheme_type, + description, + name: header_name, + r#in: location, + scheme, + bearer_format, + }, + }) +} + +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; + + 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>, + 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..7d321b5f --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/input_tests.rs @@ -0,0 +1,522 @@ +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" + ); + } +} diff --git a/crates/vespera_macro/src/router_codegen/process.rs b/crates/vespera_macro/src/router_codegen/process.rs new file mode 100644 index 00000000..3f7193f9 --- /dev/null +++ b/crates/vespera_macro/src/router_codegen/process.rs @@ -0,0 +1,106 @@ +//! Normalisation of [`AutoRouterInput`] into a builder-friendly form. +//! +//! [`ProcessedVesperaInput`] is the value [`crate::vespera_impl`] consumes when +//! orchestrating the `vespera!` macro — defaults are filled in here so the +//! orchestrator can stay agnostic about parse details. + +use vespera_core::openapi::Server; + +use super::input::AutoRouterInput; + +/// 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(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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())); + } +} diff --git a/crates/vespera_macro/src/schema_macro/circular.rs b/crates/vespera_macro/src/schema_macro/circular.rs index 70cbfe9f..e987772b 100644 --- a/crates/vespera_macro/src/schema_macro/circular.rs +++ b/crates/vespera_macro/src/schema_macro/circular.rs @@ -292,114 +292,82 @@ pub fn generate_inline_type_construction( #[cfg(test)] mod tests { + 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![] // 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 - )] + #[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(std::string::ToString::to_string) - .collect(); - let result = analyze_circular_refs(&module_path, related_schema_def).circular_fields; - assert_eq!(result, expected); + 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() { - let result = - analyze_circular_refs(&["crate".to_string()], "not valid rust").circular_fields; - assert!(result.is_empty()); + assert!( + analyze_circular_refs(&["crate".to_string()], "not valid rust") + .circular_fields + .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()); + 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, - }", + r"pub struct Model { pub id: i32, pub user: BelongsTo, }", true )] #[case( - r"pub struct Model { - pub id: i32, - pub user: HasOne, - }", + 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 name: String, - }", + r"pub struct Model { pub id: i32, pub items: HasMany, }", 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, @@ -421,106 +389,104 @@ mod tests { #[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) - ); + assert!(!required("not valid rust", "user")); } #[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) - ); + assert!(!required("pub struct TupleStruct(i32, String);", "user")); } #[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) - ); + 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(); - 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 ! []")); + 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(); - 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")); + 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(); - 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 ()")); + 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() { - 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")); + 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() { - let schema_path = quote! { user::Schema }; - let tokens = generate_inline_struct_construction( - &schema_path, - "pub struct TupleStruct(i32, String);", - &[], - "model", + assert!( + generate_inline_struct_construction( + "e! { user::Schema }, + "pub struct TupleStruct(i32, String);", + &[], + "model" + ) + .to_string() + .contains("From") ); - 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, - }", + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, pub name: String, }", &[], "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("user :: Schema")); assert!(output.contains("id : r . id")); assert!(output.contains("name : r . name")); @@ -528,17 +494,13 @@ mod tests { #[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, - }", + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, pub memos: HasMany, }", &["memos".to_string()], "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("user :: Schema")); assert!(output.contains("id : r . id")); assert!(output.contains("memos : vec ! []")); @@ -546,62 +508,54 @@ mod tests { #[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, - }", + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, #[serde(skip)] pub internal: String, }", &[], "r", - ); - let output = tokens.to_string(); + ) + .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", + assert!( + generate_inline_type_construction( + &ident("TestInline"), + &["id".to_string()], + "not valid rust", + "model" + ) + .to_string() + .contains("Default :: default ()") ); - 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", + assert!( + generate_inline_type_construction( + &ident("TestInline"), + &["id".to_string()], + "pub struct TupleStruct(i32, String);", + "model" + ) + .to_string() + .contains("Default :: default ()") ); - 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, + 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"pub struct Model { pub id: i32, pub name: String, pub email: String, }", "r", - ); - let output = tokens.to_string(); + ) + .to_string(); assert!(output.contains("UserInline")); assert!(output.contains("id : r . id")); assert!(output.contains("name : r . name")); @@ -610,266 +564,192 @@ mod tests { #[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, + 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"pub struct Model { pub id: i32, pub memos: HasMany, }", "r", - ); - let output = tokens.to_string(); + ) + .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); + 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() { - // 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); + 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() { - // 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); + assert!(!required( + r"pub struct Model { pub id: i32, pub name: String, }", + "name" + )); } #[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); + assert!(!required( + r"pub struct Model { pub id: i32, }", + "nonexistent_field" + )); } - // 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")); + 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(); - 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")); + 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(); - 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")); + assert!( + generate_default_for_relation_field( + &ty, + &ident("user"), + &[], + &fields("{ pub id: i32 }") + ) + .to_string() + .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()); + assert!( + analyze_circular_refs(&[], "pub struct Schema { pub id: i32 }") + .circular_fields + .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()]); + 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() { - // 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()]); + 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() { - // 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()); + 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() + ); } - // 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(); + 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")); - // 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, - }", + let output = generate_inline_struct_construction( + "e! { user::Schema }, + r"pub struct UserSchema { pub id: i32, pub profile: HasOne, }", &[], "r", - ); - let output = tokens.to_string(); + ) + .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, + 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"pub struct Model { pub id: i32, #[serde(skip)] pub internal: String, }", "r", - ); - let output = tokens.to_string(); + ) + .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, - }", + let output = generate_inline_type_construction( + &ident("EmptyInline"), + &[], + r"pub struct Model { pub id: i32, pub name: String, }", "r", - ); - let output = tokens.to_string(); - // Should produce empty struct construction + ) + .to_string(); assert!(output.contains("EmptyInline")); assert!(!output.contains("id : r . id")); assert!(!output.contains("name : r . name")); @@ -877,110 +757,61 @@ mod tests { #[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, - }", + 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", - ); - let output = tokens.to_string(); + ) + .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); + 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() { - // 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); + 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() { - // 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); + 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() { - // 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); + assert!(!required( + r#"pub struct Model { pub id: i32, #[sea_orm(from = "nonexistent_field")] pub user: BelongsTo, }"#, + "user" + )); } - // 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 + 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")); } @@ -988,14 +819,14 @@ mod tests { #[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 + 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")); } @@ -1003,15 +834,14 @@ mod tests { #[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 + 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/defaults.rs b/crates/vespera_macro/src/schema_macro/defaults.rs new file mode 100644 index 00000000..044649a9 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/defaults.rs @@ -0,0 +1,849 @@ +//! 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()); + + default_functions.push(quote! { + #[allow(non_snake_case)] + fn #fn_ident() -> #field_ty { + #value.parse().unwrap() + } + }); + + 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()) +} + +#[cfg(test)] +mod tests { + 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() + } + + // ====================================== + // 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")); + } +} diff --git a/crates/vespera_macro/src/schema_macro/file_cache.rs b/crates/vespera_macro/src/schema_macro/file_cache.rs index 59313549..2802567e 100644 --- a/crates/vespera_macro/src/schema_macro/file_cache.rs +++ b/crates/vespera_macro/src/schema_macro/file_cache.rs @@ -10,6 +10,20 @@ //! 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 top-level macro invocation +//! (`vespera!`, `schema_type!`) 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. +//! +//! 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; @@ -17,6 +31,26 @@ 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) +} + use super::circular::CircularAnalysis; use super::file_lookup::collect_rs_files_recursive; use crate::metadata::StructMetadata; @@ -24,7 +58,10 @@ use crate::metadata::StructMetadata; /// Internal cache state. struct FileCache { /// Cached `.rs` file lists per source directory. - file_lists: HashMap>, + /// + /// `Arc<[PathBuf]>` so cache hits hand out an O(1) pointer clone + /// instead of cloning every path in the list. + file_lists: HashMap>, /// Cached file contents: file path → (mtime, content string). /// Mtime is checked to invalidate stale entries in long-lived processes. @@ -37,7 +74,8 @@ struct FileCache { /// 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>, + /// `Arc<[PathBuf]>` for O(1) cache-hit clones. + struct_candidates: HashMap<(PathBuf, String), Arc<[PathBuf]>>, // NOTE: We CANNOT cache `syn::File` or `syn::ItemStruct` across proc-macro // invocations. Both `syn` and `proc_macro2` types contain `proc_macro::Span` @@ -63,9 +101,11 @@ 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: path_str → Option>. /// `None` values are cached (negative cache) to avoid repeated failed lookups. - struct_lookup: HashMap>, + /// `Arc` because `StructMetadata.definition` holds the full struct + /// source text — cloning it per hit copied kilobytes. + struct_lookup: HashMap>>, /// Cached FK column lookups: (schema_path, via_rel) → Option. fk_column_lookup: HashMap<(String, String), Option>, /// Cached module path extraction from schema paths: path_str → Vec. @@ -83,6 +123,17 @@ 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 + /// top-level macro invocation (`vespera!`, `schema_type!`). + epoch: u64, + /// Per-epoch mtime cache: path → (epoch_when_checked, mtime_result). + /// + /// When the stored epoch equals `self.epoch`, the mtime 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! { @@ -105,9 +156,47 @@ thread_local! { module_path_cache_hits: 0, struct_definitions: HashMap::with_capacity(32), struct_def_cache_hits: 0, + epoch: 0, + mtime_epoch_cache: HashMap::with_capacity(32), + }); +} + +/// Advance the per-invocation epoch counter. +/// +/// Call this **once** at the start of each top-level macro invocation +/// (`vespera!`, `schema_type!`). 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 mtime for `path`, using the epoch cache to avoid redundant +/// `fs::metadata` syscalls within a single macro invocation. +/// +/// Returns `None` if the file does not exist or its mtime is unavailable. +fn get_mtime_cached(cache: &mut FileCache, path: &Path) -> Option { + let current_epoch = cache.epoch; + if let Some(&(entry_epoch, mtime)) = cache.mtime_epoch_cache.get(path) + && entry_epoch == current_epoch + { + return mtime; + } + #[cfg(test)] + METADATA_CALL_COUNT.with(|c| c.set(c.get() + 1)); + let mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()); + cache + .mtime_epoch_cache + .insert(path.to_path_buf(), (current_epoch, mtime)); + mtime +} + /// Get `CARGO_MANIFEST_DIR` from cache, or read from env and cache. /// /// Within a single compilation, this value never changes. Caching avoids @@ -153,37 +242,37 @@ fn parse_file_cached(cache: &mut FileCache, path: &Path) -> Option { /// 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 { +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(); + return Arc::clone(candidates); } - // Ensure file list is cached - let files = if let Some(files) = cache.file_lists.get(src_dir) { - files.clone() + let files: Arc<[PathBuf]> = if let Some(files) = cache.file_lists.get(src_dir) { + Arc::clone(files) } else { let mut files = Vec::new(); collect_rs_files_recursive(src_dir, &mut files); + let files: Arc<[PathBuf]> = files.into(); cache .file_lists - .insert(src_dir.to_path_buf(), files.clone()); + .insert(src_dir.to_path_buf(), Arc::clone(&files)); files }; - // Filter using cheap text search, caching file contents along the way - let candidates: Vec = files - .into_iter() + let candidates: Arc<[PathBuf]> = files + .iter() .filter(|path| { let content = get_file_content_inner(&mut cache, path); content.is_some_and(|c| c.contains(struct_name)) }) + .cloned() .collect(); - cache.struct_candidates.insert(key, candidates.clone()); + cache.struct_candidates.insert(key, Arc::clone(&candidates)); candidates }) } @@ -191,7 +280,7 @@ pub fn get_struct_candidates(src_dir: &Path, struct_name: &str) -> Vec /// On first call, parses the file and caches all struct definitions as strings. /// On subsequent calls, checks mtime 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_mtime = get_mtime_cached(cache, path); if let Some(mtime) = current_mtime && let Some((cached_mtime, _)) = cache.struct_definitions.get(path) @@ -201,8 +290,6 @@ fn ensure_struct_definitions(cache: &mut FileCache, path: &Path) -> bool { 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; }; @@ -254,7 +341,7 @@ pub fn get_struct_definition(path: &Path, struct_name: &str) -> Option { /// 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_mtime = get_mtime_cached(cache, path); if let Some(mtime) = current_mtime && let Some((cached_mtime, content)) = cache.file_contents.get(path) @@ -264,7 +351,6 @@ fn get_file_content_inner(cache: &mut FileCache, path: &Path) -> Option Result CircularAnalysis { let key = (source_module_path.join("::"), definition.to_string()); - // 1. Check cache — borrow dropped at end of closure + // 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; } - // 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() @@ -323,20 +407,21 @@ pub fn get_circular_analysis(source_module_path: &[String], definition: &str) -> /// 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 +/// 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. +pub fn get_struct_from_schema_path(path_str: &str) -> Option> { + // The borrow must end before lookup: lookup re-enters FILE_CACHE. 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); + let result = super::file_lookup::find_struct_from_schema_path(path_str).map(Arc::new); - // 3. Store — new borrow FILE_CACHE.with(|cache| { cache .borrow_mut() @@ -354,17 +439,15 @@ pub fn get_struct_from_schema_path(path_str: &str) -> Option { 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 + // The borrow must end before lookup: lookup re-enters FILE_CACHE. 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() @@ -383,26 +466,20 @@ pub fn get_fk_column(schema_path: &str, via_rel: &str) -> Option { 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) + result.pop(); - // 3. Store — new borrow FILE_CACHE.with(|cache| { cache .borrow_mut() @@ -542,21 +619,16 @@ mod tests { ) .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); } @@ -564,24 +636,148 @@ mod tests { #[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(); } + + /// 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" + ); + } + + /// 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" + ); + } } diff --git a/crates/vespera_macro/src/schema_macro/file_lookup.rs b/crates/vespera_macro/src/schema_macro/file_lookup.rs index 53f87f04..b47c6f59 100644 --- a/crates/vespera_macro/src/schema_macro/file_lookup.rs +++ b/crates/vespera_macro/src/schema_macro/file_lookup.rs @@ -1,1647 +1,358 @@ -//! File system operations for finding struct definitions -//! -//! Provides functions to locate struct definitions in source files. +//! File system operations for finding struct definitions. -use std::path::Path; +mod fk; +mod lookup; -use syn::Type; +#[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_by_name_in_all_files, find_struct_from_path, find_struct_from_schema_path, +}; -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()) }; - - 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(); - - let original = std::env::var("CARGO_MANIFEST_DIR").ok(); - unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) }; + std::fs::write(models_dir.join("memo.rs"), memo_model).unwrap(); - 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() { - // Tests: Model exists but no field matches the via_rel - 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 relation + 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 memos: 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 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(), - "Non-matching relation_enum 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_tuple_struct() { - // Tests: Model is a tuple struct (not named fields) -> skip + 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 tuple struct Model - let model = "pub struct Model(i32, String);"; - std::fs::write(models_dir.join("tuple.rs"), model).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(); - 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::tuple::Schema", "SomeRelation"); + // 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 { + 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(), "Tuple struct Model should return None"); + 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] #[serial] - fn test_find_fk_column_from_target_entity_field_no_from_attr() { - // Tests: Field matches relation_enum but has no `from` attribute + 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 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(); - - // 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" - ); - } + fn test_generate_schema_type_code_cross_module_json_alias_uses_public_path() { + use tempfile::TempDir; - #[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..7f7d47dd --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/file_lookup/fk.rs @@ -0,0 +1,493 @@ +//! 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 { + if !file_path.exists() { + continue; + } + + 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..cf25591b --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/file_lookup/lookup.rs @@ -0,0 +1,879 @@ +//! Struct lookup/search helpers. + +use std::path::{Path, PathBuf}; + +use syn::Type; + +use crate::metadata::StructMetadata; + +/// 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. +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 = crate::schema_macro::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) = + crate::schema_macro::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. `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 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) = + 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); + 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 = 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("::").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) = + 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 { + if !file_path.exists() { + continue; + } + 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 { + 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_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..154f551d --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/generate.rs @@ -0,0 +1,826 @@ +//! 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; +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| { + 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 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/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__belongs_to_optional_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_optional.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__circular_has_one_required.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/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/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/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/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/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/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/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/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/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/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/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_circular.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_enum_fk_found.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/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/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_fk_no_circular.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_inline_type.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_many_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/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/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/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/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/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_inline_type.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_optional_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__has_one_required_simple.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__inline_type_required_belongs_to.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__no_relations.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/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/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/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/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/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_all_relation_types.snap b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_all_relation_types.snap new file mode 100644 index 00000000..082139c0 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/from_model/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_all_relation_types.snap @@ -0,0 +1,52 @@ +--- +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: None, + 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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__parent_stub_required_circular.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__relation_field_not_in_mappings.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_type.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__unknown_relation_with_inline_type.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/snapshots/vespera_macro__schema_macro__from_model__generate__tests__wrapped_field.snap b/crates/vespera_macro/src/schema_macro/from_model/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/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/generate_type.rs b/crates/vespera_macro/src/schema_macro/generate_type.rs new file mode 100644 index 00000000..40f3b29b --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/generate_type.rs @@ -0,0 +1,774 @@ +//! `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; +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_qualified_path, is_seaorm_model, + is_seaorm_relation_type, +}; +use super::validation::{ + extract_source_field_names, 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). + 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)" + ), + )); + } + }; + + // 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, + )?; + 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, + )); + } + } + } + } + + // 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..53a6b007 100644 --- a/crates/vespera_macro/src/schema_macro/inline_types.rs +++ b/crates/vespera_macro/src/schema_macro/inline_types.rs @@ -261,424 +261,275 @@ pub fn generate_inline_type_definition(inline_type: &InlineRelationType) -> Toke #[cfg(test)] mod tests { + use rstest::rstest; 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(), - }; + // ── Test support ───────────────────────────────────────────────────── - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); + /// 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) + } - 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")); + /// 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, + } } - #[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(), - }; + /// 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(), + } + } - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.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, + } + } - assert!(output.contains("TestType")); - assert!(output.contains("snake_case")); + /// 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 } - #[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(), - }; + const MEMO_MODULE: [&str; 3] = ["crate", "models", "memo"]; - let tokens = generate_inline_type_definition(&inline_type); - let output = tokens.to_string(); + fn module_path(segments: &[&str]) -> Vec { + segments.iter().map(ToString::to_string).collect() + } - assert!(output.contains("pub struct EmptyType")); - assert!(output.contains("Clone")); - assert!(output.contains("vespera :: Schema")); + /// 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 } - #[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![ + // ── 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")]), ], - }], - 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![], - }, + )], + ) + )] + #[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![], + ), ], - 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 <")); + ) + )] + #[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 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"])], - }; - + 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 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(), - }; - + 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()); } - #[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")); - } + // ── generate_inline_relation_type_from_def ────────────────────────── #[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 + 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( - &parent_type_name, - &rel_info, - &source_module_path, + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), None, model_def, ); - // HasMany is not considered circular, so should return None - assert!(result.is_none()); + assert!(result.is_none(), "HasMany back-references are not circular"); + } - // Test with BelongsTo instead (which IS considered circular) - let model_def_with_belongs_to = r"pub struct Model { + #[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( - &parent_type_name, - &rel_info, - &source_module_path, + &syn::Ident::new("MemoSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), None, - model_def_with_belongs_to, - ); - assert!(result.is_some()); + model_def, + ) + .expect("BelongsTo back-reference is circular"); - 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())); + assert_eq!(result.type_name.to_string(), "MemoSchema_User"); + assert_eq!(field_names(&result), ["id", "name"]); } #[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 + 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( - &parent_type_name, - &rel_info, - &source_module_path, + &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 needed + assert!(result.is_none(), "no circular fields means no inline type"); } #[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(), - ]; - + fn from_def_schema_name_override_names_the_inline_type() { 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, + &syn::Ident::new("Schema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&MEMO_MODULE), 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 + ) + .expect("circular reference present"); + assert_eq!(result.type_name.to_string(), "MemoSchema_User"); } #[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()]; - + fn from_def_invalid_model_source_returns_none() { let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, + &syn::Ident::new("TestSchema", proc_macro2::Span::call_site()), + &rel("user", "BelongsTo", quote!(super::user::Schema)), + &module_path(&["crate"]), None, "invalid rust code", ); @@ -686,26 +537,7 @@ mod tests { } #[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 + fn from_def_skips_every_relation_typed_field() { let model_def = r"pub struct Model { pub id: i32, pub name: String, @@ -713,53 +545,23 @@ mod tests { pub posts: HasMany, pub profile: HasOne }"; - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, + &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" ); - 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 + fn from_def_skips_serde_skip_fields() { let model_def = r"pub struct Model { pub id: i32, #[serde(skip)] @@ -767,385 +569,103 @@ mod tests { pub name: String, pub memo: BelongsTo }"; - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, + &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_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 + ) + .expect("circular reference present"); + assert_eq!(field_names(&result), ["id", "name"]); } #[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, - }; - + fn from_def_converts_datetime_types() { let model_def = r"pub struct Model { pub id: i32, - pub title: String + pub name: String, + pub created_at: DateTimeWithTimeZone, + pub memo: BelongsTo }"; - - // With schema_name_override - let result = generate_inline_relation_type_no_relations_from_def( - &parent_type_name, - &rel_info, - &[], - Some("UserSchema"), + 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_some()); + ) + .expect("circular reference present"); - 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 + let created_at = result .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())); + .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()); } - #[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())); - } + // ── generate_inline_relation_type_no_relations_from_def ───────────── #[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"); - } - } + 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"); - // Should return None when file not found - assert!(result.is_none()); + assert_eq!(result.type_name.to_string(), "UserSchema_Memos"); + assert_eq!(field_names(&result), ["id", "title"]); } #[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()); + 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 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 + fn no_relations_from_def_schema_name_override_names_the_inline_type() { let model_def = r"pub struct Model { pub id: i32, - pub name: String, - pub created_at: DateTimeWithTimeZone, - pub memo: BelongsTo + pub title: String }"; - - let result = generate_inline_relation_type_from_def( - &parent_type_name, - &rel_info, - &source_module_path, - None, + 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, - ); - 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}" - ); + ) + .expect("plain fields remain"); + assert_eq!(result.type_name.to_string(), "UserSchema_Memos"); } #[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 + fn no_relations_from_def_converts_datetime_types() { let model_def = r"pub struct Model { pub id: i32, pub title: String, @@ -1153,46 +673,140 @@ pub struct Model { pub updated_at: Option, pub user: BelongsTo }"; - let result = generate_inline_relation_type_no_relations_from_def( - &parent_type_name, - &rel_info, + &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"), + ) ); - assert!(result.is_some()); + } - let inline_type = result.unwrap(); + // ── File-lookup variants (CARGO_MANIFEST_DIR + temp project) ──────── - // 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"); + #[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"); - let ty_str = created_at_field.ty.to_string(); - assert!( - ty_str.contains("vespera :: chrono :: DateTime"), - "DateTimeWithTimeZone should be converted, got: {ty_str}" - ); + assert_eq!(result.type_name.to_string(), "MemoSchema_User"); + assert_eq!(field_names(&result), ["id", "name"]); + } - // Also check Option - let updated_at_field = inline_type - .fields - .iter() - .find(|f| f.name == "updated_at") - .expect("updated_at field should exist"); + #[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"); - 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}" - ); + 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/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..105eff69 --- /dev/null +++ b/crates/vespera_macro/src/schema_macro/same_file_override.rs @@ -0,0 +1,521 @@ +//! 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 { + let schema_path_str = schema_path.to_string().replace("Schema", "Model"); + syn::parse_str(&schema_path_str).ok() +} + +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 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/snapshots/vespera_macro__schema_macro__inline_types__tests__complex_field_types.snap b/crates/vespera_macro/src/schema_macro/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/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/snapshots/vespera_macro__schema_macro__inline_types__tests__doc_attribute.snap b/crates/vespera_macro/src/schema_macro/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/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/snapshots/vespera_macro__schema_macro__inline_types__tests__empty_fields.snap b/crates/vespera_macro/src/schema_macro/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/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/snapshots/vespera_macro__schema_macro__inline_types__tests__field_attr_rename_snake_case.snap b/crates/vespera_macro/src/schema_macro/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/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/snapshots/vespera_macro__schema_macro__inline_types__tests__from_def_created_at_type.snap b/crates/vespera_macro/src/schema_macro/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/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/snapshots/vespera_macro__schema_macro__inline_types__tests__multiple_field_attrs_pascal_case.snap b/crates/vespera_macro/src/schema_macro/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/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/snapshots/vespera_macro__schema_macro__inline_types__tests__no_relations_datetime_types.snap b/crates/vespera_macro/src/schema_macro/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/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/snapshots/vespera_macro__schema_macro__inline_types__tests__two_plain_fields_camel_case.snap b/crates/vespera_macro/src/schema_macro/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/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/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..543faf81 100644 --- a/crates/vespera_macro/src/schema_macro/transformation.rs +++ b/crates/vespera_macro/src/schema_macro/transformation.rs @@ -440,3 +440,450 @@ mod tests { )); } } + +#[cfg(test)] +mod schema_type_option_tests { + 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")); + } + + // 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) + } +} 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..bf697775 --- /dev/null +++ b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_route_operation_metadata.snap @@ -0,0 +1,56 @@ +--- +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 + } + } + }, + "components": {}, + "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..d0ff3ea3 --- /dev/null +++ b/crates/vespera_macro/src/snapshots/vespera_macro__openapi_generator__tests__openapi_tag_descriptions.snap @@ -0,0 +1,49 @@ +--- +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" + } + } + } + } + } + } + } + }, + "components": {}, + "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..5ae90083 100644 --- a/crates/vespera_macro/src/vespera_impl.rs +++ b/crates/vespera_macro/src/vespera_impl.rs @@ -1,1868 +1,16 @@ //! 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, -}; - -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"); - } - }; - } -} +//! 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::{DocsInfo, ensure_openapi_files_from_cache, generate_and_write_openapi}; +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..fe7dfe71 --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/cache.rs @@ -0,0 +1,426 @@ +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + path::Path, +}; + +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 = 1; + +/// 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, +} + +/// 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() +} + +/// 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); + } + hasher.finish() +} + +/// Compute a deterministic hash of OpenAPI config fields. +pub(super) 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); + 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); + scheme.r#type.hash(&mut hasher); + scheme.description.hash(&mut hasher); + scheme.name.hash(&mut hasher); + scheme.r#in.hash(&mut hasher); + scheme.scheme.hash(&mut hasher); + scheme.bearer_format.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); + } + } + for merge_path in &processed.merge { + quote!(#merge_path).to_string().hash(&mut hasher); + } + hasher.finish() +} + +/// 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())) +} + +/// 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") { + let mtime = entry.metadata().and_then(|m| m.modified()).map_or(0, |t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }); + 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 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()), + } + } + + 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..145c2cda --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/openapi_io.rs @@ -0,0 +1,632 @@ +use std::{collections::HashMap, path::Path}; + +use crate::{ + error::{MacroResult, err_call_site}, + metadata::CollectedMetadata, + openapi_generator::{OpenApiSecurity, generate_openapi_doc_with_metadata}, + route_impl::StoredRouteInfo, + router_codegen::ProcessedVesperaInput, +}; +use proc_macro2::Span; + +use super::path_utils::{current_crate_tag, find_target_dir}; + +/// Docs info tuple type alias for cleaner signatures +pub type DocsInfo = (Option, Option, 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. +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) +} + +/// 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(), + 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() + && 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 = !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.")))?; + } + } + } + + // 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)) +} + +/// 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 = spec_file.display().to_string().replace('\\', "/"); + 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, +) -> Option { + let spec_tokens = match spec_json_hash { + None => None, + Some(expected) => { + let path = embed_spec_path(); + let content = std::fs::read_to_string(&path).ok()?; + if super::cache::hash_str(&content) != expected { + return None; + } + Some(embed_tokens(&path)) + } + }; + let pretty = match spec_pretty_hash { + None => None, + Some(expected) => { + let content = std::fs::read_to_string(pretty_sidecar_path()).ok()?; + if super::cache::hash_str(&content) != expected { + return None; + } + Some(content) + } + }; + 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>) { + let Some(pretty) = spec_pretty else { + return; + }; + let path = pretty_sidecar_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let should_write = !content_unchanged(&path, pretty); + if should_write { + let _ = std::fs::write(&path, pretty); + } +} + +/// 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 should_write = !content_unchanged(&spec_file, &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 + ), + ) + })?; + } + Ok(Some(embed_tokens(&spec_file))) +} + +#[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(), &[]); + 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, + security_schemes: None, + security: None, + tag_descriptions: 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, + security_schemes: None, + security: None, + tag_descriptions: 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, + security_schemes: None, + security: None, + tag_descriptions: 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, + security_schemes: None, + security: None, + tag_descriptions: 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, + security_schemes: None, + security: None, + tag_descriptions: 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()); + } + + #[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, + 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(), &[]); + 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(), &[]); + + // 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(), &[]); + 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..9f7d1ed2 --- /dev/null +++ b/crates/vespera_macro/src/vespera_impl/orchestrator.rs @@ -0,0 +1,824 @@ +use std::{collections::HashMap, path::Path}; + +use proc_macro2::Span; +use quote::quote; + +use crate::{ + collector::collect_metadata, + metadata::StructMetadata, + openapi_generator::generate_openapi_doc_with_metadata, + route_impl::StoredRouteInfo, + router_codegen::{ProcessedVesperaInput, generate_router_code}, +}; + +use super::{ + cache::{ + CACHE_FORMAT, VesperaCache, compute_config_hash, compute_macro_dev_fingerprint, + compute_schema_hash, get_cache_path, hash_str, read_cache, 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], +) -> 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( + 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 --- + // 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 config_hash = compute_config_hash(processed); + 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) + } 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 { + let scanned_files: Vec = + scanned.iter().map(|(path, _)| path.clone()).collect(); + let (mut metadata, file_asts) = crate::collector::collect_metadata_from_files(&scanned_files, &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(&metadata)?; + stage("validate_schema_backed_extractors"); + + let (_, _, spec_json) = + generate_and_write_openapi(processed, &metadata, file_asts, route_storage)?; + stage("generate_and_write_openapi"); + + // Read back spec_pretty from first openapi file for the pretty + // sidecar (warm-rebuild recovery source for openapi.json) + let spec_pretty = processed + .openapi_file_names + .first() + .and_then(|f| std::fs::read_to_string(f).ok()); + write_pretty_sidecar(spec_pretty.as_deref()); + + // 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_json.as_deref().map(hash_str), + spec_pretty_hash: spec_pretty.as_deref().map(hash_str), + }, + ); + stage("write_cache"); + + // Write compact spec for include_str! embedding + let spec_tokens = write_spec_for_embedding(spec_json)?; + stage("write_spec_for_embedding"); + + (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 = { + 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, + )); + 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 +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}")))?; + + // 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(&metadata)?; + + // Generate OpenAPI spec JSON string + let openapi_doc = 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 + 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::*; + + 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(), &[]); + 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(), &[]); + // 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, &[]); + // 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, + 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(), &[]); + 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; + } + + #[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, + security_schemes: None, + security: None, + tag_descriptions: 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, + security_schemes: None, + security: None, + tag_descriptions: 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; + } + + #[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(), &[]); + 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"); + } + }; + } +} 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..fec8591f --- /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_item_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_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(), + 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_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(), + 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_item_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_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_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_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(), + 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_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])); + } +} 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..f9191f77 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" } @@ -977,7 +957,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1049,7 +1029,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1144,16 +1124,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 +1186,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1206,7 +1198,7 @@ }, "/path/multi-path/{var1}": { "get": { - "operationId": "mod_file_with_test_struct", + "operationId": "mod_file_with_multi_path_single", "parameters": [ { "name": "var1", @@ -1215,32 +1207,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 +1256,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1308,7 +1283,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1335,7 +1310,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1362,7 +1337,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1398,7 +1373,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1482,7 +1457,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1503,7 +1478,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1552,7 +1527,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1615,7 +1590,7 @@ "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1667,7 +1642,7 @@ "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1717,7 +1692,7 @@ "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1744,7 +1719,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1777,7 +1752,7 @@ "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -2018,6 +1993,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 +2013,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" + ] + } + } + } } } } @@ -2333,7 +2351,7 @@ "nullable": true }, "discountRate": { - "type": "number", + "type": "string", "format": "decimal", "nullable": true }, @@ -2342,11 +2360,11 @@ "minimum": 0 }, "maxPrice": { - "type": "number", + "type": "string", "format": "decimal" }, "minPrice": { - "type": "number", + "type": "string", "format": "decimal" }, "priority": { @@ -2362,7 +2380,7 @@ "format": "char" }, "taxRate": { - "type": "number", + "type": "string", "format": "decimal" } }, @@ -2385,9 +2403,9 @@ "default": 0 }, "temperature": { - "type": "number", + "type": "string", "format": "decimal", - "default": 0.7 + "default": "0.7" } }, "required": [ @@ -3188,7 +3206,7 @@ "default": "1970-01-01T00:00:00+00:00" }, "user": { - "$ref": "#/components/schemas/UserSchema", + "$ref": "#/components/schemas/UserInMemoDetail", "nullable": true }, "userId": { @@ -3412,6 +3430,67 @@ "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": { + "$ref": "#/components/schemas/MemoSummaryUser", + "nullable": true + }, + "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": { @@ -3648,10 +3727,6 @@ "SkipResponse": { "type": "object", "properties": { - "email2": { - "type": "string", - "nullable": true - }, "email4": { "type": "string", "nullable": true @@ -3903,7 +3978,7 @@ "nullable": true }, "discountRate": { - "type": "number", + "type": "string", "format": "decimal", "nullable": true }, @@ -3913,12 +3988,12 @@ "nullable": true }, "maxPrice": { - "type": "number", + "type": "string", "format": "decimal", "nullable": true }, "minPrice": { - "type": "number", + "type": "string", "format": "decimal", "nullable": true }, @@ -3938,7 +4013,7 @@ "nullable": true }, "taxRate": { - "type": "number", + "type": "string", "format": "decimal", "nullable": true } @@ -4089,7 +4164,8 @@ }, "sort": { "type": "string", - "description": "Sort order: \"asc\" or \"desc\"" + "description": "Sort order: \"asc\" or \"desc\"", + "default": "asc" } }, "required": [ 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..0ae7945d 100644 --- a/examples/axum-example/tests/integration_test.rs +++ b/examples/axum-example/tests/integration_test.rs @@ -1018,9 +1018,24 @@ 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. assert_eq!( memo_detail["properties"]["user"]["$ref"], - "#/components/schemas/UserSchema" + "#/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"], @@ -1927,3 +1942,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..7e9e2fa0 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -1,6 +1,5 @@ --- source: examples/axum-example/tests/integration_test.rs -assertion_line: 413 expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" --- { @@ -23,7 +22,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -50,7 +49,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -77,7 +76,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -286,14 +285,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 +326,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -344,14 +343,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 +370,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 +397,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 +425,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "Successful response", "headers": {}, "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -474,7 +453,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "Successful response", "headers": {}, "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -513,13 +492,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 +597,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -898,7 +877,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -915,7 +894,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -936,7 +915,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -982,7 +961,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1054,7 +1033,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1149,16 +1128,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 +1190,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1211,7 +1202,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 +1211,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 +1260,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1313,7 +1287,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1340,7 +1314,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1367,7 +1341,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1403,7 +1377,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1487,7 +1461,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1508,7 +1482,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1557,7 +1531,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1620,7 +1594,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1672,7 +1646,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1722,7 +1696,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "400": { "description": "Error response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1749,7 +1723,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -1782,7 +1756,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "200": { "description": "Successful response", "content": { - "application/json": { + "text/plain": { "schema": { "type": "string" } @@ -2023,6 +1997,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 +2017,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" + ] + } + } + } } } } @@ -2338,7 +2355,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "discountRate": { - "type": "number", + "type": "string", "format": "decimal", "nullable": true }, @@ -2347,11 +2364,11 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "minimum": 0 }, "maxPrice": { - "type": "number", + "type": "string", "format": "decimal" }, "minPrice": { - "type": "number", + "type": "string", "format": "decimal" }, "priority": { @@ -2367,7 +2384,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "format": "char" }, "taxRate": { - "type": "number", + "type": "string", "format": "decimal" } }, @@ -2390,9 +2407,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": [ @@ -3193,7 +3210,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "default": "1970-01-01T00:00:00+00:00" }, "user": { - "$ref": "#/components/schemas/UserSchema", + "$ref": "#/components/schemas/UserInMemoDetail", "nullable": true }, "userId": { @@ -3417,6 +3434,67 @@ 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": { + "$ref": "#/components/schemas/MemoSummaryUser", + "nullable": true + }, + "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": { @@ -3653,10 +3731,6 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "SkipResponse": { "type": "object", "properties": { - "email2": { - "type": "string", - "nullable": true - }, "email4": { "type": "string", "nullable": true @@ -3908,7 +3982,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "discountRate": { - "type": "number", + "type": "string", "format": "decimal", "nullable": true }, @@ -3918,12 +3992,12 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "maxPrice": { - "type": "number", + "type": "string", "format": "decimal", "nullable": true }, "minPrice": { - "type": "number", + "type": "string", "format": "decimal", "nullable": true }, @@ -3943,7 +4017,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "nullable": true }, "taxRate": { - "type": "number", + "type": "string", "format": "decimal", "nullable": true } @@ -4094,7 +4168,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "sort": { "type": "string", - "description": "Sort order: \"asc\" or \"desc\"" + "description": "Sort order: \"asc\" or \"desc\"", + "default": "asc" } }, "required": [ 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/README.md b/libs/vespera-bridge/README.md index add6b8e4..9ab8def9 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 idempotent requests (GET/HEAD/PUT/DELETE/OPTIONS, Content-Length absent or ≤ 256 KiB) ~2.2 µs; `SYNC` (heap-buffered) for small non-idempotent (POST/PATCH ≤ 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 + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, Content-Length absent or ≤ 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 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**, which re-runs the Rust handler. This 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; 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. -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. +Restore the pre-0.2.0 default (every request that may carry a body streams both ways, ~24 µs per round-trip uniform) with: + +```yaml +vespera: + bridge: + dispatch-mode: bidirectional-streaming +``` ## Customization @@ -142,7 +158,7 @@ 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()); } } ``` @@ -200,11 +216,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 +228,136 @@ 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 idempotent 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 idempotent +requests, because the retry dispatches again. + +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 + idempotent (GET/HEAD/PUT/DELETE/OPTIONS) | `DIRECT` | ~2,200 | +| Small (≤ 256 KiB Content-Length) + non-idempotent (POST/PATCH) | `SYNC` | ~3,200 | +| Large or unknown-length body | `BIDIRECTIONAL_STREAMING` | ~24,100 | + +The idempotency gate on DIRECT matters because a response that +overflows the pooled buffer (`vespera.direct.maxBufferBytes`, default +4 MiB) is retried — which re-runs the Rust handler once. SYNC never +re-runs the handler (safe for POST), but buffers the full response on +the 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. ## Direct API (without the proxy controller) @@ -245,7 +385,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 +461,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 +571,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 idempotent requests, SYNC for small non-idempotent 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 idempotent 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 +598,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 idempotent requests take `DIRECT` (~2.2 µs), small non-idempotent 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 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` | + +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 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; 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..7f9ea1ef 100644 --- a/libs/vespera-bridge/build.gradle.kts +++ b/libs/vespera-bridge/build.gradle.kts @@ -31,6 +31,13 @@ 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") } 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/DispatchMode.java b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/DispatchMode.java index 192520f3..3d05bded 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 idempotent requests take {@link #DIRECT} (~2.2 µs), small + * non-idempotent 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, idempotent requests (GET/HEAD/PUT/DELETE/ + * 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 idempotency 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..1eebc0a9 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 idempotent + * requests take {@link DispatchMode#DIRECT} (~2.2 µs), small + * non-idempotent 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,41 @@ 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.
  • + *
  • No {@code Content-Length}, no {@code Transfer-Encoding}, + * and the method is GET / HEAD / OPTIONS — per RFC 9112 + * §6.3 such an HTTP/1.1 request has no body. The method + * restriction keeps HTTP/2 safe (h2 has no + * {@code Transfer-Encoding} header, so a length-less POST + * body cannot be ruled out there).
  • + *
+ * + *

Even when this misjudges an exotic length-less GET-with-body + * (h2 only), correctness is preserved — the non-bidirectional + * modes read the servlet input stream fully and send the body + * inline; only the memory profile differs. + */ + static boolean definitelyBodyless(HttpServletRequest request) { + long contentLength = request.getContentLengthLong(); + if (contentLength == 0) { + return true; + } + if (contentLength > 0 || request.getHeader("Transfer-Encoding") != null) { + return false; + } + String method = request.getMethod(); + return "GET".equalsIgnoreCase(method) + || "HEAD".equalsIgnoreCase(method) + || "OPTIONS".equalsIgnoreCase(method); + } } 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..812fb234 --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/HttpMethods.java @@ -0,0 +1,33 @@ +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 + * safe to re-run, which the DIRECT dispatch path requires for its + * response-overflow retry. {@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"); + } +} 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..c734482f --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/SmartDispatchModeResolver.java @@ -0,0 +1,147 @@ +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} — idempotent requests + * (GET / HEAD / PUT / DELETE / OPTIONS per RFC 9110) up to the + * DIRECT gate ({@link #DEFAULT_MAX_DIRECT_BYTES}, 1 MiB), or + * provably bodyless ones of any declared length. Idempotency + * matters because a DIRECT response overflow retries the + * dispatch, re-running the Rust handler.
  • + *
  • {@link DispatchMode#SYNC} — non-idempotent requests + * (POST / PATCH) 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.
  • + *
  • {@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). Idempotent 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. Non-idempotent (POST/PATCH) + * 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 (idempotent methods) + * @param maxSyncBytes largest {@code Content-Length} eligible for SYNC + * dispatch (non-idempotent 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) { + long contentLength = request.getContentLengthLong(); + // 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 = DispatchModeResolver.definitelyBodyless(request); + String method = request.getMethod(); + + if (HttpMethods.isIdempotent(method)) { + // Idempotent (GET/HEAD/PUT/DELETE/OPTIONS): DIRECT up to the + // (larger) DIRECT gate, else stream. Idempotency 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. + if (VesperaBridge.currentThreadIsVirtual()) { + return syncSized(contentLength, bodyless) + ? DispatchMode.SYNC + : DispatchMode.BIDIRECTIONAL_STREAMING; + } + return DispatchMode.DIRECT; + } + + // Non-idempotent (POST/PATCH): 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..6b7c4fcc 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,22 +1,16 @@ 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.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.lang.ref.SoftReference; 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.concurrent.CompletableFuture; @@ -49,20 +43,103 @@ */ public class VesperaBridge { - private static final ObjectMapper MAPPER = new ObjectMapper(); + /** 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. */ + private static final byte[] EMPTY_BODY = new byte[0]; + /** + * Per-thread reusable byte buffer for {@link #fillHeaderJson}. + * Reset (size cleared, capacity preserved) per call and filled + * byte-direct — no per-call encoder object. Virtual-thread caveat + * as {@link #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(256)); + + /** + * {@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. + */ + private static final class ExposedByteArrayOutputStream extends ByteArrayOutputStream { + ExposedByteArrayOutputStream(int size) { + super(size); + } + + /** Backing buffer; valid content is {@code [0, size())} only. */ + byte[] backingArray() { + return buf; + } + + /** + * 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, buf.length << 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) { + int cap = buf.length; + while (cap < count + n) { + cap <<= 1; + } + buf = java.util.Arrays.copyOf(buf, cap); + } + for (int i = 0; i < n; i++) { + buf[count++] = (byte) lit.charAt(i); + } + } + } + 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 +153,170 @@ public record DecodedResponse( int status, Map headers, Map metadata, - byte[] body, - List> validationErrors) {} + ByteBuffer body, + List> validationErrors) { + + public DecodedResponse { + Objects.requireNonNull(body, "body"); + 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) { System.loadLibrary(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. + } loaded = true; + loadedLibraryName = libraryName; + } + + /** + * 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. + configureStreaming0(chunkBytes, channelCapacity); + } else { + // Native library not yet loaded — store pending values. + // These will be applied in init() before any dispatch. + pendingChunkBytes = chunkBytes; + pendingChannelCapacity = channelCapacity; + } } + /** + * 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 @@ -184,7 +406,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.
  • @@ -249,7 +472,7 @@ public static byte[] encodeRequestHeader( Objects.requireNonNull(path, "path"), query, headers != null ? headers : java.util.Map.of(), - new byte[0]); + EMPTY_BODY); } /** @@ -288,6 +511,481 @@ 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 (non-idempotent requests). Carries the exact + * buffer size needed for a successful retry. + * + *

    Retrying re-runs the dispatch — the Rust + * handler executes again. Only retry idempotent requests + * (GET/HEAD/PUT/DELETE) automatically; for POST/PATCH 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; + } + + /** Exact out-buffer capacity needed for a successful retry. */ + public int requiredSize() { + return requiredSize; + } + } + + /** 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). Payloads beyond the cap fall back to + * {@link #dispatchBytes(byte[])}. + */ + private static final int DIRECT_MAX_CAPACITY = Integer.getInteger( + "vespera.direct.maxBufferBytes", 4 * 1024 * 1024); + + /** + * 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} at the start of the next + * dispatch on the same thread, so a single big response cannot pin + * off-heap memory for the thread's whole lifetime. Transient growth + * up to {@link #DIRECT_MAX_CAPACITY} for an individual request is + * still allowed — only steady-state retention is capped. + * + *

    Default raised from 256 KiB to 2 MiB (measured 2026-06). + * Bodyless requests (the common GET) always take DIRECT regardless of + * response size, so when the cap sat below the response size every such + * dispatch shrank the buffer, overflowed, regrew, and re-ran the + * handler — measured 6–8× slower than streaming for + * 256 KiB–1.5 MiB responses (e.g. a {@code GET} download). At + * 2 MiB DIRECT instead beats streaming by 1.7–2.7× across + * that range. The cost is self-targeting: only threads that actually + * handle large responses retain more (small-response threads keep the + * 64 KiB baseline), and the pool is {@link SoftReference}-backed so the + * JVM reclaims it under memory pressure. Memory-sensitive deployments + * dial it back via {@code vespera.direct.maxRetainedBytes}. + */ + 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 through a {@link SoftReference} so the JVM can reclaim the + * off-heap direct buffers under memory pressure — the + * {@code DirectByteBuffer} Cleaner frees the native memory once the + * soft reference is cleared — instead of pinning up to {@code 2 ×} + * {@link #DIRECT_MAX_CAPACITY} per thread for the whole thread + * lifetime. Under normal load the soft reference survives, so the + * pooling benefit is preserved; see {@link #directPool()} for the + * resolve + retention-cap logic. + * + *

    Virtual thread limitation: {@link ThreadLocal} + * binds to the virtual thread (not the carrier) in Java 21+. Each + * virtual thread gets its own pool, losing the pooling benefit in + * virtual-thread-per-request servers. See + * {@link #dispatchDirectPooled(byte[], boolean)} for mitigation. + */ + private static final ThreadLocal> DIRECT_POOL = + new ThreadLocal<>(); + + /** + * Resolve the calling thread's pooled direct buffers, (re)allocating + * a baseline pair when the {@link SoftReference} has been cleared + * under memory pressure, and shrinking any buffer a prior large + * dispatch grew past {@link #DIRECT_RETAIN_CAPACITY} back to the + * baseline. + * + *

    Shrinking here — at the start of a dispatch, before any + * request bytes are written into the pool — is safe with respect to + * the "view valid until the next dispatch" contract of + * {@link #dispatchDirectPooled(byte[], boolean)}: the previous + * response view's validity window has already ended by the time the + * next dispatch begins. + */ + private static ByteBuffer[] directPool() { + SoftReference ref = DIRECT_POOL.get(); + ByteBuffer[] pool = ref == null ? null : ref.get(); + if (pool == null) { + pool = new ByteBuffer[] { + ByteBuffer.allocateDirect(DIRECT_INITIAL_CAPACITY), + ByteBuffer.allocateDirect(DIRECT_INITIAL_CAPACITY)}; + DIRECT_POOL.set(new SoftReference<>(pool)); + return pool; + } + if (pool[0].capacity() > DIRECT_RETAIN_CAPACITY) { + pool[0] = ByteBuffer.allocateDirect(DIRECT_INITIAL_CAPACITY); + } + if (pool[1].capacity() > DIRECT_RETAIN_CAPACITY) { + pool[1] = ByteBuffer.allocateDirect(DIRECT_INITIAL_CAPACITY); + } + return pool; + } + + /** + * 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 #dispatchBytes(byte[])} path instead, automating the + * mitigation the docs previously left to manual configuration. + */ + static boolean currentThreadIsVirtual() { + if (IS_VIRTUAL == null) { + return false; + } + try { + return (boolean) IS_VIRTUAL.invokeExact(Thread.currentThread()); + } catch (Throwable ignoredFallBackToPooled) { + return false; + } + } + + /** + * 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, + * {@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 (inLen < 0 || inLen > in.capacity()) { + throw new IllegalArgumentException( + "inLen " + inLen + " out of range for in.capacity() " + in.capacity()); + } + return dispatchDirect0(in, inLen, out); + } + + /** + * 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. For virtual-thread deployments, prefer + * {@link #dispatchBytes(byte[])}, {@link #dispatchStreaming}, or + * {@link #dispatchFullStreaming}, or run dispatch on a bounded + * platform-thread executor, or lower {@code vespera.direct.maxBufferBytes}. + * + *

    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 idempotent + * 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 (idempotent 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) { + Objects.requireNonNull(wireRequest, "wireRequest"); + if (currentThreadIsVirtual() || wireRequest.length > DIRECT_MAX_CAPACITY) { + // Virtual thread: the per-thread direct buffer pool would + // accumulate off-heap memory per vthread (ThreadLocal binds to + // the vthread, not the carrier) — use the GC-managed heap path. + // Oversized request (> cap): byte[] fallback is safe for any + // method because no dispatch has run yet. + return ByteBuffer.wrap(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, () -> wireRequest); + } + + /** + * 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 via + * {@link #encodeRequestInto}, so the body bytes are copied + * heap→direct exactly once (the {@code byte[]}-based overload + * assembles a full wire array first and then copies it again). + * + *

    Same pooling, fallback, overflow, and view-validity semantics + * as {@link #dispatchDirectPooled(byte[], boolean)}. Note the two + * distinct retry concepts: encoding growth (request bigger + * than the pooled buffer) happens before any dispatch and is always + * safe; response-overflow retry re-runs the Rust handler + * and is gated by {@code retryOnOverflow}. + * + *

    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. For virtual-thread deployments, prefer + * {@link #dispatchBytes(byte[])}, {@link #dispatchStreaming}, or + * {@link #dispatchFullStreaming}, or run dispatch on a bounded + * platform-thread executor, or lower {@code vespera.direct.maxBufferBytes}. + * + * @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 (idempotent 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) { + byte[] bodyBytes = body != null ? body : EMPTY_BODY; + ExposedByteArrayOutputStream hdr = fillHeaderJson(appName, method, path, query, headers); + int headerLen = hdr.size(); + int total = 4 + headerLen + bodyBytes.length; + if (currentThreadIsVirtual() || total > DIRECT_MAX_CAPACITY) { + // Virtual thread: avoid the per-vthread off-heap direct buffer + // accumulation — use the GC-managed heap path. Oversized + // request (> cap): byte[] fallback is safe for any method + // because no dispatch has run yet. The reusable header buffer + // is consumed here, before any other fillHeaderJson call. + return ByteBuffer.wrap( + dispatchBytes(assembleWire(hdr.backingArray(), headerLen, bodyBytes))) + .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 + // now; dispatchViaPool's lazy wireFallback re-encodes from scratch + // rather than capturing the buffer, so buffer reuse cannot corrupt + // a deferred fallback. + int written = assembleInto(hdr.backingArray(), headerLen, bodyBytes, pool[0]); + if (written != total) { + throw new IllegalStateException( + "assembleInto wrote " + written + ", expected " + total); + } + return dispatchViaPool(pool, total, retryOnOverflow, + () -> encodeRequest(appName, method, path, query, headers, bodyBytes)); + } + + /** + * 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 {@code dispatchBytes} path. + */ + private static ByteBuffer dispatchViaPool( + ByteBuffer[] pool, int reqLen, boolean retryOnOverflow, + java.util.function.Supplier wireFallback) { + int n = dispatchDirect(pool[0], reqLen, pool[1]); + if (n < 0 && n != Integer.MIN_VALUE) { + int required = -n; + if (!retryOnOverflow) { + throw new BufferTooSmallException(required); + } + if (required > DIRECT_MAX_CAPACITY) { + // Retry permitted; beyond the pool cap use the byte[] path. + return ByteBuffer.wrap(dispatchBytes(wireFallback.get())).asReadOnlyBuffer(); + } + pool[1] = ByteBuffer.allocateDirect(grownCapacity(required)); + n = dispatchDirect(pool[0], reqLen, pool[1]); + } + 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); + return view; + } + + /** + * Encode a wire 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"); + ExposedByteArrayOutputStream hdr = fillHeaderJson(appName, method, path, query, headers); + return assembleInto(hdr.backingArray(), hdr.size(), body != null ? body : EMPTY_BODY, target); + } + + /** Internal: write {@code [u32 BE len | headerJson[0..headerLen] | body]} at position 0. */ + private static int assembleInto(byte[] headerJson, int headerLen, byte[] body, ByteBuffer target) { + int total = 4 + headerLen + body.length; + if (target.capacity() < total) { + return -total; + } + target.clear(); + target.order(ByteOrder.BIG_ENDIAN); + target.putInt(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. */ + private static byte[] assembleWire(byte[] headerJson, int headerLen, byte[] body) { + byte[] wire = new byte[4 + 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; + } + + /** 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); + } + /** * Encode a request into the binary wire format. * @@ -333,36 +1031,145 @@ 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()); + ExposedByteArrayOutputStream hdr = fillHeaderJson(appName, method, path, query, headers); + return assembleWire(hdr.backingArray(), hdr.size(), body != null ? body : EMPTY_BODY); + } + + /** + * 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. + */ + private static ExposedByteArrayOutputStream fillHeaderJson(String appName, String method, + String path, String query, Map headers) { + ExposedByteArrayOutputStream buf = HEADER_BUF.get(); + buf.reset(); + // {"v":, ...} — WIRE_VERSION is a single decimal digit. + buf.putAscii("{\"v\":"); + buf.put('0' + WIRE_VERSION); + buf.putAscii(",\"method\":"); + writeJsonString(buf, method); + buf.putAscii(",\"path\":"); + writeJsonString(buf, path); + if (query != null && !query.isEmpty()) { + buf.putAscii(",\"query\":"); + writeJsonString(buf, query); + } + if (headers != null && !headers.isEmpty()) { + buf.putAscii(",\"headers\":{"); + boolean first = true; + for (Map.Entry e : headers.entrySet()) { + if (!first) { + buf.put(','); } - header.set("headers", hdrs); + first = false; + writeJsonString(buf, e.getKey()); + buf.put(':'); + writeJsonString(buf, e.getValue()); } - if (appName != null && !appName.isBlank()) { - header.put("app", appName.trim()); + buf.put('}'); + } + if (appName != null && !appName.isBlank()) { + buf.putAscii(",\"app\":"); + writeJsonString(buf, appName.trim()); + } + buf.put('}'); + return buf; + } + + /** + * 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('"'); + 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)); } - 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); } + out.put('"'); } /** @@ -376,74 +1183,30 @@ public static DecodedResponse decodeResponse(byte[] wire) { "wire response too short: " + (wire == null ? "null" : wire.length + " bytes")); } - ByteBuffer buf = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN); - int headerLen = buf.getInt(); + int headerLen = ((wire[0] & 0xFF) << 24) | ((wire[1] & 0xFF) << 16) + | ((wire[2] & 0xFF) << 8) | (wire[3] & 0xFF); 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); - } + // 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(ByteBuffer.wrap(wire), 4, headerLen); + ByteBuffer body = ByteBuffer.wrap(wire, 4 + headerLen, wire.length - 4 - headerLen); + return new DecodedResponse( + d.status, + d.headers == null ? Map.of() : d.headers, + d.metadata, + body, + d.validationErrors); } - // --- Internal: bundled native lib extraction --- - private static void loadBundled(String libraryName) { String os = detectOs(); String arch = detectArch(); 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..b11ef216 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 @@ -1,5 +1,7 @@ package com.devfive.vespera.bridge; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; @@ -25,34 +27,111 @@ * 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.
  • * + * + *

    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) @EnableConfigurationProperties(VesperaBridgeProperties.class) public class VesperaBridgeAutoConfiguration { + private static final Logger log = + LoggerFactory.getLogger(VesperaBridgeAutoConfiguration.class); + @Bean @ConditionalOnMissingBean public AppNameResolver vesperaBridgeAppNameResolver(VesperaBridgeProperties props) { 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 + * idempotent requests, SYNC for small non-idempotent 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 + * idempotent methods (GET/HEAD/PUT/DELETE/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")) { + log.warn( + "Unrecognized vespera.bridge.dispatch-mode '{}' — falling back to " + + "'smart'. Valid values: 'smart' (default), 'bidirectional-streaming'.", + mode); + } + return new SmartDispatchModeResolver(); + } + @Bean @ConditionalOnProperty( prefix = "vespera.bridge", 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..8ad93494 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 @@ -42,6 +42,32 @@ public class VesperaBridgeProperties { */ private boolean controllerEnabled = true; + /** + * Dispatch-mode policy for the autoconfigured proxy. + * + *

      + *
    • {@code smart} (default since 0.2.0) — small bounded + * idempotent requests (Content-Length absent/bodyless or + * ≤ 1 MiB; GET/HEAD/PUT/DELETE/OPTIONS) take the pooled + * direct-buffer path, skipping JNI array copies and + * per-request stream setup; small non-idempotent requests + * (POST/PATCH) 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 idempotent 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"; + public String getAppHeader() { return appHeader; } @@ -57,4 +83,12 @@ public boolean isControllerEnabled() { public void setControllerEnabled(boolean controllerEnabled) { this.controllerEnabled = controllerEnabled; } + + public String getDispatchMode() { + return dispatchMode; + } + + public void setDispatchMode(String dispatchMode) { + this.dispatchMode = dispatchMode; + } } 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..1ea27c75 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,6 +1,5 @@ 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; @@ -14,10 +13,12 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; 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; @@ -47,10 +48,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 idempotent 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 { @@ -89,15 +95,20 @@ public Object proxy(HttpServletRequest request, // the InputStream and leave the bidirectional path empty). switch (mode) { case SYNC: - return dispatchSync(appName, method, path, query, headers, + dispatchSync(response, appName, method, path, query, headers, readBody(request)); + return null; case ASYNC: return dispatchAsyncFlow(appName, method, path, query, headers, readBody(request)); case STREAMING: - dispatchStreaming(request, response, appName, method, path, query, + dispatchStreaming(response, appName, method, path, query, headers, readBody(request)); return null; + case DIRECT: + dispatchDirectMode(response, appName, method, path, query, headers, + readBody(request)); + return null; case BIDIRECTIONAL_STREAMING: default: dispatchBidirectional(request, response, appName, method, path, query, headers); @@ -105,46 +116,113 @@ public Object proxy(HttpServletRequest request, } } + /** Shared empty body — avoids a {@code new byte[0]} per bodyless request. */ + private static final byte[] EMPTY_BODY = new byte[0]; + /** - * 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). + * 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 byte[] readBody(HttpServletRequest request) throws IOException { + private static final int MAX_FIXED_BODY = 64 * 1024 * 1024; + + // 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 { + // 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 idempotent GETs the SmartDispatch + // resolver routes through DIRECT, which previously still paid a + // getInputStream()+readAllBytes() round-trip on an empty body). + if (DispatchModeResolver.definitelyBodyless(request)) { + return EMPTY_BODY; + } + long contentLength = request.getContentLengthLong(); try (InputStream in = request.getInputStream()) { + if (contentLength > 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 (-1) or oversized length: faithful incremental read. return in.readAllBytes(); } } - // ── Mode handlers ───────────────────────────────────────────────── - - /** 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). + */ + 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]; + Map headers, byte[] body) 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); + writeWireResponse(wireResp, response); } /** - * 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 set when the wire + * header did not already carry it — preserving the prior + * {@code ResponseEntity} behaviour without the copy. */ + private static void writeWireResponse(byte[] wire, HttpServletResponse response) + throws IOException { + 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 || (long) 4 + headerLen > wire.length) { + throw new IllegalArgumentException( + "wire header_len " + headerLen + + " overflows response (" + wire.length + " bytes)"); + } + WireHeaderReader.apply( + ByteBuffer.wrap(wire), 4, headerLen, + response::setStatus, response::addHeader); + int bodyOff = 4 + headerLen; + int bodyLen = wire.length - bodyOff; + if (bodyLen > 0) { + if (!response.containsHeader("Content-Length")) { + response.setContentLength(bodyLen); + } + response.getOutputStream().write(wire, bodyOff, bodyLen); + } + response.getOutputStream().flush(); + } + private CompletableFuture> dispatchAsyncFlow( String appName, String method, String path, String query, Map headers, byte[] body) { - byte[] bodyBytes = body != null ? body : new byte[0]; 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) + .thenApply(VesperaProxyController::buildResponseEntityFromWire); } /** @@ -154,12 +232,11 @@ 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]; byte[] wireReq = VesperaBridge.encodeRequest( - appName, method, path, query, headers, bodyBytes); + appName, method, path, query, headers, body); VesperaBridge.dispatchStreamingWithHeader( wireReq, headerBytes -> applyDecodedHeader(headerBytes, response), @@ -188,18 +265,143 @@ private void dispatchBidirectional( 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 idempotent methods; for others a + * {@link VesperaBridge.BufferTooSmallException} surfaces as a + * {@code 500} with the required size — the controller never + * double-executes a non-idempotent handler. (The resolver should + * keep such requests off DIRECT in the first place.) + */ + private static void dispatchDirectMode( + HttpServletResponse response, + String appName, String method, String path, String query, + Map headers, byte[] body) throws IOException { + ByteBuffer wireResp; + try { + // Encodes straight into the pooled direct buffer — no + // intermediate wire-sized byte[]. + wireResp = VesperaBridge.dispatchDirectPooled( + appName, method, path, query, headers, body, isIdempotent(method)); + } catch (VesperaBridge.BufferTooSmallException overflow) { + // Non-idempotent + response larger than the pool: the first + // dispatch already ran; its result was discarded. Serving + // via dispatchBytes would run the handler a second time, so + // surface the size to the operator instead of silently + // double-executing. (The resolver should keep + // non-idempotent methods off DIRECT in the first place.) + response.setStatus(500); + response.getOutputStream().write( + ("vespera DIRECT overflow: response needs " + + overflow.requiredSize() + + " bytes; route this request via BIDIRECTIONAL_STREAMING") + .getBytes(StandardCharsets.UTF_8)); + 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 headerLen = wireResp.getInt(0); + WireHeaderReader.apply(wireResp, 4, headerLen, response::setStatus, response::addHeader); + + // Stream the body region of the direct buffer straight out. + // Drain explicitly: WritableByteChannel.write() is contractually + // permitted to perform a partial write, so loop until the buffer + // is fully written rather than relying on the internal looping of + // Channels.newChannel(OutputStream). A single channel is created + // and reused across the (normally one) iterations. The channel + // wraps a blocking servlet OutputStream, so each write makes + // forward progress and the loop terminates. + wireResp.position(4 + headerLen); + if (wireResp.hasRemaining()) { + WritableByteChannel bodyChannel = + Channels.newChannel(response.getOutputStream()); + while (wireResp.hasRemaining()) { + bodyChannel.write(wireResp); + } + } + response.getOutputStream().flush(); + } + + /** Idempotent per RFC 9110 — safe to re-run on DIRECT overflow retry. */ + private static boolean isIdempotent(String method) { + return HttpMethods.isIdempotent(method); + } - private static Map collectHeaders(HttpServletRequest request) { - Map headers = new LinkedHashMap<>(); + // 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); Enumeration names = request.getHeaderNames(); while (names.hasMoreElements()) { String name = names.nextElement(); - headers.put(name.toLowerCase(Locale.ROOT), request.getHeader(name)); + headers.put(toLowerCaseAscii(name), joinHeaderValues(name, request)); } return headers; } + /** + * 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()) { + return request.getHeader(name); + } + 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 without allocating when it is + * already lowercase — the common case, since HTTP/2 mandates + * lowercase field names and most HTTP/1.1 clients send canonical + * names. Header names are ASCII per RFC 9110 §5.1, so an ASCII + * scan is sufficient; only on encountering an uppercase letter do + * we fall back to a full {@link String#toLowerCase} copy. + */ + private static String toLowerCaseAscii(String name) { + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c >= 'A' && c <= 'Z') { + return name.toLowerCase(Locale.ROOT); + } + } + return name; + } + /** * Apply a decoded wire header to {@link HttpServletResponse} — * called from streaming dispatch callbacks BEFORE the first body @@ -207,18 +409,18 @@ private static Map collectHeaders(HttpServletRequest request) { */ 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)); - } - } + // 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. + ByteBuffer buf = ByteBuffer.wrap(headerBytes); + int headerLen = buf.getInt(0); + WireHeaderReader.apply(buf, 4, headerLen, response::setStatus, response::addHeader); } /** @@ -227,40 +429,58 @@ private static void applyDecodedHeader(byte[] headerBytes, * {@link String} for text-like Content-Types, * {@code byte[]} otherwise. */ - private static ResponseEntity buildResponseEntity(DecodedResponse decoded) { - 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)); - } - } else if (val != null) { - httpHeaders.set(entry.getKey(), String.valueOf(val)); - } + /** + * 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 sliced once straight from the wire tail — for text this + * drops the intermediate {@code byte[]} that {@code bodyBytes()} would + * allocate (a body-sized copy avoided per text response, scaling with + * payload).
    • + *
    + * + *

    {@link VesperaBridge#decodeResponse(byte[])} stays the public API for + * external/streaming consumers; this is a controller-internal fast path. + * Pure Java (no JNI) — safe to run on the async completion thread. + */ + private static ResponseEntity buildResponseEntityFromWire(byte[] wire) { + if (wire == null || wire.length < 4) { + throw new IllegalArgumentException( + "wire response too short: " + (wire == null ? "null" : wire.length + " bytes")); } - 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); + int headerLen = ((wire[0] & 0xFF) << 24) | ((wire[1] & 0xFF) << 16) + | ((wire[2] & 0xFF) << 8) | (wire[3] & 0xFF); + if (headerLen < 0 || (long) 4 + headerLen > wire.length) { + throw new IllegalArgumentException( + "wire header_len " + headerLen + " overflows response (" + wire.length + " bytes)"); } - 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"); + HttpHeaders httpHeaders = new HttpHeaders(); + int[] statusHolder = {500}; + WireHeaderReader.apply( + java.nio.ByteBuffer.wrap(wire), + 4, + headerLen, + s -> statusHolder[0] = s, + httpHeaders::add); + HttpStatus status = HttpStatus.valueOf(statusHolder[0]); + // Deliver the body as byte[] for every content type. The wire + // header already carries the exact Content-Type, and Spring's + // ByteArrayHttpMessageConverter writes it verbatim — so this + // drops, for text responses, both the intermediate String + // allocation AND the UTF-8 decode→re-encode round-trip that + // ResponseEntity performed (the StringHttpMessageConverter + // would re-encode the just-decoded String straight back to UTF-8). + // One body-sized slice copy remains: ResponseEntity needs + // an owned array. (BREAKING vs ≤0.2.0: text responses surface as + // ResponseEntity rather than ResponseEntity; the + // bytes on the wire are identical.) + int bodyOff = 4 + headerLen; + return new ResponseEntity<>( + java.util.Arrays.copyOfRange(wire, bodyOff, wire.length), httpHeaders, status); } } 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..66dda84a --- /dev/null +++ b/libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/WireHeaderReader.java @@ -0,0 +1,744 @@ +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 { + + private final ByteBuffer buf; + private int pos; + private final int end; + + private WireHeaderReader(ByteBuffer buf, int off, int len) { + this.buf = buf; + 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; + if (r.peek() == '{') { + r.beginObject(); + int key; + while ((key = r.nextRootKey()) != KEY_END) { + switch (key) { + case KEY_STATUS -> status = r.readInt(); + 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(); + } + } + } + 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); + Decoded out = new Decoded(); + if (r.peek() == '{') { + r.beginObject(); + int key; + while ((key = r.nextRootKey()) != KEY_END) { + switch (key) { + case KEY_STATUS -> out.status = r.readInt(); + case KEY_HEADERS -> { + if (r.isObjectStart()) { + r.beginObject(); + String k; + while ((k = r.nextKeyCanonical()) != null) { + if (out.headers == null) { + // Pre-size for a typical response header + // count (content-type, content-length, …). + out.headers = new LinkedHashMap<>(8); + } + if (r.isArrayStart()) { + r.beginArray(); + List list = new ArrayList<>(); + while (r.hasNextElement()) { + list.add(r.readString()); + } + out.headers.put(k, list); + } else { + out.headers.put(k, r.readString()); + } + } + } else { + r.skipValue(); + } + } + case KEY_METADATA -> { + if (r.isObjectStart()) { + r.beginObject(); + out.metadata = r.readStringMap(); + } else { + r.skipValue(); + } + } + case KEY_VALIDATION -> { + if (r.isArrayStart()) { + r.beginArray(); + out.validationErrors = new ArrayList<>(); + while (r.hasNextElement()) { + if (!r.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. + r.skipValue(); + continue; + } + r.beginObject(); + Map entry = new LinkedHashMap<>(4); + String k; + while ((k = r.nextKeyCanonical()) != null) { + entry.put(k, r.readString()); + } + out.validationErrors.add(entry); + } + } else { + r.skipValue(); + } + } + // KEY_OTHER: "v" and any unknown field — value skipped, + // never materialised. + default -> r.skipValue(); + } + } + } + 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 = buf.get(pos) & 0xFF; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + pos++; + } else { + break; + } + } + } + + private int cur() { + return pos < end ? buf.get(pos) & 0xFF : -1; + } + + int peek() { + skipWs(); + return cur(); + } + + private IllegalArgumentException err(String what) { + return new IllegalArgumentException("wire header JSON: " + what + " at offset " + pos); + } + + 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). + */ + private static final String[] CANONICAL_KEYS = { + "content-type", "content-length", "content-encoding", + "content-disposition", "cache-control", "set-cookie", "location", + "etag", "date", "vary", "access-control-allow-origin", + "version", "path", "code", "message", + }; + + /** + * Shared canonical instance for {@code buf[start .. start+len]} when it + * equals a {@link #CANONICAL_KEYS} entry, else {@code null}. Linear scan + * with a length pre-check — the list is tiny, so the per-key cost is a + * handful of byte comparisons. + */ + private String canonicalKey(int start, int len) { + for (String k : CANONICAL_KEYS) { + if (k.length() == len && regionEquals(start, k)) { + return k; + } + } + return null; + } + + /** + * If the upcoming quoted member key is a plain-ASCII {@link #CANONICAL_KEYS} + * 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 = buf.get(p) & 0xFF; + 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 = buf.get(pos) & 0xFF; + 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; + } + + /** Whether {@code buf[s .. s+lit.length())} equals the ASCII literal. */ + private boolean regionEquals(int s, String lit) { + for (int i = 0; i < lit.length(); i++) { + if ((buf.get(s + i) & 0xFF) != lit.charAt(i)) { + return false; + } + } + return true; + } + + 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) { + String s; + if (buf.hasArray()) { + // Heap-backed buffer (ByteBuffer.wrap on the SYNC / streaming + // / async paths): build the String straight from the backing + // array — one copy, no intermediate byte[]. Direct buffers + // (the DIRECT dispatch path) have no accessible array and keep + // the absolute bulk-get copy below. + s = + new String( + buf.array(), + buf.arrayOffset() + pos, + simpleLen, + java.nio.charset.StandardCharsets.US_ASCII); + } else { + byte[] tmp = new byte[simpleLen]; + buf.get(pos, tmp, 0, simpleLen); // absolute bulk get (Java 13+); position untouched + s = new String(tmp, java.nio.charset.StandardCharsets.US_ASCII); + } + pos += simpleLen + 1; // consume the run + the closing quote + return s; + } + StringBuilder sb = new StringBuilder(); + while (pos < end) { + int b = buf.get(pos++) & 0xFF; + if (b == '"') { + return sb.toString(); + } + if (b == '\\') { + if (pos >= end) { + throw err("dangling escape"); + } + int e = buf.get(pos++) & 0xFF; + 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' -> sb.append(readHex4()); + default -> throw err("bad escape"); + } + } else if (b < 0x80) { + sb.append((char) b); + } else if (b < 0xE0) { + sb.append((char) (((b & 0x1F) << 6) | nextCont())); + } else if (b < 0xF0) { + sb.append((char) (((b & 0x0F) << 12) | (nextCont() << 6) | nextCont())); + } else { + int cp = ((b & 0x07) << 18) | (nextCont() << 12) | (nextCont() << 6) | nextCont(); + sb.appendCodePoint(cp); + } + } + throw err("unterminated string"); + } + + /** + * 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 = buf.get(p) & 0xFF; + if (b == '"') { + return p - pos; + } + if (b == '\\' || b >= 0x80) { + return -1; + } + p++; + } + return -1; + } + + private int nextCont() { + if (pos >= end) { + throw err("truncated UTF-8"); + } + return buf.get(pos++) & 0x3F; + } + + private char readHex4() { + if (pos + 4 > end) { + throw err("truncated unicode escape"); + } + int v = 0; + for (int k = 0; k < 4; k++) { + int d = buf.get(pos++) & 0xFF; + 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; + } + + int readInt() { + skipWs(); + int start = pos; + boolean neg = cur() == '-'; + if (neg) { + pos++; + } + boolean any = false; + long v = 0; + while (pos < end) { + int d = buf.get(pos) & 0xFF; + if (d < '0' || d > '9') { + break; + } + v = v * 10 + (d - '0'); + pos++; + any = true; + } + if (pos < end) { + int c = cur(); + if (c == '.' || c == 'e' || c == 'E') { + skipNumberTail(); + } + } + if (!any) { + pos = start; + throw err("expected number"); + } + return (int) (neg ? -v : v); + } + + private void skipNumberTail() { + while (pos < end) { + int d = buf.get(pos) & 0xFF; + if ((d >= '0' && d <= '9') || d == '.' || d == 'e' || d == 'E' || d == '+' || d == '-') { + pos++; + } else { + break; + } + } + } + + void skipValue() { + int c = peek(); + switch (c) { + case '"' -> skipStringRaw(); + case '{', '[' -> skipContainerRaw(); + case 't', 'f', 'n' -> skipLiteral(); + default -> { + if (c == '-' || (c >= '0' && c <= '9')) { + readInt(); + } 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 = buf.get(pos++) & 0xFF; + 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 = buf.get(pos++) & 0xFF; + switch (b) { + case '"' -> { + // Skip a nested string so its braces/brackets don't count. + while (pos < end) { + int x = buf.get(pos++) & 0xFF; + 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() { + while (pos < end) { + int d = buf.get(pos) & 0xFF; + if (d >= 'a' && d <= 'z') { + pos++; + } else { + break; + } + } + } +} 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..169a1375 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ConfigureStreamingTest.java @@ -0,0 +1,108 @@ +package com.devfive.vespera.bridge; + +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")); + } +} 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..45329298 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/EncodeRequestIntoTest.java @@ -0,0 +1,95 @@ +package com.devfive.vespera.bridge; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +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"); + } + + @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); + } +} 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/ProxyControllerBodyHeaderTest.java b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ProxyControllerBodyHeaderTest.java new file mode 100644 index 00000000..02ef323a --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/ProxyControllerBodyHeaderTest.java @@ -0,0 +1,71 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * 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 = VesperaProxyController.collectHeaders(req); + assertEquals("text/html, application/json", headers.get("accept")); + } + + @Test + void duplicateCookieHeadersAreSemicolonJoined() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/x"); + req.addHeader("Cookie", "a=1"); + req.addHeader("Cookie", "b=2"); + Map headers = VesperaProxyController.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 = VesperaProxyController.collectHeaders(req); + assertEquals("abc123", headers.get("x-trace-id")); + } + + // ── 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)); + } +} 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..e971c6e8 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/SmartDispatchModeResolverTest.java @@ -0,0 +1,137 @@ +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.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 smallIdempotentRequestUsesDirect() { + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("GET", 128))); + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("DELETE", 0))); + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("PUT", + SmartDispatchModeResolver.DEFAULT_MAX_DIRECT_BYTES))); + } + + @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 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 mediumIdempotentRequestUsesDirectAfterGateRaise() { + // 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("PUT", 512 * 1024))); + assertEquals(DispatchMode.DIRECT, + resolver.resolveMode(request("GET", 1024 * 1024))); + } + + @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 (idempotent), SYNC gate 100 KiB (non-idempotent). + 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("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..e7b3be5b --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaBridgeAutoConfigurationTest.java @@ -0,0 +1,124 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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; + +/** + * 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 unknownDispatchModeFallsBackToSmart() { + // Q7: a typo'd dispatch-mode no longer silently changes semantics — + // it falls back to smart (with a logged warning), not bidirectional. + runner.withPropertyValues("vespera.bridge.dispatch-mode=not-a-real-mode") + .run( + ctx -> + assertInstanceOf( + SmartDispatchModeResolver.class, + ctx.getBean(DispatchModeResolver.class), + "unrecognized dispatch-mode must fall back to smart")); + } + + 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(); + } + } +} 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..5870a3be --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaDirectWrapperTest.java @@ -0,0 +1,71 @@ +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 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()); + } +} 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..20d3cd9b 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 @@ -133,7 +133,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 +171,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 +206,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 +237,146 @@ 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_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()); + assertEquals("/p\"a\\th/한글", h.path("path").asText()); + assertEquals("q=\"x\"&한=글", 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..e2df4f28 --- /dev/null +++ b/libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/WireHeaderReaderTest.java @@ -0,0 +1,136 @@ +package com.devfive.vespera.bridge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +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); + 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); + } + + @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\"}}"); + assertEquals(200, c.status()); + assertEquals(List.of("x-q=a\"b\\c\n", "x-u=caf\u00e9"), c.headers()); + } + + @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()); + } + + /** + * P3: {@code apply()} now routes common header names through the shared + * {@code CANONICAL_KEYS} table (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/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",