Skip to content

Commit 32b712a

Browse files
committed
feat: binary wire migration + multi-app routing + Spring autoconfigure
Two milestone cycles accumulated: Cycle 1 - Binary wire migration: replace base64-in-JSON envelope with length-prefixed binary wire format ([u32 BE | UTF-8 JSON header | raw body]). Remove dispatch_from_json/parse_request/serialize_error; introduce dispatch_from_bytes. Java native String dispatch(String) -> byte[] dispatchBytes(byte[]). Eliminates ~33% wire overhead + base64 CPU. Cycle 2 - Multi-app + 6 follow-up waves: (A) measured benchmarks, (C) TypedMultipart integration tests, (E) 422 validation hoisting to wire header, (B) dispatch_from_bytes_async + JNI dispatchAsync (CompletableFuture, always-complete), (F) Gradle plugin kr.devfive.vespera-bridge with dogfooding, (D) streaming + JNI OutputStream proxy + bidirectional streaming. Multi-app: register_app_named + APP_ROUTERS<RwLock<HashMap>> + wire app field (additive v=1) + jni_apps! macro. Multi-app is for external-dispatcher scenarios (JNI/WASM/FFI); Rust standalone uses axum native Router::merge/nest. Spring autoconfigure: VesperaBridgeAutoConfiguration + @ConditionalOnMissingBean + zero-config defaults (HeaderAppNameResolver + BidirectionalStreamingDispatchModeResolver). Single /** catch-all keeps OpenAPI URLs == Spring URLs. 4 DispatchMode values (SYNC / ASYNC / STREAMING / BIDIRECTIONAL_STREAMING). 6 JNI native symbols, 1846+ tests passing, clippy clean, 1 MiB SHA256 byte-identical bidirectional streaming roundtrip verified end-to-end.
1 parent 463f512 commit 32b712a

54 files changed

Lines changed: 6055 additions & 4899 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@ bin/
2323
.omc
2424
.omo
2525
node_modules
26+
27+
# Generated OpenAPI artifacts at workspace root
28+
/openapi*.json

AGENTS.md

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ vespera/
1919
│ ├── vespera_core/ # OpenAPI types, route/schema abstractions
2020
│ ├── vespera_macro/ # Proc-macros (main logic lives here)
2121
│ ├── vespera_inprocess/ # In-process dispatch (transport-agnostic)
22-
│ │ └── src/lib.rs # dispatch(), register_app(), dispatch_from_json()
22+
│ │ └── src/lib.rs # dispatch(), register_app(), dispatch_from_bytes()
2323
│ └── vespera_jni/ # JNI bridge (depends on vespera_inprocess)
2424
│ └── src/lib.rs # RUNTIME, jni_app! macro, JNI symbol export
2525
├── libs/
@@ -46,7 +46,7 @@ vespera/
4646
| Add core types | `crates/vespera_core/src/` | OpenAPI spec types |
4747
| Test new features | `examples/axum-example/` | Add route, run example |
4848
| In-process dispatch | `crates/vespera_inprocess/src/lib.rs` | RequestEnvelope → Router → ResponseEnvelope |
49-
| App factory (FFI pattern) | `crates/vespera_inprocess/src/lib.rs` | register_app(), dispatch_from_json() |
49+
| App factory (FFI pattern) | `crates/vespera_inprocess/src/lib.rs` | register_app(), dispatch_from_bytes() |
5050
| JNI integration | `crates/vespera_jni/src/lib.rs` | RUNTIME, jni_app! macro, JNI symbol export |
5151
| Java bridge library | `libs/vespera-bridge/` | com.devfive.vespera.bridge package |
5252
| JNI demo (Rust) | `examples/rust-jni-demo/src/` | Routes + vespera::jni_app! |
@@ -76,9 +76,10 @@ vespera (OpenAPI framework)
7676
7777
vespera_inprocess (transport layer — no JNI deps)
7878
├── axum (direct — owns Router re-export)
79+
├── bytes (Bytes for zero-copy body handling)
7980
├── http, http-body-util, tower
8081
├── serde, serde_json
81-
└── tokio (rt only — for dispatch_from_json Runtime param)
82+
└── tokio (rt only — for dispatch_from_bytes Runtime param)
8283
8384
vespera_jni (JNI glue — thin layer)
8485
├── vespera_inprocess (via workspace)
@@ -120,17 +121,97 @@ Feature flags:
120121
## JNI ARCHITECTURE
121122

122123
```
123-
Java (Spring Boot) Rust (cdylib) vespera crates
124-
───────────────── ────────────── ─────────────────
125-
VesperaBridge.init() → JNI_OnLoad vespera_inprocess::register_app()
126-
↓ ↓
127-
VesperaBridge.dispatch() → JNI symbol vespera_inprocess::dispatch_from_json()
128-
↓ ↓ ↓
129-
VesperaProxyController catch_unwind router.oneshot(request)
130-
↓ ↓ ↓
131-
ResponseEntity JSON envelope axum handlers
124+
Java (Spring Boot) Rust (cdylib) vespera crates
125+
───────────────── ────────────── ─────────────────
126+
VesperaBridge.init() → JNI_OnLoad vespera_inprocess::register_app()
127+
↓ ↓
128+
VesperaBridge.dispatchBytes() → JNI symbol vespera_inprocess::dispatch_from_bytes()
129+
↓ ↓ ↓
130+
VesperaProxyController catch_unwind router.oneshot(request)
131+
↓ ↓ ↓
132+
ResponseEntity binary wire response axum handlers
133+
(String OR byte[]) [u32 BE | JSON | body]
132134
```
133135

136+
### Binary Wire Format
137+
138+
Both request and response use the same layout:
139+
140+
```
141+
bytes 0..4 : u32 BE = header_json byte length N
142+
bytes 4..4+N : UTF-8 JSON
143+
(request) { "v":1, "method", "path",
144+
"query"?, "headers"? }
145+
(response) { "v":1, "status", "headers",
146+
"metadata", "validation_errors"? }
147+
bytes 4+N.. : raw body bytes (UTF-8 text or binary —
148+
no encoding applied)
149+
```
150+
151+
- No base64 — multipart uploads / PDFs / images travel as raw bytes.
152+
- `"v":1` is the protocol version; mismatched versions get a `400` wire response.
153+
- 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.
154+
- `validation_errors` is an optional array hoisted from 422 JSON bodies (`{"errors":[...]}`) — original body preserved verbatim alongside.
155+
156+
### JNI Dispatch Modes (four symbols)
157+
158+
| Symbol | Java native | Mode | Memory |
159+
|---|---|---|---|
160+
| `Java_...dispatchBytes` | `byte[] dispatchBytes(byte[])` | sync | full body |
161+
| `Java_...dispatchAsync` | `void dispatchAsync(CompletableFuture<byte[]>, byte[])` | async | full body |
162+
| `Java_...dispatchStreaming` | `byte[] dispatchStreaming(byte[], OutputStream)` | sync response-streaming | chunk-bounded response |
163+
| `Java_...dispatchFullStreaming` | `byte[] dispatchFullStreaming(byte[], InputStream, OutputStream)` | sync bidirectional streaming | chunk-bounded both directions |
164+
165+
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.
166+
167+
### Rust Public API (vespera_inprocess)
168+
169+
| Function | Sig | Use |
170+
|---|---|---|
171+
| `register_app(F)` | sync | Register the default app (first-wins, BC) |
172+
| `register_app_named(&str, F)` | sync | Register a named app for multi-app routing |
173+
| `dispatch_from_bytes(Vec<u8>, &Runtime) -> Vec<u8>` | sync | FFI entry, blocks on runtime |
174+
| `dispatch_from_bytes_async(Vec<u8>) -> Vec<u8>` (async) | async | inside an existing runtime |
175+
| `dispatch_streaming_async<F>(Vec<u8>, F) -> Vec<u8>` (async) | response streaming async | `F: FnMut(&[u8])` body chunks |
176+
| `dispatch_bidirectional_streaming<P,F>(Vec<u8>, P, F) -> Vec<u8>` (async) | bidirectional streaming | `P: FnMut() -> Option<Vec<u8>> + Send + 'static`, `F: FnMut(&[u8])` |
177+
| `error_wire(u16, &str) -> Vec<u8>` | sync | wire-format error builder |
178+
| `dispatch_typed(Router, &RequestEnvelope) -> ResponseEnvelope` | async | direct axum API (BC) |
179+
180+
### Multi-app routing
181+
182+
**Use case**: multi-app 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. For Rust **standalone** servers (`axum::serve(...)`), the native axum patterns (`Router::merge()`, `Router::nest()`) are more idiomatic for modularization — `register_app_named` adds no value when the same binary owns both the router registration and the HTTP entry point.
183+
184+
The wire header carries an optional `"app": "<name>"` field (default
185+
omitted → `"_default"` app). Dispatch looks the name up in
186+
`APP_ROUTERS: RwLock<HashMap<String, Router>>` and returns:
187+
188+
- 404 wire response if the name is registered but no such app exists
189+
- 400 wire response if the name fails validation (non-empty, ≤ 64 bytes, `[A-Za-z0-9_-]`)
190+
- Otherwise the matching `Router` is cloned (Arc-backed) and dispatched
191+
192+
Two Rust-side macros assemble the single mandatory `JNI_OnLoad`:
193+
194+
```rust
195+
vespera::jni_app!(create_app); // BC sugar for single default app
196+
197+
vespera::jni_apps! { // multi-app primary API
198+
"_default" => create_app,
199+
"admin" => admin_app,
200+
"public" => public_app,
201+
}
202+
```
203+
204+
### Spring Boot autoconfigure (Java side)
205+
206+
`vespera-bridge` ships a Spring Boot autoconfiguration that wires up
207+
`VesperaProxyController` + two strategy beans, both replaceable via
208+
`@ConditionalOnMissingBean`:
209+
210+
- `AppNameResolver` (default: `HeaderAppNameResolver("X-Vespera-App")`) — picks app per request
211+
- `DispatchModeResolver` (default: `BidirectionalStreamingDispatchModeResolver`) — picks `DispatchMode`
212+
213+
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.
214+
134215
### Rust side (example app — 2 lines of JNI code):
135216
```rust
136217
pub fn create_app() -> axum::Router { vespera!(...) }

Cargo.lock

Lines changed: 30 additions & 29 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -714,16 +714,51 @@ This automatically:
714714

715715
---
716716

717+
## JNI / Java Integration
718+
719+
Embed your Vespera router inside a Java/Spring application — no TCP, no JSON envelope overhead.
720+
721+
```rust
722+
// Cargo.toml
723+
// vespera = { version = "0.1", features = ["jni"] }
724+
725+
pub fn create_app() -> axum::Router {
726+
vespera!(title = "My API")
727+
}
728+
729+
vespera::jni_app!(create_app);
730+
```
731+
732+
```java
733+
@SpringBootApplication
734+
@ComponentScan(basePackages = {"com.example.app", "com.devfive.vespera.bridge"})
735+
public class MyApp {
736+
public static void main(String[] args) {
737+
VesperaBridge.init("my_rust_lib");
738+
SpringApplication.run(MyApp.class, args);
739+
}
740+
}
741+
```
742+
743+
The `VesperaProxyController` auto-registers as a catch-all and forwards every HTTP request through a length-prefixed **binary wire format** (`[u32 BE | UTF-8 JSON header | raw body]`) — multipart uploads, PDFs, and images travel raw, with zero base64 overhead.
744+
745+
See [`libs/vespera-bridge`](./libs/vespera-bridge/) for the Java library docs and [`examples/rust-jni-demo`](./examples/rust-jni-demo/) for a complete end-to-end demo.
746+
717747
## Project Structure
718748

719749
```
720750
vespera/
721751
├── crates/
722-
│ ├── vespera/ # Main crate - re-exports everything
723-
│ ├── vespera_core/ # OpenAPI types and abstractions
724-
│ └── vespera_macro/ # Proc-macros (compile-time magic)
752+
│ ├── vespera/ # Main crate - re-exports everything
753+
│ ├── vespera_core/ # OpenAPI types and abstractions
754+
│ ├── vespera_macro/ # Proc-macros (compile-time magic)
755+
│ ├── vespera_inprocess/ # In-process axum dispatch + binary wire API
756+
│ └── vespera_jni/ # JNI glue (Runtime + JNI symbol)
757+
├── libs/
758+
│ └── vespera-bridge/ # Java library (kr.devfive:vespera-bridge)
725759
└── examples/
726-
└── axum-example/ # Complete example application
760+
├── axum-example/ # Standalone OpenAPI server
761+
└── rust-jni-demo/ # Rust + Spring Boot JNI integration
727762
```
728763

729764
---

crates/vespera/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ garde = { workspace = true, optional = true }
5353
serde = { version = "1", features = ["derive"] }
5454
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
5555
tower = { version = "0.5", features = ["util"] }
56+
# Integration tests for the JNI dispatch path call
57+
# `vespera_inprocess::{register_app, dispatch_from_json}` directly so
58+
# they don't need the `inprocess` cargo feature to be enabled.
59+
vespera_inprocess = { workspace = true }
5660

5761
[lints]
5862
workspace = true

crates/vespera/src/lib.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ pub use vespera_inprocess as inprocess;
147147
#[cfg(feature = "jni")]
148148
pub use vespera_jni as jni;
149149

150-
/// Generate the `JNI_OnLoad` export that registers your app.
150+
/// Generate the `JNI_OnLoad` export that registers your app
151+
/// (single-app, default).
151152
///
152153
/// ```ignore
153154
/// vespera::jni_app!(create_app);
@@ -159,3 +160,23 @@ macro_rules! jni_app {
159160
$crate::jni::jni_app!($factory);
160161
};
161162
}
163+
164+
/// Generate the `JNI_OnLoad` export that registers **multiple named
165+
/// apps** for multi-app routing. See [`vespera_jni::jni_apps!`] for
166+
/// details.
167+
///
168+
/// ```ignore
169+
/// vespera::jni_apps! {
170+
/// "admin" => admin_app,
171+
/// "public" => public_app,
172+
/// }
173+
/// ```
174+
#[cfg(feature = "jni")]
175+
#[macro_export]
176+
macro_rules! jni_apps {
177+
( $( $name:literal => $factory:expr ),+ $(,)? ) => {
178+
$crate::jni::jni_apps! {
179+
$( $name => $factory ),+
180+
}
181+
};
182+
}

0 commit comments

Comments
 (0)