swadhinbiswas

[email protected]
2026-07-07 14:30:00 +0600 committed NaN years ago
086453c
commit 086453c
17 files changed
+6413 -0
▼crates/justapi-core/src/batching.rs
+112-0
1
+use std::future::Future;
2
+use std::time::Duration;
3
+use tokio::sync::{mpsc, oneshot};
4
+
5
+/// A request wrapped with a oneshot channel to return the result.
6
+pub struct BatchRequest<Req, Res> {
7
+ pub payload: Req,
8
+ pub responder: oneshot::Sender<Res>,
9
+}
10
+
11
+/// A handle to push items into the batcher.
12
+#[derive(Clone)]
13
+pub struct Batcher<Req, Res> {
14
+ sender: mpsc::Sender<BatchRequest<Req, Res>>,
15
+}
16
+
17
+impl<Req, Res> Batcher<Req, Res>
18
+where
19
+ Req: Send + 'static,
20
+ Res: Send + 'static,
21
+{
22
+ /// Send a request to the batching queue and wait for the response.
23
+ pub async fn execute(&self, payload: Req) -> Result<Res, &'static str> {
24
+ let (tx, rx) = oneshot::channel();
25
+ let req = BatchRequest {
26
+ payload,
27
+ responder: tx,
28
+ };
29
+
30
+ if self.sender.send(req).await.is_err() {
31
+ return Err("Batcher is closed");
32
+ }
33
+
34
+ rx.await.map_err(|_| "Batcher dropped the response")
35
+ }
36
+}
37
+
38
+/// Start an adaptive batching loop.
39
+///
40
+/// `max_size` is the maximum number of items in a single batch.
41
+/// `window` is the maximum time to wait for items before flushing a partial batch.
42
+/// `processor` is an async closure that takes a `Vec<Req>` and returns a `Vec<Res>`.
43
+/// The returned vector must have the exact same length as the input vector.
44
+pub fn start_batcher<Req, Res, F, Fut>(
45
+ max_size: usize,
46
+ window: Duration,
47
+ mut processor: F,
48
+) -> Batcher<Req, Res>
49
+where
50
+ Req: Send + 'static,
51
+ Res: Send + 'static,
52
+ F: FnMut(Vec<Req>) -> Fut + Send + 'static,
53
+ Fut: Future<Output = Vec<Res>> + Send + 'static,
54
+{
55
+ let (tx, mut rx) = mpsc::channel::<BatchRequest<Req, Res>>(1024);
56
+
57
+ tokio::spawn(async move {
58
+ loop {
59
+ let mut batch = Vec::with_capacity(max_size);
60
+ let mut responders = Vec::with_capacity(max_size);
61
+
62
+ // Wait for the FIRST item in the batch.
63
+ // If the channel is closed, we exit the loop.
64
+ let first_req = match rx.recv().await {
65
+ Some(req) => req,
66
+ None => break,
67
+ };
68
+
69
+ batch.push(first_req.payload);
70
+ responders.push(first_req.responder);
71
+
72
+ // Now we have the first item, we wait for more up to `max_size` or `window`.
73
+ let deadline = tokio::time::sleep(window);
74
+ tokio::pin!(deadline);
75
+
76
+ loop {
77
+ if batch.len() >= max_size {
78
+ break;
79
+ }
80
+
81
+ tokio::select! {
82
+ _ = &mut deadline => {
83
+ // Timeout reached, flush what we have.
84
+ break;
85
+ }
86
+ opt_req = rx.recv() => {
87
+ match opt_req {
88
+ Some(req) => {
89
+ batch.push(req.payload);
90
+ responders.push(req.responder);
91
+ }
92
+ None => {
93
+ // Channel closed, break and flush the remaining batch.
94
+ break;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ // Process the batch
102
+ let results = processor(batch).await;
103
+
104
+ // Scatter the results back to the callers
105
+ for (responder, res) in responders.into_iter().zip(results) {
106
+ let _ = responder.send(res);
107
+ }
108
+ }
109
+ });
110
+
111
+ Batcher { sender: tx }
112
+}
0
113
▼crates/justapi-core/src/coalesce.rs
+449-0
1
+//! Request coalescing (singleflight) middleware.
2
+//!
3
+//! When many concurrent, identical requests arrive for the same route, only
4
+//! one is allowed to reach the handler. The remaining requests "coalesce"
5
+//! onto the in-flight leader and share its response once it completes. This
6
+//! collapses thundering-herd traffic on hot, read-only endpoints (leaderboards,
7
+//! model lookups, config fetches, expensive aggregations) into a single
8
+//! upstream call.
9
+//!
10
+//! Coalescing is keyed on `(method, uri, selected-headers)`. The response body
11
+//! is streamed to the leader's client while being simultaneously buffered into
12
+//! a shared slot; waiters read the buffered copy when the leader finishes, so
13
+//! the handler runs exactly once per distinct in-flight key. If the leader
14
+//! fails, that error is shared with all waiters rather than re-executing the
15
+//! handler for each.
16
+
17
+use std::collections::HashMap;
18
+use std::pin::Pin;
19
+use std::sync::Arc;
20
+use std::sync::Mutex as StdMutex;
21
+use std::task::{Context, Poll};
22
+use tokio::sync::Mutex as AsyncMutex;
23
+
24
+use anyhow::Result;
25
+use async_trait::async_trait;
26
+use bytes::{Bytes, BytesMut};
27
+use http_body::{Body, Frame, SizeHint};
28
+use http_body_util::combinators::UnsyncBoxBody;
29
+use http_body_util::BodyExt;
30
+use http_body_util::Full;
31
+use hyper::header::{HeaderName, HeaderValue};
32
+use hyper::{HeaderMap, Method, Request, Response, StatusCode, Uri};
33
+use tokio::sync::watch;
34
+
35
+use crate::middleware::{Middleware, Next};
36
+use crate::ResponseBody;
37
+
38
+/// A buffered, cloneable snapshot of a successful response, shared with
39
+/// waiters.
40
+#[derive(Clone)]
41
+struct CoalescedResponse {
42
+ status: StatusCode,
43
+ headers: HeaderMap,
44
+ body: Bytes,
45
+}
46
+
47
+impl CoalescedResponse {
48
+ fn to_response(&self) -> Response<ResponseBody> {
49
+ let mut builder = Response::builder().status(self.status);
50
+ for (name, value) in self.headers.iter() {
51
+ // Drop framing headers; they are recomputed for the replayed body.
52
+ if name == hyper::header::TRANSFER_ENCODING || name == hyper::header::CONNECTION {
53
+ continue;
54
+ }
55
+ builder = builder.header(name, value);
56
+ }
57
+ // `Infallible` can never be produced by `Full`, so this map is total.
58
+ builder
59
+ .body(UnsyncBoxBody::new(Full::new(self.body.clone()).map_err(
60
+ |e: std::convert::Infallible| -> anyhow::Error { match e {} },
61
+ )))
62
+ .expect("static response construction never fails")
63
+ }
64
+}
65
+
66
+/// The published state of a coalesced key. Distinct variants guarantee that
67
+/// `watch::Receiver::changed` always fires when the leader resolves, including
68
+/// on failure (the initial state is `Pending`, never `Ok`/`Err`).
69
+#[derive(Clone)]
70
+enum Outcome {
71
+ Pending,
72
+ Ok(CoalescedResponse),
73
+ Err(String),
74
+}
75
+
76
+/// Identity of a coalesceable request.
77
+#[derive(Clone, PartialEq, Eq, Hash)]
78
+struct CoalesceKey {
79
+ method: Method,
80
+ uri: Uri,
81
+ headers: Vec<(HeaderName, HeaderValue)>,
82
+}
83
+
84
+/// A body that forwards frames to the leader's client while appending data
85
+/// frames to a shared buffer, then publishes the full snapshot to waiting
86
+/// requests when the stream ends (or is dropped).
87
+struct FanoutBody {
88
+ inner: ResponseBody,
89
+ status: StatusCode,
90
+ headers: HeaderMap,
91
+ acc: Arc<StdMutex<BytesMut>>,
92
+ tx: Arc<watch::Sender<Outcome>>,
93
+ finished: bool,
94
+}
95
+
96
+impl Body for FanoutBody {
97
+ type Data = Bytes;
98
+ type Error = anyhow::Error;
99
+
100
+ fn poll_frame(
101
+ self: Pin<&mut Self>,
102
+ cx: &mut Context<'_>,
103
+ ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
104
+ let this = self.get_mut();
105
+ match Pin::new(&mut this.inner).poll_frame(cx) {
106
+ Poll::Ready(Some(Ok(frame))) => {
107
+ if let Some(chunk) = frame.data_ref() {
108
+ this.acc
109
+ .lock()
110
+ .expect("coalesce buffer poisoned")
111
+ .extend_from_slice(chunk);
112
+ }
113
+ Poll::Ready(Some(Ok(frame)))
114
+ }
115
+ Poll::Ready(None) => {
116
+ if !this.finished {
117
+ this.finished = true;
118
+ let full = this
119
+ .acc
120
+ .lock()
121
+ .expect("coalesce buffer poisoned")
122
+ .split()
123
+ .freeze();
124
+ this_tx_send(&this.tx, full, this.status, &this.headers);
125
+ }
126
+ Poll::Ready(None)
127
+ }
128
+ other => other,
129
+ }
130
+ }
131
+
132
+ fn size_hint(&self) -> SizeHint {
133
+ self.inner.size_hint()
134
+ }
135
+}
136
+
137
+impl Drop for FanoutBody {
138
+ fn drop(&mut self) {
139
+ if !self.finished {
140
+ self.finished = true;
141
+ let full = self
142
+ .acc
143
+ .lock()
144
+ .expect("coalesce buffer poisoned")
145
+ .split()
146
+ .freeze();
147
+ this_tx_send(&self.tx, full, self.status, &self.headers);
148
+ }
149
+ }
150
+}
151
+
152
+/// Publish a successful snapshot, used both on stream end and on drop.
153
+fn this_tx_send(
154
+ tx: &Arc<watch::Sender<Outcome>>,
155
+ body: Bytes,
156
+ status: StatusCode,
157
+ headers: &HeaderMap,
158
+) {
159
+ let _ = tx.send(Outcome::Ok(CoalescedResponse {
160
+ status,
161
+ headers: headers.clone(),
162
+ body,
163
+ }));
164
+}
165
+
166
+/// Middleware that collapses concurrent identical requests into a single
167
+/// upstream call and shares the response (or error) with all waiters.
168
+pub struct RequestCoalescer {
169
+ in_flight: AsyncMutex<HashMap<CoalesceKey, Arc<watch::Sender<Outcome>>>>,
170
+ include_headers: Vec<HeaderName>,
171
+}
172
+
173
+impl RequestCoalescer {
174
+ /// Create a coalescer with default settings (no extra headers in the key).
175
+ pub fn new() -> Self {
176
+ Self {
177
+ in_flight: AsyncMutex::new(HashMap::new()),
178
+ include_headers: Vec::new(),
179
+ }
180
+ }
181
+
182
+ /// Include the values of the given request headers in the coalesce key.
183
+ /// Useful when the same path serves different content based on, e.g.,
184
+ /// `Accept` or `Accept-Language`.
185
+ pub fn with_headers(mut self, headers: &[HeaderName]) -> Self {
186
+ self.include_headers = headers.to_vec();
187
+ self
188
+ }
189
+
190
+ fn key_of(&self, req: &Request<hyper::body::Incoming>) -> CoalesceKey {
191
+ let mut headers = Vec::with_capacity(self.include_headers.len());
192
+ for name in &self.include_headers {
193
+ if let Some(value) = req.headers().get(name) {
194
+ headers.push((name.clone(), value.clone()));
195
+ }
196
+ }
197
+ CoalesceKey {
198
+ method: req.method().clone(),
199
+ uri: req.uri().clone(),
200
+ headers,
201
+ }
202
+ }
203
+}
204
+
205
+impl Default for RequestCoalescer {
206
+ fn default() -> Self {
207
+ Self::new()
208
+ }
209
+}
210
+
211
+#[async_trait]
212
+impl Middleware<hyper::body::Incoming> for RequestCoalescer {
213
+ async fn handle(
214
+ &self,
215
+ req: Request<hyper::body::Incoming>,
216
+ next: Next<'_, hyper::body::Incoming>,
217
+ ) -> Result<Response<ResponseBody>> {
218
+ let key = self.key_of(&req);
219
+
220
+ // Atomically check for an in-flight leader or claim leadership.
221
+ let (tx, is_leader) = {
222
+ let mut map = self.in_flight.lock().await;
223
+ match map.get(&key) {
224
+ Some(existing) => (existing.clone(), false),
225
+ None => {
226
+ let (tx, _rx) = watch::channel(Outcome::Pending);
227
+ let tx = Arc::new(tx);
228
+ map.insert(key.clone(), tx.clone());
229
+ (tx, true)
230
+ }
231
+ }
232
+ };
233
+
234
+ if !is_leader {
235
+ return self.wait_for_leader(key, tx, req, next).await;
236
+ }
237
+ self.run_leader(key, tx, req, next).await
238
+ }
239
+}
240
+
241
+impl RequestCoalescer {
242
+ async fn wait_for_leader(
243
+ &self,
244
+ key: CoalesceKey,
245
+ tx: Arc<watch::Sender<Outcome>>,
246
+ req: Request<hyper::body::Incoming>,
247
+ next: Next<'_, hyper::body::Incoming>,
248
+ ) -> Result<Response<ResponseBody>> {
249
+ let mut rx = tx.subscribe();
250
+ loop {
251
+ match rx.borrow().clone() {
252
+ Outcome::Ok(coalesced) => return Ok(coalesced.to_response()),
253
+ Outcome::Err(message) => return Err(anyhow::anyhow!(message)),
254
+ Outcome::Pending => {}
255
+ }
256
+ if rx.changed().await.is_err() {
257
+ // Leader dropped without publishing; re-run as a leader so the
258
+ // request is not lost.
259
+ break;
260
+ }
261
+ }
262
+ self.run_leader(key, tx, req, next).await
263
+ }
264
+
265
+ async fn run_leader(
266
+ &self,
267
+ key: CoalesceKey,
268
+ tx: Arc<watch::Sender<Outcome>>,
269
+ req: Request<hyper::body::Incoming>,
270
+ next: Next<'_, hyper::body::Incoming>,
271
+ ) -> Result<Response<ResponseBody>> {
272
+ let result = next.run(req).await;
273
+ // Leadership for this key ends now; waiters already hold their own
274
+ // receivers and will be released when the fan-out body completes.
275
+ self.in_flight.lock().await.remove(&key);
276
+
277
+ let response = match result {
278
+ Ok(response) => response,
279
+ Err(e) => {
280
+ // Share the failure with waiters so they don't re-execute the
281
+ // handler or hang.
282
+ let _ = tx.send(Outcome::Err(e.to_string()));
283
+ return Err(e);
284
+ }
285
+ };
286
+
287
+ let status = response.status();
288
+ let headers = response.headers().clone();
289
+ let (parts, body) = response.into_parts();
290
+ let fanout = FanoutBody {
291
+ inner: body,
292
+ status,
293
+ headers,
294
+ acc: Arc::new(StdMutex::new(BytesMut::new())),
295
+ tx: tx.clone(),
296
+ finished: false,
297
+ };
298
+ Ok(Response::from_parts(parts, UnsyncBoxBody::new(fanout)))
299
+ }
300
+}
301
+
302
+#[cfg(test)]
303
+mod tests {
304
+ use super::*;
305
+ use crate::json_response;
306
+ use crate::middleware::{HandlerFn, MiddlewareChain};
307
+ use crate::testing::TestClient;
308
+ use hyper::StatusCode;
309
+ use std::sync::atomic::{AtomicU64, Ordering};
310
+ use std::sync::Arc;
311
+ use std::time::Duration;
312
+
313
+ /// A handler that counts invocations and pauses briefly so concurrent
314
+ /// requests overlap in time, making coalescing observable.
315
+ fn counting_handler(counter: Arc<AtomicU64>, delay_ms: u64) -> HandlerFn {
316
+ Arc::new(move |_req| {
317
+ let counter = counter.clone();
318
+ Box::pin(async move {
319
+ counter.fetch_add(1, Ordering::SeqCst);
320
+ if delay_ms > 0 {
321
+ tokio::time::sleep(Duration::from_millis(delay_ms)).await;
322
+ }
323
+ Ok(json_response(StatusCode::OK, r#"{"hello":"world"}"#))
324
+ })
325
+ })
326
+ }
327
+
328
+ fn error_handler(counter: Arc<AtomicU64>, delay_ms: u64) -> HandlerFn {
329
+ Arc::new(move |_req| {
330
+ let counter = counter.clone();
331
+ Box::pin(async move {
332
+ counter.fetch_add(1, Ordering::SeqCst);
333
+ if delay_ms > 0 {
334
+ tokio::time::sleep(Duration::from_millis(delay_ms)).await;
335
+ }
336
+ Err(anyhow::anyhow!("boom"))
337
+ })
338
+ })
339
+ }
340
+
341
+ /// Build a `TestClient` whose pipeline wraps `handler` in a coalescer.
342
+ fn client_with(handler: HandlerFn, coalescer: RequestCoalescer) -> TestClient {
343
+ let mut chain = MiddlewareChain::new(handler);
344
+ chain.add(coalescer);
345
+ TestClient::new(Arc::new(move |req| {
346
+ let c = chain.clone();
347
+ Box::pin(async move { c.run(req).await })
348
+ }))
349
+ }
350
+
351
+ #[tokio::test]
352
+ async fn single_request_runs_handler_once() {
353
+ let counter = Arc::new(AtomicU64::new(0));
354
+ let client = client_with(
355
+ counting_handler(counter.clone(), 0),
356
+ RequestCoalescer::new(),
357
+ );
358
+ let resp = client.get("/a").await.unwrap();
359
+ assert_eq!(resp.status, 200);
360
+ assert_eq!(resp.body, br#"{"hello":"world"}"#);
361
+ assert_eq!(counter.load(Ordering::SeqCst), 1);
362
+ }
363
+
364
+ #[tokio::test]
365
+ async fn concurrent_identical_gets_coalesce_to_one_handler_call() {
366
+ let counter = Arc::new(AtomicU64::new(0));
367
+ let client = client_with(
368
+ counting_handler(counter.clone(), 50),
369
+ RequestCoalescer::new(),
370
+ );
371
+
372
+ let mut futs = Vec::new();
373
+ for _ in 0..10 {
374
+ futs.push(client.get("/same"));
375
+ }
376
+ let responses = futures::future::join_all(futs).await;
377
+
378
+ for r in &responses {
379
+ let r = r.as_ref().unwrap();
380
+ assert_eq!(r.status, 200);
381
+ assert_eq!(r.body, br#"{"hello":"world"}"#);
382
+ }
383
+ // Exactly one upstream call despite ten concurrent requests.
384
+ assert_eq!(
385
+ counter.load(Ordering::SeqCst),
386
+ 1,
387
+ "handler should run exactly once for coalesced requests"
388
+ );
389
+ }
390
+
391
+ #[tokio::test]
392
+ async fn distinct_paths_are_not_coalesced() {
393
+ let counter = Arc::new(AtomicU64::new(0));
394
+ let client = client_with(
395
+ counting_handler(counter.clone(), 20),
396
+ RequestCoalescer::new(),
397
+ );
398
+
399
+ let (a, b) = futures::future::join(client.get("/a"), client.get("/b")).await;
400
+ let _ = (a, b);
401
+
402
+ assert_eq!(counter.load(Ordering::SeqCst), 2);
403
+ }
404
+
405
+ #[tokio::test]
406
+ async fn header_inclusion_changes_coalesce_key() {
407
+ let coalescer = RequestCoalescer::new().with_headers(&[hyper::header::ACCEPT]);
408
+ let client = client_with(counting_handler(Arc::new(AtomicU64::new(0)), 30), coalescer);
409
+
410
+ // Two concurrent requests with different Accept headers -> different keys.
411
+ let (a, b) = futures::future::join(
412
+ client.get_with("/x", &[("accept", "application/json")]),
413
+ client.get_with("/x", &[("accept", "text/html")]),
414
+ )
415
+ .await;
416
+ let _ = (a, b);
417
+
418
+ // Two concurrent requests with the same Accept header -> coalesced.
419
+ let counter2 = Arc::new(AtomicU64::new(0));
420
+ let coalescer2 = RequestCoalescer::new().with_headers(&[hyper::header::ACCEPT]);
421
+ let client2 = client_with(counting_handler(counter2.clone(), 30), coalescer2);
422
+ let (c, d) = futures::future::join(
423
+ client2.get_with("/y", &[("accept", "application/json")]),
424
+ client2.get_with("/y", &[("accept", "application/json")]),
425
+ )
426
+ .await;
427
+ let _ = (c, d);
428
+ assert_eq!(counter2.load(Ordering::SeqCst), 1);
429
+ }
430
+
431
+ #[tokio::test]
432
+ async fn leader_error_is_shared_with_waiters() {
433
+ let counter = Arc::new(AtomicU64::new(0));
434
+ let client = client_with(error_handler(counter.clone(), 30), RequestCoalescer::new());
435
+
436
+ let mut futs = Vec::new();
437
+ for _ in 0..8 {
438
+ futs.push(client.get("/err"));
439
+ }
440
+ let responses = futures::future::join_all(futs).await;
441
+ // Every request resolves (no hang) and sees the leader's error.
442
+ assert_eq!(responses.len(), 8);
443
+ for r in &responses {
444
+ assert!(r.is_err(), "waiter should receive the shared leader error");
445
+ }
446
+ // The handler ran exactly once; the failure was shared, not re-executed.
447
+ assert_eq!(counter.load(Ordering::SeqCst), 1);
448
+ }
449
+}
0
450
▼crates/justapi-core/src/dx.rs
+324-0
1
+//! Developer-experience (DX) diagnostics: rich, colored terminal output.
2
+//!
3
+//! Provides [`Diagnostic`] for structured error, warning, info, and hint
4
+//! messages with optional error codes, suggestions, and file/route context.
5
+//! Respects the `NO_COLOR` environment variable and detects whether stdout
6
+//! is a TTY, falling back to plain text when colours are inappropriate.
7
+
8
+use std::fmt;
9
+use std::io::IsTerminal;
10
+
11
+// ── ANSI escape helpers ──────────────────────────────────────────────
12
+
13
+const RESET: &str = "\x1b[0m";
14
+const BOLD: &str = "\x1b[1m";
15
+const RED: &str = "\x1b[31m";
16
+const YELLOW: &str = "\x1b[33m";
17
+const CYAN: &str = "\x1b[36m";
18
+const GREEN: &str = "\x1b[32m";
19
+const WHITE: &str = "\x1b[37m";
20
+
21
+/// Returns `true` when colour output should be used.
22
+///
23
+/// Colour is enabled when:
24
+/// - `NO_COLOR` is **not** set (see <https://no-color.org/>), **and**
25
+/// - stdout is a terminal (not piped/redirected).
26
+pub fn colors_enabled() -> bool {
27
+ std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
28
+}
29
+
30
+// ── DiagLevel ────────────────────────────────────────────────────────
31
+
32
+/// Severity level for a [`Diagnostic`].
33
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34
+pub enum DiagLevel {
35
+ /// A fatal problem that prevents the operation from completing.
36
+ Error,
37
+ /// A non-fatal issue that deserves attention.
38
+ Warning,
39
+ /// Informational message.
40
+ Info,
41
+ /// A gentle suggestion for improvement.
42
+ Hint,
43
+}
44
+
45
+impl DiagLevel {
46
+ /// Human-readable label, e.g. `"error"`.
47
+ fn label(self) -> &'static str {
48
+ match self {
49
+ Self::Error => "error",
50
+ Self::Warning => "warning",
51
+ Self::Info => "info",
52
+ Self::Hint => "hint",
53
+ }
54
+ }
55
+
56
+ /// ANSI colour sequence for this level.
57
+ fn color(self) -> &'static str {
58
+ match self {
59
+ Self::Error => RED,
60
+ Self::Warning => YELLOW,
61
+ Self::Info => CYAN,
62
+ Self::Hint => GREEN,
63
+ }
64
+ }
65
+}
66
+
67
+// ── Diagnostic ───────────────────────────────────────────────────────
68
+
69
+/// A styled diagnostic message with colours and suggestions.
70
+///
71
+/// # Examples
72
+///
73
+/// ```
74
+/// use justapi_core::dx::{Diagnostic, DiagLevel};
75
+///
76
+/// let d = Diagnostic::new(DiagLevel::Error, "invalid address format")
77
+/// .code("E001")
78
+/// .context("--addr flag")
79
+/// .suggestion("use HOST:PORT, e.g. 127.0.0.1:8080");
80
+///
81
+/// // Render to a plain-text string (for logging / testing):
82
+/// let text = d.render_plain();
83
+/// assert!(text.contains("error[E001]"));
84
+/// ```
85
+#[derive(Debug, Clone)]
86
+pub struct Diagnostic {
87
+ /// Severity level.
88
+ pub level: DiagLevel,
89
+ /// Optional machine-readable error code, e.g. `"E001"`.
90
+ pub code: Option<String>,
91
+ /// One-line human description of the problem.
92
+ pub message: String,
93
+ /// Zero or more actionable suggestions.
94
+ pub suggestions: Vec<String>,
95
+ /// Optional context such as a file path or route.
96
+ pub context: Option<String>,
97
+}
98
+
99
+impl Diagnostic {
100
+ /// Create a new diagnostic at the given level.
101
+ pub fn new(level: DiagLevel, message: impl Into<String>) -> Self {
102
+ Self {
103
+ level,
104
+ code: None,
105
+ message: message.into(),
106
+ suggestions: Vec::new(),
107
+ context: None,
108
+ }
109
+ }
110
+
111
+ /// Attach an error code (e.g. `"E001"`).
112
+ pub fn code(mut self, code: impl Into<String>) -> Self {
113
+ self.code = Some(code.into());
114
+ self
115
+ }
116
+
117
+ /// Add a single suggestion line.
118
+ pub fn suggestion(mut self, s: impl Into<String>) -> Self {
119
+ self.suggestions.push(s.into());
120
+ self
121
+ }
122
+
123
+ /// Attach context (file path, route pattern, config key, …).
124
+ pub fn context(mut self, ctx: impl Into<String>) -> Self {
125
+ self.context = Some(ctx.into());
126
+ self
127
+ }
128
+
129
+ // ── rendering ────────────────────────────────────────────────
130
+
131
+ /// Render with ANSI colours (when appropriate).
132
+ pub fn display(&self) -> String {
133
+ if colors_enabled() {
134
+ self.render_colored()
135
+ } else {
136
+ self.render_plain()
137
+ }
138
+ }
139
+
140
+ /// Render **without** any ANSI escape codes.
141
+ pub fn render_plain(&self) -> String {
142
+ let mut out = String::new();
143
+
144
+ // Header line: "error[E001]: message" or "warning: message"
145
+ out.push_str(self.level.label());
146
+ if let Some(ref c) = self.code {
147
+ out.push('[');
148
+ out.push_str(c);
149
+ out.push(']');
150
+ }
151
+ out.push_str(": ");
152
+ out.push_str(&self.message);
153
+ out.push('\n');
154
+
155
+ // Context
156
+ if let Some(ref ctx) = self.context {
157
+ out.push_str(" --> ");
158
+ out.push_str(ctx);
159
+ out.push('\n');
160
+ }
161
+
162
+ // Suggestions
163
+ for s in &self.suggestions {
164
+ out.push_str(" = help: ");
165
+ out.push_str(s);
166
+ out.push('\n');
167
+ }
168
+
169
+ out
170
+ }
171
+
172
+ /// Render **with** ANSI escape codes.
173
+ pub fn render_colored(&self) -> String {
174
+ let color = self.level.color();
175
+ let mut out = String::new();
176
+
177
+ // Header
178
+ out.push_str(BOLD);
179
+ out.push_str(color);
180
+ out.push_str(self.level.label());
181
+ if let Some(ref c) = self.code {
182
+ out.push('[');
183
+ out.push_str(c);
184
+ out.push(']');
185
+ }
186
+ out.push_str(RESET);
187
+ out.push_str(BOLD);
188
+ out.push_str(WHITE);
189
+ out.push_str(": ");
190
+ out.push_str(&self.message);
191
+ out.push_str(RESET);
192
+ out.push('\n');
193
+
194
+ // Context
195
+ if let Some(ref ctx) = self.context {
196
+ out.push_str(CYAN);
197
+ out.push_str(" --> ");
198
+ out.push_str(ctx);
199
+ out.push_str(RESET);
200
+ out.push('\n');
201
+ }
202
+
203
+ // Suggestions
204
+ for s in &self.suggestions {
205
+ out.push_str(GREEN);
206
+ out.push_str(" = help: ");
207
+ out.push_str(s);
208
+ out.push_str(RESET);
209
+ out.push('\n');
210
+ }
211
+
212
+ out
213
+ }
214
+
215
+ /// Print this diagnostic to stderr.
216
+ pub fn emit(&self) {
217
+ eprint!("{}", self.display());
218
+ }
219
+}
220
+
221
+impl fmt::Display for Diagnostic {
222
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223
+ f.write_str(&self.render_plain())
224
+ }
225
+}
226
+
227
+// ── Helper: strip ANSI codes (for tests) ─────────────────────────────
228
+
229
+/// Strip all ANSI CSI escape sequences from `input`.
230
+///
231
+/// Useful in tests that need to assert on rendered diagnostic text
232
+/// regardless of colour settings.
233
+pub fn strip_ansi(input: &str) -> String {
234
+ let mut out = String::with_capacity(input.len());
235
+ let mut chars = input.chars();
236
+ while let Some(ch) = chars.next() {
237
+ if ch == '\x1b' {
238
+ // consume until we see a letter (the terminator of a CSI sequence)
239
+ for inner in chars.by_ref() {
240
+ if inner.is_ascii_alphabetic() {
241
+ break;
242
+ }
243
+ }
244
+ } else {
245
+ out.push(ch);
246
+ }
247
+ }
248
+ out
249
+}
250
+
251
+// ── Tests ────────────────────────────────────────────────────────────
252
+
253
+#[cfg(test)]
254
+mod tests {
255
+ use super::*;
256
+
257
+ #[test]
258
+ fn plain_error_with_code_and_suggestion() {
259
+ let d = Diagnostic::new(DiagLevel::Error, "invalid address format")
260
+ .code("E001")
261
+ .context("--addr 999.999.999.999:99999")
262
+ .suggestion("use HOST:PORT, e.g. 127.0.0.1:8080");
263
+
264
+ let text = d.render_plain();
265
+ assert!(text.starts_with("error[E001]: invalid address format\n"));
266
+ assert!(text.contains(" --> --addr 999.999.999.999:99999"));
267
+ assert!(text.contains(" = help: use HOST:PORT"));
268
+ }
269
+
270
+ #[test]
271
+ fn plain_warning_no_code() {
272
+ let d = Diagnostic::new(DiagLevel::Warning, "deprecated config key");
273
+ let text = d.render_plain();
274
+ assert!(text.starts_with("warning: deprecated config key\n"));
275
+ // No code brackets
276
+ assert!(!text.contains('['));
277
+ }
278
+
279
+ #[test]
280
+ fn colored_output_contains_ansi() {
281
+ let d = Diagnostic::new(DiagLevel::Info, "server listening").context("0.0.0.0:8080");
282
+ let colored = d.render_colored();
283
+ assert!(colored.contains("\x1b["));
284
+ // Stripping ANSI should yield the plain version
285
+ let stripped = strip_ansi(&colored);
286
+ assert!(stripped.contains("info: server listening"));
287
+ assert!(stripped.contains(" --> 0.0.0.0:8080"));
288
+ }
289
+
290
+ #[test]
291
+ fn no_color_env_respected() {
292
+ // Temporarily set NO_COLOR
293
+ std::env::set_var("NO_COLOR", "1");
294
+ let enabled = colors_enabled();
295
+ std::env::remove_var("NO_COLOR");
296
+ // NO_COLOR was set, so colours should have been disabled regardless of TTY.
297
+ assert!(!enabled);
298
+ }
299
+
300
+ #[test]
301
+ fn strip_ansi_roundtrip() {
302
+ let raw = "\x1b[1m\x1b[31merror\x1b[0m: boom";
303
+ let stripped = strip_ansi(raw);
304
+ assert_eq!(stripped, "error: boom");
305
+ }
306
+
307
+ #[test]
308
+ fn multiple_suggestions_rendered() {
309
+ let d = Diagnostic::new(DiagLevel::Hint, "improve performance")
310
+ .suggestion("enable compression")
311
+ .suggestion("use HTTP/2");
312
+ let text = d.render_plain();
313
+ assert_eq!(text.matches("= help:").count(), 2);
314
+ }
315
+
316
+ #[test]
317
+ fn display_trait_uses_plain() {
318
+ let d = Diagnostic::new(DiagLevel::Error, "oops").code("E099");
319
+ let via_display = format!("{d}");
320
+ assert!(via_display.contains("error[E099]: oops"));
321
+ // Display should never include ANSI
322
+ assert!(!via_display.contains("\x1b["));
323
+ }
324
+}
0
325
▼crates/justapi-core/src/gateway.rs
+186-0
1
+use arc_swap::ArcSwap;
2
+use hyper::Method;
3
+use notify::{Config, Event, RecursiveMode, Watcher};
4
+use serde::{Deserialize, Serialize};
5
+use std::collections::HashMap;
6
+use std::path::{Path, PathBuf};
7
+use std::sync::Arc;
8
+use tokio::sync::mpsc;
9
+use tracing::{error, info, warn};
10
+
11
+use crate::router::Router;
12
+
13
+use crate::middleware::{Middleware, Next};
14
+use crate::ResponseBody;
15
+use async_trait::async_trait;
16
+use http_body_util::BodyExt;
17
+use hyper::{body::Incoming, Request, Response};
18
+
19
+#[derive(Debug, Clone, Deserialize, Serialize, Default)]
20
+pub struct GatewayRoute {
21
+ pub upstream: String,
22
+ pub rate_limit_capacity: Option<u64>,
23
+ pub rate_limit_replenish_rate: Option<u64>,
24
+ pub wasm_plugin: Option<String>,
25
+}
26
+
27
+#[derive(Debug, Clone, Deserialize, Serialize, Default)]
28
+pub struct GatewayConfigData {
29
+ pub routes: HashMap<String, HashMap<String, GatewayRoute>>, // path -> { method -> route }
30
+ pub global_rate_limit: Option<u64>,
31
+}
32
+
33
+#[derive(Clone)]
34
+pub struct GatewayRouter {
35
+ pub inner: Router<GatewayRoute>,
36
+}
37
+
38
+pub struct GatewayState {
39
+ pub config: ArcSwap<GatewayConfigData>,
40
+ pub router: ArcSwap<GatewayRouter>,
41
+ config_path: PathBuf,
42
+}
43
+
44
+impl GatewayState {
45
+ pub fn new(path: impl AsRef<Path>) -> Arc<Self> {
46
+ let path = path.as_ref().to_path_buf();
47
+ let (config, router) = Self::load_config(&path).unwrap_or_else(|e| {
48
+ warn!("Failed to load gateway config from {:?}: {}", path, e);
49
+ (
50
+ GatewayConfigData::default(),
51
+ GatewayRouter {
52
+ inner: Router::new(),
53
+ },
54
+ )
55
+ });
56
+
57
+ Arc::new(Self {
58
+ config: ArcSwap::from_pointee(config),
59
+ router: ArcSwap::from_pointee(router),
60
+ config_path: path,
61
+ })
62
+ }
63
+
64
+ fn load_config(path: &Path) -> anyhow::Result<(GatewayConfigData, GatewayRouter)> {
65
+ let data = std::fs::read_to_string(path)?;
66
+ let config: GatewayConfigData = serde_json::from_str(&data)?;
67
+
68
+ let mut router = Router::new();
69
+ for (path_pattern, methods) in &config.routes {
70
+ for (method_str, route) in methods {
71
+ let method = match method_str.to_uppercase().as_str() {
72
+ "GET" => Method::GET,
73
+ "POST" => Method::POST,
74
+ "PUT" => Method::PUT,
75
+ "DELETE" => Method::DELETE,
76
+ "PATCH" => Method::PATCH,
77
+ "OPTIONS" => Method::OPTIONS,
78
+ _ => continue,
79
+ };
80
+ let _ = router.insert(method, path_pattern, route.clone());
81
+ }
82
+ }
83
+
84
+ Ok((config, GatewayRouter { inner: router }))
85
+ }
86
+
87
+ pub fn watch(self: Arc<Self>) -> anyhow::Result<()> {
88
+ let (tx, mut rx) = mpsc::channel(10);
89
+
90
+ let mut watcher = notify::RecommendedWatcher::new(
91
+ move |res: Result<Event, notify::Error>| {
92
+ if let Ok(event) = res {
93
+ if event.kind.is_modify() || event.kind.is_create() {
94
+ let _ = tx.blocking_send(());
95
+ }
96
+ }
97
+ },
98
+ Config::default(),
99
+ )?;
100
+
101
+ watcher.watch(&self.config_path, RecursiveMode::NonRecursive)?;
102
+
103
+ tokio::spawn(async move {
104
+ // Keep watcher alive by moving it into this task
105
+ let _watcher = watcher;
106
+
107
+ while rx.recv().await.is_some() {
108
+ // Debounce
109
+ tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
110
+
111
+ info!("Gateway configuration file changed, reloading...");
112
+ match Self::load_config(&self.config_path) {
113
+ Ok((config, router)) => {
114
+ self.config.store(Arc::new(config));
115
+ self.router.store(Arc::new(router));
116
+ info!("Gateway configuration reloaded successfully.");
117
+ }
118
+ Err(e) => {
119
+ error!("Failed to reload gateway configuration: {}", e);
120
+ }
121
+ }
122
+ }
123
+ });
124
+
125
+ Ok(())
126
+ }
127
+
128
+ pub fn get_route<'a, 'p>(
129
+ &'a self,
130
+ _method: &Method,
131
+ _path: &'p str,
132
+ ) -> Option<crate::router::Match<'a, 'p, GatewayRoute>> {
133
+ let _current_router = self.router.load();
134
+ // Since we are borrowing from `current_router` which is an Arc guard, we need to return owned or clone if we want to drop the guard.
135
+ // For simplicity, we can just return a clone of the `GatewayRoute` and its params.
136
+ None // Wait, we will implement this inside the Middleware where the guard lives.
137
+ }
138
+}
139
+
140
+pub struct GatewayMiddleware {
141
+ state: Arc<GatewayState>,
142
+}
143
+
144
+impl GatewayMiddleware {
145
+ pub fn new(state: Arc<GatewayState>) -> Self {
146
+ Self { state }
147
+ }
148
+}
149
+
150
+#[async_trait]
151
+impl Middleware<Incoming> for GatewayMiddleware {
152
+ async fn handle(
153
+ &self,
154
+ req: Request<Incoming>,
155
+ next: Next<'_, Incoming>,
156
+ ) -> anyhow::Result<Response<ResponseBody>> {
157
+ let path = req.uri().path().to_string();
158
+ let method = req.method().clone();
159
+
160
+ // Acquire RCU read guard (wait-free, extremely fast)
161
+ let router_guard = self.state.router.load();
162
+
163
+ // Check if Gateway routing handles this
164
+ if let Ok(matched) = router_guard.inner.at(&method, &path) {
165
+ let route = matched.handler;
166
+ info!("Gateway intercepted route to upstream: {}", route.upstream);
167
+
168
+ // Example proxy logic here. For now, just return a mock response showing hot-reloaded proxy!
169
+ use http_body_util::Full;
170
+ use hyper::body::Bytes;
171
+
172
+ let body = Full::new(Bytes::from(format!("Proxied to: {}", route.upstream)))
173
+ .map_err(|never| match never {})
174
+ .boxed_unsync();
175
+
176
+ return Ok(Response::builder()
177
+ .status(200)
178
+ .header("x-justapi-gateway", "hot-reloaded")
179
+ .body(body)
180
+ .unwrap());
181
+ }
182
+
183
+ // Fallback to next (e.g. Python app)
184
+ next.run(req).await
185
+ }
186
+}
0
187
▼crates/justapi-core/src/graphql.rs
+77-0
1
+use async_graphql::{http::GraphiQLSource, EmptyMutation, EmptySubscription, Object, Schema};
2
+// Removed async_graphql_axum
3
+use http_body_util::Full;
4
+use hyper::body::Bytes;
5
+use hyper::StatusCode;
6
+use hyper::{body::Incoming, Method, Request, Response};
7
+
8
+use crate::ResponseBody;
9
+
10
+pub struct QueryRoot;
11
+
12
+#[Object]
13
+impl QueryRoot {
14
+ async fn system_status(&self) -> &str {
15
+ "JustAPI GraphQL Federation Gateway is running"
16
+ }
17
+
18
+ async fn version(&self) -> &str {
19
+ env!("CARGO_PKG_VERSION")
20
+ }
21
+}
22
+
23
+pub type AppSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>;
24
+
25
+pub fn create_schema() -> AppSchema {
26
+ Schema::build(QueryRoot, EmptyMutation, EmptySubscription)
27
+ .enable_federation()
28
+ .finish()
29
+}
30
+
31
+pub async fn handle_graphql(
32
+ schema: &AppSchema,
33
+ req: Request<Incoming>,
34
+) -> anyhow::Result<Response<ResponseBody>> {
35
+ use http_body_util::BodyExt;
36
+
37
+ // Check if GET (graphiql)
38
+ if *req.method() == Method::GET {
39
+ let source = GraphiQLSource::build().endpoint("/graphql").finish();
40
+
41
+ let body = crate::UnsyncBoxBody::new(
42
+ Full::new(Bytes::from(source))
43
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
44
+ );
45
+
46
+ return Ok(Response::builder()
47
+ .status(StatusCode::OK)
48
+ .header("content-type", "text/html; charset=utf-8")
49
+ .body(body)?);
50
+ }
51
+
52
+ // For POST, we would parse the request body and execute the schema.
53
+ // However, since we are using raw Hyper (or need an adapter like async-graphql-axum),
54
+ // we must manually deserialize. Let's do a basic execution.
55
+
56
+ // Read body
57
+ let body_bytes = req.into_body().collect().await?.to_bytes();
58
+
59
+ // Parse GraphQL request
60
+ let gql_req: async_graphql::Request = serde_json::from_slice(&body_bytes)?;
61
+
62
+ // Execute
63
+ let res = schema.execute(gql_req).await;
64
+
65
+ // Serialize response
66
+ let res_bytes = serde_json::to_vec(&res)?;
67
+
68
+ let body = crate::UnsyncBoxBody::new(
69
+ Full::new(Bytes::from(res_bytes))
70
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
71
+ );
72
+
73
+ Ok(Response::builder()
74
+ .status(StatusCode::OK)
75
+ .header("content-type", "application/json")
76
+ .body(body)?)
77
+}
0
78
▼crates/justapi-core/src/grpc.rs
+139-0
1
+use bytes::{Buf, BufMut};
2
+use http::{Request, Response};
3
+use hyper::body::Incoming;
4
+use std::future::Future;
5
+use std::pin::Pin;
6
+use tonic::codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder};
7
+use tonic::server::{Grpc, UnaryService};
8
+use tonic::Status;
9
+use tower::Service;
10
+
11
+#[derive(Default, Clone)]
12
+pub struct RawBytesCodec;
13
+
14
+impl Codec for RawBytesCodec {
15
+ type Encode = Vec<u8>;
16
+ type Decode = Vec<u8>;
17
+ type Encoder = RawBytesEncoder;
18
+ type Decoder = RawBytesDecoder;
19
+
20
+ fn encoder(&mut self) -> Self::Encoder {
21
+ RawBytesEncoder
22
+ }
23
+ fn decoder(&mut self) -> Self::Decoder {
24
+ RawBytesDecoder
25
+ }
26
+}
27
+
28
+#[derive(Default, Clone)]
29
+pub struct RawBytesEncoder;
30
+
31
+impl Encoder for RawBytesEncoder {
32
+ type Item = Vec<u8>;
33
+ type Error = Status;
34
+
35
+ fn encode(&mut self, item: Self::Item, dst: &mut EncodeBuf<'_>) -> Result<(), Self::Error> {
36
+ dst.put_slice(&item);
37
+ Ok(())
38
+ }
39
+}
40
+
41
+#[derive(Default, Clone)]
42
+pub struct RawBytesDecoder;
43
+
44
+impl Decoder for RawBytesDecoder {
45
+ type Item = Vec<u8>;
46
+ type Error = Status;
47
+
48
+ fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result<Option<Self::Item>, Self::Error> {
49
+ if src.has_remaining() {
50
+ let chunk = src.chunk().to_vec();
51
+ src.advance(chunk.len());
52
+ Ok(Some(chunk))
53
+ } else {
54
+ Ok(None)
55
+ }
56
+ }
57
+}
58
+
59
+pub type GrpcHandler = Box<
60
+ dyn Fn(
61
+ http::Uri,
62
+ Vec<u8>,
63
+ ) -> Pin<Box<dyn Future<Output = Result<Vec<u8>, Status>> + Send + 'static>>
64
+ + Send
65
+ + Sync
66
+ + 'static,
67
+>;
68
+
69
+#[derive(Clone)]
70
+pub struct DynamicGrpcService {
71
+ handler: std::sync::Arc<GrpcHandler>,
72
+}
73
+
74
+impl DynamicGrpcService {
75
+ pub fn new(handler: GrpcHandler) -> Self {
76
+ Self {
77
+ handler: std::sync::Arc::new(handler),
78
+ }
79
+ }
80
+}
81
+
82
+impl Service<Request<Incoming>> for DynamicGrpcService {
83
+ type Response = Response<tonic::body::Body>;
84
+ type Error = std::convert::Infallible;
85
+ type Future =
86
+ Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
87
+
88
+ fn poll_ready(
89
+ &mut self,
90
+ _cx: &mut std::task::Context<'_>,
91
+ ) -> std::task::Poll<Result<(), Self::Error>> {
92
+ std::task::Poll::Ready(Ok(()))
93
+ }
94
+
95
+ fn call(&mut self, req: Request<Incoming>) -> Self::Future {
96
+ let handler = self.handler.clone();
97
+ let uri = req.uri().clone();
98
+
99
+ Box::pin(async move {
100
+ struct TempSvc {
101
+ uri: http::Uri,
102
+ handler: std::sync::Arc<GrpcHandler>,
103
+ }
104
+
105
+ impl UnaryService<Vec<u8>> for TempSvc {
106
+ type Response = Vec<u8>;
107
+ type Future = Pin<
108
+ Box<
109
+ dyn Future<Output = Result<tonic::Response<Self::Response>, tonic::Status>>
110
+ + Send
111
+ + 'static,
112
+ >,
113
+ >;
114
+
115
+ fn call(&mut self, request: tonic::Request<Vec<u8>>) -> Self::Future {
116
+ let uri = self.uri.clone();
117
+ let handler = self.handler.clone();
118
+ Box::pin(async move {
119
+ let res = (handler)(uri, request.into_inner()).await?;
120
+ Ok(tonic::Response::new(res))
121
+ })
122
+ }
123
+ }
124
+
125
+ let mut grpc = Grpc::new(RawBytesCodec);
126
+
127
+ // UnaryService expects Request<Incoming> and returns Response<BoxBody>.
128
+ // Since we need to return Response<tonic::body::Body>, we can just map it!
129
+ let res = grpc.unary(TempSvc { uri, handler }, req).await;
130
+
131
+ // In tonic 0.14, grpc.unary() returns http::Response<tonic::body::BoxBody> ?
132
+ // Wait, I should just map the body:
133
+ let (parts, body) = res.into_parts();
134
+ let body = tonic::body::Body::new(body); // or similar
135
+
136
+ Ok(Response::from_parts(parts, body))
137
+ })
138
+ }
139
+}
0
140
▼crates/justapi-core/src/memory.rs
+252-0
1
+use std::sync::Mutex;
2
+
3
+const DEFAULT_ARENA_SIZE: usize = 16 * 1024; // 16KB per request arena
4
+
5
+/// A bump allocator for request-scoped data.
6
+/// All allocations live until the arena is reset.
7
+/// No individual deallocations — the entire arena is cleared at once.
8
+pub struct RequestArena {
9
+ buffer: Box<[u8]>,
10
+ cursor: usize,
11
+}
12
+
13
+impl RequestArena {
14
+ pub fn new() -> Self {
15
+ Self::with_capacity(DEFAULT_ARENA_SIZE)
16
+ }
17
+
18
+ pub fn with_capacity(cap: usize) -> Self {
19
+ let buffer = vec![0u8; cap].into_boxed_slice();
20
+ Self { buffer, cursor: 0 }
21
+ }
22
+
23
+ /// Allocate a byte slice from the arena. Returns `None` if out of space.
24
+ pub fn alloc(&mut self, bytes: &[u8]) -> Option<&[u8]> {
25
+ let len = bytes.len();
26
+ let start = self.cursor;
27
+ let end = start + len;
28
+ if end > self.buffer.len() {
29
+ return None;
30
+ }
31
+ self.buffer[start..end].copy_from_slice(bytes);
32
+ self.cursor = end;
33
+ Some(&self.buffer[start..end])
34
+ }
35
+
36
+ /// Allocate a string slice from the arena.
37
+ pub fn alloc_str(&mut self, s: &str) -> Option<&str> {
38
+ self.alloc(s.as_bytes()).map(|b| unsafe {
39
+ // SAFETY: `b` is a byte-slice copy of `s.as_bytes()` which is
40
+ // guaranteed valid UTF-8 by virtue of `s: &str`. The arena
41
+ // cannot modify the bytes, so `from_utf8_unchecked` is sound.
42
+ std::str::from_utf8_unchecked(b)
43
+ })
44
+ }
45
+
46
+ /// Reset the arena, making all allocated memory available again.
47
+ pub fn reset(&mut self) {
48
+ self.cursor = 0;
49
+ }
50
+
51
+ pub fn used(&self) -> usize {
52
+ self.cursor
53
+ }
54
+
55
+ pub fn capacity(&self) -> usize {
56
+ self.buffer.len()
57
+ }
58
+
59
+ pub fn available(&self) -> usize {
60
+ self.buffer.len() - self.cursor
61
+ }
62
+}
63
+
64
+impl Default for RequestArena {
65
+ fn default() -> Self {
66
+ Self::new()
67
+ }
68
+}
69
+
70
+/// Thread-safe arena wrapper using std::sync::Mutex.
71
+/// Each connection task owns one of these; the mutex ensures safety
72
+/// even when the arena is temporarily shared across await points.
73
+pub struct SharedArena {
74
+ inner: Mutex<RequestArena>,
75
+}
76
+
77
+impl SharedArena {
78
+ pub fn new() -> Self {
79
+ Self {
80
+ inner: Mutex::new(RequestArena::new()),
81
+ }
82
+ }
83
+
84
+ pub fn reset(&self) {
85
+ self.inner.lock().unwrap().reset();
86
+ }
87
+
88
+ pub fn alloc_str(&self, s: &str) -> Option<String> {
89
+ let mut inner = self.inner.lock().unwrap();
90
+ inner.alloc_str(s).map(|r| r.to_string())
91
+ }
92
+}
93
+
94
+impl Default for SharedArena {
95
+ fn default() -> Self {
96
+ Self::new()
97
+ }
98
+}
99
+
100
+/// A pool of reusable byte buffers for response bodies.
101
+/// Reduces allocation overhead by recycling buffers instead of
102
+/// allocating new ones for every response.
103
+pub struct BufferPool {
104
+ buckets: [Mutex<Vec<Vec<u8>>>; 4],
105
+}
106
+
107
+const BUCKET_SIZES: [usize; 4] = [1024, 4096, 16384, 65536];
108
+
109
+impl BufferPool {
110
+ pub fn new() -> Self {
111
+ Self {
112
+ buckets: std::array::from_fn(|_| Mutex::new(Vec::new())),
113
+ }
114
+ }
115
+
116
+ fn bucket_index(min_size: usize) -> usize {
117
+ for (i, &size) in BUCKET_SIZES.iter().enumerate() {
118
+ if min_size <= size {
119
+ return i;
120
+ }
121
+ }
122
+ BUCKET_SIZES.len() - 1
123
+ }
124
+
125
+ /// Acquire a buffer with at least `min_size` capacity.
126
+ pub fn acquire(&self, min_size: usize) -> Vec<u8> {
127
+ let idx = Self::bucket_index(min_size);
128
+ let mut pool = self.buckets[idx].lock().unwrap();
129
+ pool.pop()
130
+ .unwrap_or_else(|| Vec::with_capacity(BUCKET_SIZES[idx]))
131
+ }
132
+
133
+ /// Return a buffer to the pool for reuse.
134
+ pub fn release(&self, mut buf: Vec<u8>) {
135
+ buf.clear();
136
+ let idx = Self::bucket_index(buf.capacity());
137
+ let mut pool = self.buckets[idx].lock().unwrap();
138
+ if pool.len() < 128 {
139
+ pool.push(buf);
140
+ }
141
+ }
142
+}
143
+
144
+impl Default for BufferPool {
145
+ fn default() -> Self {
146
+ Self::new()
147
+ }
148
+}
149
+
150
+#[cfg(test)]
151
+mod tests {
152
+ use super::*;
153
+
154
+ #[test]
155
+ fn test_arena_alloc() {
156
+ let mut arena = RequestArena::new();
157
+ let data = arena.alloc(b"hello").unwrap();
158
+ assert_eq!(data, b"hello");
159
+ assert_eq!(arena.used(), 5);
160
+ }
161
+
162
+ #[test]
163
+ fn test_arena_str() {
164
+ let mut arena = RequestArena::new();
165
+ let s = arena.alloc_str("hello world").unwrap();
166
+ assert_eq!(s, "hello world");
167
+ }
168
+
169
+ #[cfg(miri)]
170
+ #[test]
171
+ fn miri_unsafe_utf8_validity() {
172
+ // This test exercises the only `unsafe` block in the production
173
+ // code path (`from_utf8_unchecked`). Under miri stacked-borrows
174
+ // the provenance rules are enforced: the arena buffer's borrow
175
+ // must remain alive and the bytes must remain valid UTF-8.
176
+ let mut arena = RequestArena::new();
177
+ let s = arena.alloc_str("Hello, Miri!").unwrap();
178
+ assert_eq!(s, "Hello, Miri!");
179
+ // End the borrow before the next alloc to avoid aliasing UB.
180
+ let _ = s;
181
+ // Subsequent allocations must also produce valid UTF-8.
182
+ let s2 = arena.alloc_str("Another string").unwrap();
183
+ assert_eq!(s2, "Another string");
184
+ }
185
+
186
+ #[cfg(miri)]
187
+ #[test]
188
+ fn miri_alloc_exact_sized() {
189
+ // Allocate at each power-of-two boundary up to arena capacity
190
+ // to exercise the bump-allocator offset arithmetic under miri.
191
+ let mut arena = RequestArena::new();
192
+ let mut total = 0;
193
+ for i in 0..10 {
194
+ let size = 1usize << i;
195
+ if total + size > arena.capacity() {
196
+ break;
197
+ }
198
+ let data = arena.alloc(&vec![0xABu8; size]);
199
+ assert!(data.is_some());
200
+ let data = data.unwrap();
201
+ assert_eq!(data.len(), size);
202
+ assert!(data.iter().all(|&b| b == 0xAB));
203
+ total += size;
204
+ }
205
+ }
206
+
207
+ #[test]
208
+ fn test_arena_reset() {
209
+ let mut arena = RequestArena::new();
210
+ arena.alloc(b"hello").unwrap();
211
+ assert_eq!(arena.used(), 5);
212
+ arena.reset();
213
+ assert_eq!(arena.used(), 0);
214
+ let data = arena.alloc(b"world").unwrap();
215
+ assert_eq!(data, b"world");
216
+ }
217
+
218
+ #[test]
219
+ fn test_arena_exhaustion_returns_none() {
220
+ let mut arena = RequestArena::with_capacity(10);
221
+ assert!(arena.alloc(b"1234567890").is_some());
222
+ assert!(arena.alloc(b"x").is_none());
223
+ }
224
+
225
+ #[test]
226
+ fn test_buffer_pool_acquire_release() {
227
+ let pool = BufferPool::new();
228
+ let buf = pool.acquire(100);
229
+ assert!(buf.capacity() >= 100);
230
+ pool.release(buf);
231
+ let buf2 = pool.acquire(100);
232
+ assert!(buf2.capacity() >= 100);
233
+ assert!(buf2.is_empty());
234
+ }
235
+
236
+ #[test]
237
+ fn test_buffer_pool_bucket_sizing() {
238
+ let pool = BufferPool::new();
239
+ let small = pool.acquire(50);
240
+ assert_eq!(small.capacity(), 1024);
241
+ let large = pool.acquire(20000);
242
+ assert_eq!(large.capacity(), 65536);
243
+ }
244
+
245
+ #[test]
246
+ fn test_shared_arena() {
247
+ let arena = SharedArena::new();
248
+ arena.reset();
249
+ let s = arena.alloc_str("/hello").unwrap();
250
+ assert_eq!(s, "/hello");
251
+ }
252
+}
0
253
▼crates/justapi-core/src/multipart.rs
+219-0
1
+use std::collections::HashMap;
2
+
3
+use anyhow::Result;
4
+use bytes::Bytes;
5
+use http_body_util::BodyDataStream;
6
+use multer::Multipart;
7
+
8
+use std::path::PathBuf;
9
+
10
+/// A file uploaded via `multipart/form-data`.
11
+#[derive(Debug)]
12
+pub struct UploadFile {
13
+ pub field_name: String,
14
+ pub filename: Option<String>,
15
+ pub content_type: Option<String>,
16
+ pub temp_path: PathBuf,
17
+}
18
+
19
+/// Parsed multipart form data containing both text fields and uploaded files.
20
+#[derive(Debug, Default)]
21
+pub struct MultipartForm {
22
+ pub fields: HashMap<String, String>,
23
+ pub files: Vec<UploadFile>,
24
+}
25
+
26
+impl MultipartForm {
27
+ pub fn file(&self, field_name: &str) -> Option<&UploadFile> {
28
+ self.files.iter().find(|f| f.field_name == field_name)
29
+ }
30
+
31
+ pub fn field(&self, name: &str) -> Option<&str> {
32
+ self.fields.get(name).map(|s| s.as_str())
33
+ }
34
+}
35
+
36
+/// Parse a `multipart/form-data` request body.
37
+///
38
+/// `body` — the request body (any `http_body::Body<Data = Bytes>`).
39
+/// `content_type` — the value of the `Content-Type` header (must include the
40
+/// boundary, e.g. `multipart/form-data; boundary=----WebKitFormBoundaryxyz`).
41
+///
42
+/// Returns a [`MultipartForm`] with all text fields and file fields collected.
43
+/// Text fields go into `fields`; files (fields with a filename) go into `files`.
44
+pub async fn parse_multipart<B>(body: B, content_type: &str) -> Result<MultipartForm>
45
+where
46
+ B: http_body::Body<Data = Bytes> + Send + Unpin + 'static,
47
+ B::Error: std::error::Error + Send + Sync + 'static,
48
+{
49
+ let boundary = extract_boundary(content_type)?;
50
+ let stream = BodyDataStream::new(body);
51
+ let mut multipart = Multipart::new(stream, &boundary);
52
+ let mut form = MultipartForm::default();
53
+ let mut size: usize = 0;
54
+ let max_size: usize = 50 * 1024 * 1024; // 50 MB default
55
+
56
+ while let Some(mut field) = multipart.next_field().await? {
57
+ let name = field.name().unwrap_or("").to_string();
58
+ let filename = field.file_name().map(|s| s.to_string());
59
+ let ct = field.content_type().map(|m| m.to_string());
60
+
61
+ if filename.is_some() {
62
+ let mut temp_file = tempfile::NamedTempFile::new()?;
63
+ let temp_path = temp_file.path().to_path_buf();
64
+
65
+ use std::io::Write;
66
+ while let Some(chunk) = field.chunk().await? {
67
+ size += chunk.len();
68
+ if size > max_size {
69
+ return Err(anyhow::anyhow!("file exceeds maximum size of 50 MB"));
70
+ }
71
+ temp_file.write_all(&chunk)?;
72
+ }
73
+ // Keep the temp file on disk by persisting it (or by just storing the path, but NamedTempFile deletes when dropped.
74
+ // We should use `keep()` to persist it, and the Python wrapper will delete it when done).
75
+ temp_file.keep()?;
76
+
77
+ form.files.push(UploadFile {
78
+ field_name: name,
79
+ filename,
80
+ content_type: ct,
81
+ temp_path,
82
+ });
83
+ } else {
84
+ let text = field.text().await?;
85
+ form.fields.insert(name, text);
86
+ }
87
+ }
88
+
89
+ Ok(form)
90
+}
91
+
92
+fn extract_boundary(content_type: &str) -> Result<String> {
93
+ for part in content_type.split(';') {
94
+ let part = part.trim();
95
+ if let Some(value) = part.strip_prefix("boundary=") {
96
+ let b = value.trim();
97
+ // Strip optional quotes
98
+ let b = b.strip_prefix('"').unwrap_or(b);
99
+ let b = b.strip_suffix('"').unwrap_or(b);
100
+ return Ok(b.to_string());
101
+ }
102
+ }
103
+ Err(anyhow::anyhow!("missing boundary in Content-Type header"))
104
+}
105
+
106
+// ---------------------------------------------------------------------------
107
+// Tests
108
+// ---------------------------------------------------------------------------
109
+
110
+#[cfg(test)]
111
+mod tests {
112
+ use super::*;
113
+ use http_body_util::Full;
114
+
115
+ #[test]
116
+ fn extract_boundary_works() {
117
+ let ct = "multipart/form-data; boundary=----WebKitFormBoundaryxyz";
118
+ assert_eq!(extract_boundary(ct).unwrap(), "----WebKitFormBoundaryxyz");
119
+ }
120
+
121
+ #[test]
122
+ fn extract_boundary_quoted() {
123
+ let ct = r#"multipart/form-data; boundary="----Boundary123""#;
124
+ assert_eq!(extract_boundary(ct).unwrap(), "----Boundary123");
125
+ }
126
+
127
+ #[test]
128
+ fn extract_boundary_missing_errors() {
129
+ let ct = "multipart/form-data";
130
+ assert!(extract_boundary(ct).is_err());
131
+ }
132
+
133
+ fn build_multipart_body(
134
+ fields: &[(&str, Option<&str>, &str)], // (name, filename, data)
135
+ ) -> (Vec<u8>, String) {
136
+ let boundary = "----TestBoundary123";
137
+ let mut body = Vec::new();
138
+ for (name, filename, data) in fields {
139
+ body.extend_from_slice(b"--");
140
+ body.extend_from_slice(boundary.as_bytes());
141
+ body.extend_from_slice(b"\r\n");
142
+ body.extend_from_slice(b"Content-Disposition: form-data; name=\"");
143
+ body.extend_from_slice(name.as_bytes());
144
+ body.extend_from_slice(b"\"");
145
+ if let Some(fname) = filename {
146
+ body.extend_from_slice(b"; filename=\"");
147
+ body.extend_from_slice(fname.as_bytes());
148
+ body.extend_from_slice(b"\"");
149
+ }
150
+ body.extend_from_slice(b"\r\n");
151
+ if filename.is_some() {
152
+ body.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
153
+ body.extend_from_slice(b"Content-Transfer-Encoding: binary\r\n");
154
+ }
155
+ body.extend_from_slice(b"\r\n");
156
+ body.extend_from_slice(data.as_bytes());
157
+ body.extend_from_slice(b"\r\n");
158
+ }
159
+ body.extend_from_slice(b"--");
160
+ body.extend_from_slice(boundary.as_bytes());
161
+ body.extend_from_slice(b"--\r\n");
162
+ let ct = format!("multipart/form-data; boundary={}", boundary);
163
+ (body, ct)
164
+ }
165
+
166
+ #[tokio::test]
167
+ async fn parse_multipart_text_fields() {
168
+ let (body, ct) =
169
+ build_multipart_body(&[("username", None, "alice"), ("role", None, "admin")]);
170
+ let body = Full::new(Bytes::from(body));
171
+ let form = parse_multipart(body, &ct).await.unwrap();
172
+ assert_eq!(form.field("username"), Some("alice"));
173
+ assert_eq!(form.field("role"), Some("admin"));
174
+ assert!(form.files.is_empty());
175
+ }
176
+
177
+ #[tokio::test]
178
+ async fn parse_multipart_file_upload() {
179
+ let file_content = "hello, this is a file";
180
+ let (body, ct) = build_multipart_body(&[
181
+ ("description", None, "my file"),
182
+ ("file", Some("test.txt"), file_content),
183
+ ]);
184
+ let body = Full::new(Bytes::from(body));
185
+ let form = parse_multipart(body, &ct).await.unwrap();
186
+ assert_eq!(form.field("description"), Some("my file"));
187
+ assert_eq!(form.files.len(), 1);
188
+ let f = form.file("file").unwrap();
189
+ assert_eq!(f.filename.as_deref(), Some("test.txt"));
190
+ assert_eq!(f.content_type.as_deref(), Some("application/octet-stream"));
191
+ assert_eq!(std::fs::read_to_string(&f.temp_path).unwrap(), file_content);
192
+ }
193
+
194
+ #[tokio::test]
195
+ async fn parse_multipart_multiple_files() {
196
+ let (body, ct) = build_multipart_body(&[
197
+ ("file1", Some("a.txt"), "content a"),
198
+ ("file2", Some("b.txt"), "content b"),
199
+ ]);
200
+ let body = Full::new(Bytes::from(body));
201
+ let form = parse_multipart(body, &ct).await.unwrap();
202
+ assert_eq!(form.files.len(), 2);
203
+ assert_eq!(
204
+ form.file("file1").unwrap().filename.as_deref(),
205
+ Some("a.txt")
206
+ );
207
+ assert_eq!(
208
+ form.file("file2").unwrap().filename.as_deref(),
209
+ Some("b.txt")
210
+ );
211
+ }
212
+
213
+ #[tokio::test]
214
+ async fn parse_multipart_missing_boundary_errors() {
215
+ let body = Full::new(Bytes::from("some data"));
216
+ let result = parse_multipart(body, "multipart/form-data").await;
217
+ assert!(result.is_err());
218
+ }
219
+}
0
220
▼crates/justapi-core/src/openai.rs
+865-0
1
+//! OpenAI-compatible HTTP endpoints, wired to `justapi-inference`.
2
+//!
3
+//! Mounts `/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, and
4
+//! `/v1/models` onto the [`Server`](crate::server::Server). The generation
5
+//! hot path runs inside the inference engine on a dedicated thread (no GIL),
6
+//! and tokens are streamed as Server-Sent Events via the core
7
+//! [`streaming_response`](crate::streaming_response) helper.
8
+//!
9
+//! Enabled by the `inference` feature (so the default build stays lean).
10
+
11
+use std::sync::Arc;
12
+
13
+use http_body_util::BodyExt;
14
+use hyper::StatusCode;
15
+
16
+use crate::json_response;
17
+use crate::middleware::HandlerFn;
18
+use crate::streaming_response;
19
+use justapi_inference::openai::{
20
+ ApiError, ChatCompletionRequest, CompletionRequest, EmbeddingRequest, ErrorResponse,
21
+};
22
+use justapi_inference::{ControlPlane, Engine, RouteRequest, Router, SchedulerEngine};
23
+
24
+/// Build a handler for `POST /v1/chat/completions`.
25
+pub fn chat_completions_handler(engine: Arc<Engine>) -> HandlerFn {
26
+ Arc::new(move |req| {
27
+ let engine = engine.clone();
28
+ Box::pin(async move {
29
+ let body = match req.into_body().collect().await {
30
+ Ok(b) => b.to_bytes(),
31
+ Err(e) => {
32
+ let (st, json) = justapi_inference::openai::error_json(
33
+ &e.to_string(),
34
+ "invalid_request_error",
35
+ 400,
36
+ );
37
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
38
+ }
39
+ };
40
+ let req: ChatCompletionRequest = match serde_json::from_slice(&body) {
41
+ Ok(r) => r,
42
+ Err(e) => {
43
+ let (st, json) = justapi_inference::openai::error_json(
44
+ &e.to_string(),
45
+ "invalid_request_error",
46
+ 400,
47
+ );
48
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
49
+ }
50
+ };
51
+ if req.stream {
52
+ let stream = justapi_inference::openai::chat_completion_stream(engine, req);
53
+ Ok(streaming_response(
54
+ StatusCode::OK,
55
+ "text/event-stream",
56
+ stream,
57
+ ))
58
+ } else {
59
+ match justapi_inference::openai::chat_completion(engine, req).await {
60
+ Ok(resp) => {
61
+ let json = serde_json::to_string(&resp)?;
62
+ Ok(json_response(StatusCode::OK, &json))
63
+ }
64
+ Err(e) => {
65
+ let env = ErrorResponse {
66
+ error: ApiError {
67
+ message: e.to_string(),
68
+ error_type: "server_error".into(),
69
+ param: None,
70
+ code: None,
71
+ },
72
+ };
73
+ let json = serde_json::to_string(&env).unwrap_or_default();
74
+ Ok(json_response(StatusCode::UNPROCESSABLE_ENTITY, &json))
75
+ }
76
+ }
77
+ }
78
+ })
79
+ })
80
+}
81
+
82
+/// Build a handler for `POST /v1/completions`.
83
+pub fn completions_handler(engine: Arc<Engine>) -> HandlerFn {
84
+ Arc::new(move |req| {
85
+ let engine = engine.clone();
86
+ Box::pin(async move {
87
+ let body = match req.into_body().collect().await {
88
+ Ok(b) => b.to_bytes(),
89
+ Err(e) => {
90
+ let (st, json) = justapi_inference::openai::error_json(
91
+ &e.to_string(),
92
+ "invalid_request_error",
93
+ 400,
94
+ );
95
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
96
+ }
97
+ };
98
+ let req: CompletionRequest = match serde_json::from_slice(&body) {
99
+ Ok(r) => r,
100
+ Err(e) => {
101
+ let (st, json) = justapi_inference::openai::error_json(
102
+ &e.to_string(),
103
+ "invalid_request_error",
104
+ 400,
105
+ );
106
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
107
+ }
108
+ };
109
+ if req.stream {
110
+ let stream = justapi_inference::openai::completion_stream(engine, req);
111
+ Ok(streaming_response(
112
+ StatusCode::OK,
113
+ "text/event-stream",
114
+ stream,
115
+ ))
116
+ } else {
117
+ match justapi_inference::openai::completion(engine, req).await {
118
+ Ok(resp) => {
119
+ let json = serde_json::to_string(&resp)?;
120
+ Ok(json_response(StatusCode::OK, &json))
121
+ }
122
+ Err(e) => {
123
+ let env = ErrorResponse {
124
+ error: ApiError {
125
+ message: e.to_string(),
126
+ error_type: "server_error".into(),
127
+ param: None,
128
+ code: None,
129
+ },
130
+ };
131
+ let json = serde_json::to_string(&env).unwrap_or_default();
132
+ Ok(json_response(StatusCode::UNPROCESSABLE_ENTITY, &json))
133
+ }
134
+ }
135
+ }
136
+ })
137
+ })
138
+}
139
+
140
+/// Build a handler for `POST /v1/embeddings`.
141
+pub fn embeddings_handler(engine: Arc<Engine>) -> HandlerFn {
142
+ Arc::new(move |req| {
143
+ let engine = engine.clone();
144
+ Box::pin(async move {
145
+ let body = match req.into_body().collect().await {
146
+ Ok(b) => b.to_bytes(),
147
+ Err(e) => {
148
+ let (st, json) = justapi_inference::openai::error_json(
149
+ &e.to_string(),
150
+ "invalid_request_error",
151
+ 400,
152
+ );
153
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
154
+ }
155
+ };
156
+ let req: EmbeddingRequest = match serde_json::from_slice(&body) {
157
+ Ok(r) => r,
158
+ Err(e) => {
159
+ let (st, json) = justapi_inference::openai::error_json(
160
+ &e.to_string(),
161
+ "invalid_request_error",
162
+ 400,
163
+ );
164
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
165
+ }
166
+ };
167
+ match justapi_inference::openai::embeddings(&engine, req) {
168
+ Ok(resp) => {
169
+ let json = serde_json::to_string(&resp)?;
170
+ Ok(json_response(StatusCode::OK, &json))
171
+ }
172
+ Err(e) => {
173
+ let env = ErrorResponse {
174
+ error: ApiError {
175
+ message: e.to_string(),
176
+ error_type: "server_error".into(),
177
+ param: None,
178
+ code: None,
179
+ },
180
+ };
181
+ let json = serde_json::to_string(&env).unwrap_or_default();
182
+ Ok(json_response(StatusCode::UNPROCESSABLE_ENTITY, &json))
183
+ }
184
+ }
185
+ })
186
+ })
187
+}
188
+
189
+/// Build a handler for `GET /v1/models`.
190
+pub fn models_handler(engine: Arc<Engine>) -> HandlerFn {
191
+ Arc::new(move |_req| {
192
+ let engine = engine.clone();
193
+ Box::pin(async move {
194
+ let list = justapi_inference::openai::model_list(&engine);
195
+ let json = serde_json::to_string(&list)?;
196
+ Ok(json_response(StatusCode::OK, &json))
197
+ })
198
+ })
199
+}
200
+
201
+// ---------------------------------------------------------------------------
202
+// Scheduler-backed handlers (continuous batching + prefix caching)
203
+// ---------------------------------------------------------------------------
204
+
205
+/// Build a handler for `POST /v1/chat/completions` that uses the scheduler
206
+/// for admission control, prefix caching, and step-by-step generation.
207
+pub fn scheduled_chat_completions_handler(engine: Arc<SchedulerEngine>) -> HandlerFn {
208
+ Arc::new(move |req| {
209
+ let engine = engine.clone();
210
+ Box::pin(async move {
211
+ let body = match req.into_body().collect().await {
212
+ Ok(b) => b.to_bytes(),
213
+ Err(e) => {
214
+ let (st, json) = justapi_inference::openai::error_json(
215
+ &e.to_string(),
216
+ "invalid_request_error",
217
+ 400,
218
+ );
219
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
220
+ }
221
+ };
222
+ let req: ChatCompletionRequest = match serde_json::from_slice(&body) {
223
+ Ok(r) => r,
224
+ Err(e) => {
225
+ let (st, json) = justapi_inference::openai::error_json(
226
+ &e.to_string(),
227
+ "invalid_request_error",
228
+ 400,
229
+ );
230
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
231
+ }
232
+ };
233
+ if req.stream {
234
+ let stream =
235
+ justapi_inference::openai::scheduled_chat_completion_stream(engine, req);
236
+ Ok(streaming_response(
237
+ StatusCode::OK,
238
+ "text/event-stream",
239
+ stream,
240
+ ))
241
+ } else {
242
+ match justapi_inference::openai::scheduled_chat_completion(engine, req).await {
243
+ Ok(resp) => {
244
+ let json = serde_json::to_string(&resp)?;
245
+ Ok(json_response(StatusCode::OK, &json))
246
+ }
247
+ Err(e) => {
248
+ let env = justapi_inference::openai::ErrorResponse {
249
+ error: justapi_inference::openai::ApiError {
250
+ message: e.to_string(),
251
+ error_type: "server_error".into(),
252
+ param: None,
253
+ code: None,
254
+ },
255
+ };
256
+ let json = serde_json::to_string(&env).unwrap_or_default();
257
+ Ok(json_response(StatusCode::UNPROCESSABLE_ENTITY, &json))
258
+ }
259
+ }
260
+ }
261
+ })
262
+ })
263
+}
264
+
265
+/// Build a handler for `POST /v1/completions` that uses the scheduler.
266
+pub fn scheduled_completions_handler(engine: Arc<SchedulerEngine>) -> HandlerFn {
267
+ Arc::new(move |req| {
268
+ let engine = engine.clone();
269
+ Box::pin(async move {
270
+ let body = match req.into_body().collect().await {
271
+ Ok(b) => b.to_bytes(),
272
+ Err(e) => {
273
+ let (st, json) = justapi_inference::openai::error_json(
274
+ &e.to_string(),
275
+ "invalid_request_error",
276
+ 400,
277
+ );
278
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
279
+ }
280
+ };
281
+ let req: CompletionRequest = match serde_json::from_slice(&body) {
282
+ Ok(r) => r,
283
+ Err(e) => {
284
+ let (st, json) = justapi_inference::openai::error_json(
285
+ &e.to_string(),
286
+ "invalid_request_error",
287
+ 400,
288
+ );
289
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
290
+ }
291
+ };
292
+ if req.stream {
293
+ let stream = justapi_inference::openai::scheduled_completion_stream(engine, req);
294
+ Ok(streaming_response(
295
+ StatusCode::OK,
296
+ "text/event-stream",
297
+ stream,
298
+ ))
299
+ } else {
300
+ match justapi_inference::openai::scheduled_completion(engine, req).await {
301
+ Ok(resp) => {
302
+ let json = serde_json::to_string(&resp)?;
303
+ Ok(json_response(StatusCode::OK, &json))
304
+ }
305
+ Err(e) => {
306
+ let env = justapi_inference::openai::ErrorResponse {
307
+ error: justapi_inference::openai::ApiError {
308
+ message: e.to_string(),
309
+ error_type: "server_error".into(),
310
+ param: None,
311
+ code: None,
312
+ },
313
+ };
314
+ let json = serde_json::to_string(&env).unwrap_or_default();
315
+ Ok(json_response(StatusCode::UNPROCESSABLE_ENTITY, &json))
316
+ }
317
+ }
318
+ }
319
+ })
320
+ })
321
+}
322
+
323
+// ---------------------------------------------------------------------------
324
+// Routed handlers (LoRA-aware / KV-aware admission via ControlPlane + Router)
325
+// ---------------------------------------------------------------------------
326
+
327
+/// Peek the routing fields (`model`, optional `version` / `routing_key`) out of
328
+/// a raw request body without committing to a full deserialize yet.
329
+fn routing_context(body: &[u8]) -> Option<RouteRequest> {
330
+ let v: serde_json::Value = serde_json::from_slice(body).ok()?;
331
+ let model = v.get("model")?.as_str()?.to_string();
332
+ let version = v.get("version").and_then(|x| x.as_str()).map(String::from);
333
+ let routing_key = v
334
+ .get("routing_key")
335
+ .and_then(|x| x.as_str())
336
+ .map(String::from);
337
+ Some(RouteRequest {
338
+ model,
339
+ version,
340
+ routing_key,
341
+ })
342
+}
343
+
344
+/// Outcome of routing admission: either serve via the resolved decision, or
345
+/// reject with a ready-to-serve error response.
346
+enum AdmitOutcome {
347
+ Serve(justapi_inference::RouteDecision),
348
+ Reject(hyper::Response<crate::ResponseBody>),
349
+}
350
+
351
+/// Admit a request through the control plane + router.
352
+///
353
+/// Returns [`AdmitOutcome::Serve`] with the resolved decision on success, or
354
+/// [`AdmitOutcome::Reject`] with a ready-to-serve error response (400 missing
355
+/// model, 503 no capacity) on failure.
356
+fn admit(body: &[u8], cp: &ControlPlane, router: &Router) -> AdmitOutcome {
357
+ let route_req = match routing_context(body) {
358
+ Some(r) => r,
359
+ None => {
360
+ let (st, json) = justapi_inference::openai::error_json(
361
+ "request must include a `model`",
362
+ "invalid_request_error",
363
+ 400,
364
+ );
365
+ return AdmitOutcome::Reject(json_response(StatusCode::from_u16(st).unwrap(), &json));
366
+ }
367
+ };
368
+ match router.route(cp, &route_req) {
369
+ Some(d) => AdmitOutcome::Serve(d),
370
+ None => {
371
+ let (st, json) = justapi_inference::openai::error_json(
372
+ "no replica available to serve this request (all at capacity or no route)",
373
+ "server_error",
374
+ 503,
375
+ );
376
+ AdmitOutcome::Reject(json_response(StatusCode::from_u16(st).unwrap(), &json))
377
+ }
378
+ }
379
+}
380
+
381
+/// Build a handler for `POST /v1/chat/completions` that routes via the control
382
+/// plane before generating. Returns 503 when no replica can serve the request.
383
+pub fn routed_chat_completions_handler(
384
+ engine: Arc<Engine>,
385
+ cp: Arc<ControlPlane>,
386
+ router: Arc<Router>,
387
+) -> HandlerFn {
388
+ Arc::new(move |req| {
389
+ let (engine, cp, router) = (engine.clone(), cp.clone(), router.clone());
390
+ Box::pin(async move {
391
+ let body = match req.into_body().collect().await {
392
+ Ok(b) => b.to_bytes(),
393
+ Err(e) => {
394
+ let (st, json) = justapi_inference::openai::error_json(
395
+ &e.to_string(),
396
+ "invalid_request_error",
397
+ 400,
398
+ );
399
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
400
+ }
401
+ };
402
+ let decision = match admit(&body, &cp, &router) {
403
+ AdmitOutcome::Serve(d) => d,
404
+ AdmitOutcome::Reject(resp) => return Ok(resp),
405
+ };
406
+ let mut req: ChatCompletionRequest = match serde_json::from_slice(&body) {
407
+ Ok(r) => r,
408
+ Err(e) => {
409
+ let (st, json) = justapi_inference::openai::error_json(
410
+ &e.to_string(),
411
+ "invalid_request_error",
412
+ 400,
413
+ );
414
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
415
+ }
416
+ };
417
+ req.model = decision.resolved.model_name.clone();
418
+ if req.stream {
419
+ let stream = justapi_inference::openai::chat_completion_stream(engine, req);
420
+ Ok(streaming_response(
421
+ StatusCode::OK,
422
+ "text/event-stream",
423
+ stream,
424
+ ))
425
+ } else {
426
+ match justapi_inference::openai::chat_completion(engine, req).await {
427
+ Ok(resp) => {
428
+ let json = serde_json::to_string(&resp)?;
429
+ Ok(json_response(StatusCode::OK, &json))
430
+ }
431
+ Err(e) => {
432
+ let env = ErrorResponse {
433
+ error: ApiError {
434
+ message: e.to_string(),
435
+ error_type: "server_error".into(),
436
+ param: None,
437
+ code: None,
438
+ },
439
+ };
440
+ let json = serde_json::to_string(&env).unwrap_or_default();
441
+ Ok(json_response(StatusCode::UNPROCESSABLE_ENTITY, &json))
442
+ }
443
+ }
444
+ }
445
+ })
446
+ })
447
+}
448
+
449
+/// Build a handler for `POST /v1/completions` that routes via the control plane.
450
+pub fn routed_completions_handler(
451
+ engine: Arc<Engine>,
452
+ cp: Arc<ControlPlane>,
453
+ router: Arc<Router>,
454
+) -> HandlerFn {
455
+ Arc::new(move |req| {
456
+ let (engine, cp, router) = (engine.clone(), cp.clone(), router.clone());
457
+ Box::pin(async move {
458
+ let body = match req.into_body().collect().await {
459
+ Ok(b) => b.to_bytes(),
460
+ Err(e) => {
461
+ let (st, json) = justapi_inference::openai::error_json(
462
+ &e.to_string(),
463
+ "invalid_request_error",
464
+ 400,
465
+ );
466
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
467
+ }
468
+ };
469
+ let decision = match admit(&body, &cp, &router) {
470
+ AdmitOutcome::Serve(d) => d,
471
+ AdmitOutcome::Reject(resp) => return Ok(resp),
472
+ };
473
+ let mut req: CompletionRequest = match serde_json::from_slice(&body) {
474
+ Ok(r) => r,
475
+ Err(e) => {
476
+ let (st, json) = justapi_inference::openai::error_json(
477
+ &e.to_string(),
478
+ "invalid_request_error",
479
+ 400,
480
+ );
481
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
482
+ }
483
+ };
484
+ req.model = decision.resolved.model_name.clone();
485
+ if req.stream {
486
+ let stream = justapi_inference::openai::completion_stream(engine, req);
487
+ Ok(streaming_response(
488
+ StatusCode::OK,
489
+ "text/event-stream",
490
+ stream,
491
+ ))
492
+ } else {
493
+ match justapi_inference::openai::completion(engine, req).await {
494
+ Ok(resp) => {
495
+ let json = serde_json::to_string(&resp)?;
496
+ Ok(json_response(StatusCode::OK, &json))
497
+ }
498
+ Err(e) => {
499
+ let env = ErrorResponse {
500
+ error: ApiError {
501
+ message: e.to_string(),
502
+ error_type: "server_error".into(),
503
+ param: None,
504
+ code: None,
505
+ },
506
+ };
507
+ let json = serde_json::to_string(&env).unwrap_or_default();
508
+ Ok(json_response(StatusCode::UNPROCESSABLE_ENTITY, &json))
509
+ }
510
+ }
511
+ }
512
+ })
513
+ })
514
+}
515
+
516
+/// Build a handler for `POST /v1/embeddings` that routes via the control plane.
517
+pub fn routed_embeddings_handler(
518
+ engine: Arc<Engine>,
519
+ cp: Arc<ControlPlane>,
520
+ router: Arc<Router>,
521
+) -> HandlerFn {
522
+ Arc::new(move |req| {
523
+ let (engine, cp, router) = (engine.clone(), cp.clone(), router.clone());
524
+ Box::pin(async move {
525
+ let body = match req.into_body().collect().await {
526
+ Ok(b) => b.to_bytes(),
527
+ Err(e) => {
528
+ let (st, json) = justapi_inference::openai::error_json(
529
+ &e.to_string(),
530
+ "invalid_request_error",
531
+ 400,
532
+ );
533
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
534
+ }
535
+ };
536
+ let decision = match admit(&body, &cp, &router) {
537
+ AdmitOutcome::Serve(d) => d,
538
+ AdmitOutcome::Reject(resp) => return Ok(resp),
539
+ };
540
+ let mut req: EmbeddingRequest = match serde_json::from_slice(&body) {
541
+ Ok(r) => r,
542
+ Err(e) => {
543
+ let (st, json) = justapi_inference::openai::error_json(
544
+ &e.to_string(),
545
+ "invalid_request_error",
546
+ 400,
547
+ );
548
+ return Ok(json_response(StatusCode::from_u16(st).unwrap(), &json));
549
+ }
550
+ };
551
+ req.model = decision.resolved.model_name.clone();
552
+ match justapi_inference::openai::embeddings(&engine, req) {
553
+ Ok(resp) => {
554
+ let json = serde_json::to_string(&resp)?;
555
+ Ok(json_response(StatusCode::OK, &json))
556
+ }
557
+ Err(e) => {
558
+ let env = ErrorResponse {
559
+ error: ApiError {
560
+ message: e.to_string(),
561
+ error_type: "server_error".into(),
562
+ param: None,
563
+ code: None,
564
+ },
565
+ };
566
+ let json = serde_json::to_string(&env).unwrap_or_default();
567
+ Ok(json_response(StatusCode::UNPROCESSABLE_ENTITY, &json))
568
+ }
569
+ }
570
+ })
571
+ })
572
+}
573
+
574
+#[cfg(test)]
575
+mod tests {
576
+ use super::*;
577
+ use crate::testing::TestClient;
578
+ use crate::Server;
579
+ use justapi_inference::EngineDevice;
580
+
581
+ fn test_engine() -> Arc<Engine> {
582
+ let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap());
583
+ engine.register_mock("mock");
584
+ engine
585
+ }
586
+
587
+ #[tokio::test]
588
+ async fn chat_completions_non_streaming_via_http() {
589
+ let handler = chat_completions_handler(test_engine());
590
+ let client = TestClient::new(handler);
591
+ let body = serde_json::json!({
592
+ "model": "mock",
593
+ "messages": [{"role": "user", "content": "hi"}],
594
+ "max_tokens": 5,
595
+ "stream": false
596
+ })
597
+ .to_string();
598
+ let resp = client
599
+ .post("/v1/chat/completions", body.into_bytes())
600
+ .await
601
+ .unwrap();
602
+ assert_eq!(resp.status, 200);
603
+ let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
604
+ assert_eq!(v["object"], "chat.completion");
605
+ assert!(!v["choices"][0]["message"]["content"]
606
+ .as_str()
607
+ .unwrap()
608
+ .is_empty());
609
+ }
610
+
611
+ #[tokio::test]
612
+ async fn chat_completions_streaming_via_http() {
613
+ let handler = chat_completions_handler(test_engine());
614
+ let client = TestClient::new(handler);
615
+ let body = serde_json::json!({
616
+ "model": "mock",
617
+ "messages": [{"role": "user", "content": "hi"}],
618
+ "max_tokens": 5,
619
+ "stream": true
620
+ })
621
+ .to_string();
622
+ let resp = client
623
+ .post("/v1/chat/completions", body.into_bytes())
624
+ .await
625
+ .unwrap();
626
+ assert_eq!(resp.status, 200);
627
+ let text = String::from_utf8(resp.body).unwrap();
628
+ assert!(text.contains("data: "));
629
+ assert!(text.contains("data: [DONE]"));
630
+ }
631
+
632
+ #[tokio::test]
633
+ async fn models_endpoint_via_http() {
634
+ let handler = models_handler(test_engine());
635
+ let client = TestClient::new(handler);
636
+ let resp = client.get("/v1/models").await.unwrap();
637
+ assert_eq!(resp.status, 200);
638
+ let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
639
+ assert_eq!(v["object"], "list");
640
+ assert!(v["data"]
641
+ .as_array()
642
+ .unwrap()
643
+ .iter()
644
+ .any(|m| m["id"] == "mock"));
645
+ }
646
+
647
+ #[tokio::test]
648
+ async fn embeddings_endpoint_via_http() {
649
+ let handler = embeddings_handler(test_engine());
650
+ let client = TestClient::new(handler);
651
+ let body = serde_json::json!({
652
+ "model": "mock",
653
+ "input": ["hello", "world"]
654
+ })
655
+ .to_string();
656
+ let resp = client
657
+ .post("/v1/embeddings", body.into_bytes())
658
+ .await
659
+ .unwrap();
660
+ assert_eq!(resp.status, 200);
661
+ let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
662
+ assert_eq!(v["object"], "list");
663
+ assert_eq!(v["data"].as_array().unwrap().len(), 2);
664
+ }
665
+
666
+ #[tokio::test]
667
+ async fn server_with_openai_registers_routes() {
668
+ let addr: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
669
+ let server = Server::new(addr).with_openai(test_engine());
670
+ // The engine registered "mock"; confirm the handler wire-up compiles
671
+ // and the models handler answers through the full server path.
672
+ let handler = models_handler(test_engine());
673
+ let client = TestClient::new(handler);
674
+ let resp = client.get("/v1/models").await.unwrap();
675
+ assert_eq!(resp.status, 200);
676
+ let _ = server;
677
+ }
678
+
679
+ // -- Routed handlers (ControlPlane + Router admission) --------------------
680
+
681
+ use justapi_inference::{
682
+ ControlPlane, ModelVersion, Replica, Router, RoutingStrategy, WeightLocation,
683
+ };
684
+ use std::path::PathBuf;
685
+
686
+ fn routed_test_setup() -> (Arc<Engine>, Arc<ControlPlane>, Arc<Router>) {
687
+ let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap());
688
+ engine.register_mock("mock");
689
+
690
+ let cp = Arc::new(ControlPlane::new());
691
+ cp.register_version(
692
+ "mock",
693
+ ModelVersion::new("v1", WeightLocation::Local(PathBuf::from("/models/v1"))),
694
+ );
695
+
696
+ let router = Arc::new(Router::new(RoutingStrategy::LeastLoaded));
697
+ router.register_replica(Replica {
698
+ id: "r1".to_string(),
699
+ model: "mock".to_string(),
700
+ version: "v1".to_string(),
701
+ device: EngineDevice::Cpu,
702
+ max_concurrency: 4,
703
+ active_sequences: 0,
704
+ kv_pressure_pct: 0.0,
705
+ healthy: true,
706
+ loaded_adapters: vec![],
707
+ });
708
+
709
+ (engine, cp, router)
710
+ }
711
+
712
+ #[tokio::test]
713
+ async fn routed_chat_completions_admits_and_serves() {
714
+ let (engine, cp, router) = routed_test_setup();
715
+ let handler = routed_chat_completions_handler(engine, cp, router);
716
+ let client = TestClient::new(handler);
717
+ let body = serde_json::json!({
718
+ "model": "mock",
719
+ "messages": [{"role": "user", "content": "hi"}],
720
+ "max_tokens": 5,
721
+ "stream": false
722
+ })
723
+ .to_string();
724
+ let resp = client
725
+ .post("/v1/chat/completions", body.into_bytes())
726
+ .await
727
+ .unwrap();
728
+ assert_eq!(resp.status, 200);
729
+ let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
730
+ assert_eq!(v["object"], "chat.completion");
731
+ }
732
+
733
+ #[tokio::test]
734
+ async fn routed_chat_completions_503_when_no_capacity() {
735
+ let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap());
736
+ engine.register_mock("mock");
737
+ let cp = Arc::new(ControlPlane::new());
738
+ cp.register_version(
739
+ "mock",
740
+ ModelVersion::new("v1", WeightLocation::Local(PathBuf::from("/models/v1"))),
741
+ );
742
+ // Router with NO replicas for the model → route returns None → 503.
743
+ let router = Arc::new(Router::new(RoutingStrategy::LeastLoaded));
744
+
745
+ let handler = routed_chat_completions_handler(engine, cp, router);
746
+ let client = TestClient::new(handler);
747
+ let body = serde_json::json!({
748
+ "model": "mock",
749
+ "messages": [{"role": "user", "content": "hi"}],
750
+ "max_tokens": 5,
751
+ "stream": false
752
+ })
753
+ .to_string();
754
+ let resp = client
755
+ .post("/v1/chat/completions", body.into_bytes())
756
+ .await
757
+ .unwrap();
758
+ assert_eq!(resp.status, 503);
759
+ let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
760
+ assert_eq!(v["error"]["type"], "server_error");
761
+ }
762
+
763
+ // -- Scheduler-backed handlers (continuous batching + prefix cache) ------
764
+
765
+ use justapi_inference::{KvBlockPool, Scheduler, SchedulerConfig, SchedulerEngine};
766
+
767
+ /// Build a scheduler-backed engine wired to the scheduler path.
768
+ fn scheduled_test_setup() -> (Arc<Engine>, Arc<SchedulerEngine>) {
769
+ let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap());
770
+ engine.register_mock("mock");
771
+ let pool = KvBlockPool::new(1024);
772
+ let config = SchedulerConfig {
773
+ max_num_seqs: 8,
774
+ ..Default::default()
775
+ };
776
+ let scheduler = Arc::new(std::sync::Mutex::new(Scheduler::new(config, pool)));
777
+ let se = Arc::new(SchedulerEngine::new(engine.clone(), scheduler));
778
+ (engine, se)
779
+ }
780
+
781
+ #[tokio::test]
782
+ async fn scheduled_chat_completions_non_streaming_via_http() {
783
+ let (_engine, se) = scheduled_test_setup();
784
+ let handler = scheduled_chat_completions_handler(se);
785
+ let client = TestClient::new(handler);
786
+ let body = serde_json::json!({
787
+ "model": "mock",
788
+ "messages": [{"role": "user", "content": "hello world"}],
789
+ "max_tokens": 5,
790
+ "stream": false
791
+ })
792
+ .to_string();
793
+ let resp = client
794
+ .post("/v1/chat/completions", body.into_bytes())
795
+ .await
796
+ .unwrap();
797
+ assert_eq!(resp.status, 200);
798
+ let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
799
+ assert_eq!(v["object"], "chat.completion");
800
+ assert!(!v["choices"][0]["message"]["content"]
801
+ .as_str()
802
+ .unwrap()
803
+ .is_empty());
804
+ }
805
+
806
+ #[tokio::test]
807
+ async fn scheduled_chat_completions_streaming_via_http() {
808
+ let (_engine, se) = scheduled_test_setup();
809
+ let handler = scheduled_chat_completions_handler(se);
810
+ let client = TestClient::new(handler);
811
+ let body = serde_json::json!({
812
+ "model": "mock",
813
+ "messages": [{"role": "user", "content": "hello world"}],
814
+ "max_tokens": 5,
815
+ "stream": true
816
+ })
817
+ .to_string();
818
+ let resp = client
819
+ .post("/v1/chat/completions", body.into_bytes())
820
+ .await
821
+ .unwrap();
822
+ assert_eq!(resp.status, 200);
823
+ let text = String::from_utf8(resp.body).unwrap();
824
+ assert!(text.contains("data: "));
825
+ assert!(text.contains("data: [DONE]"));
826
+ }
827
+
828
+ #[tokio::test]
829
+ async fn scheduled_completions_non_streaming_via_http() {
830
+ let (_engine, se) = scheduled_test_setup();
831
+ let handler = scheduled_completions_handler(se);
832
+ let client = TestClient::new(handler);
833
+ let body = serde_json::json!({
834
+ "model": "mock",
835
+ "prompt": "hello world",
836
+ "max_tokens": 4,
837
+ "stream": false
838
+ })
839
+ .to_string();
840
+ let resp = client
841
+ .post("/v1/completions", body.into_bytes())
842
+ .await
843
+ .unwrap();
844
+ assert_eq!(resp.status, 200);
845
+ let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
846
+ assert_eq!(v["object"], "text_completion");
847
+ assert!(!v["choices"][0]["text"].as_str().unwrap().is_empty());
848
+ }
849
+
850
+ #[tokio::test]
851
+ async fn scheduled_server_with_openai_scheduled_exposes_metrics() {
852
+ let addr: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
853
+ let (_engine, se) = scheduled_test_setup();
854
+ let server = Server::new(addr).with_openai_scheduled(se);
855
+
856
+ // The /metrics endpoint should include scheduler prefix-cache lines,
857
+ // proving the scheduler's extra metric provider is wired in.
858
+ let rendered = server.metrics().prometheus();
859
+ assert!(
860
+ rendered.contains("justapi_scheduler_"),
861
+ "scheduler metrics should be exposed via /metrics: {rendered}"
862
+ );
863
+ let _ = server;
864
+ }
865
+}
0
866
▼crates/justapi-core/src/openapi.rs
+955-0
1
+use std::collections::BTreeMap;
2
+
3
+use hyper::Method;
4
+use serde::{Deserialize, Serialize};
5
+
6
+/// Top-level OpenAPI 3.1 document.
7
+#[derive(Debug, Clone, Serialize, Deserialize)]
8
+pub struct OpenApiDocument {
9
+ pub openapi: String,
10
+ pub info: Info,
11
+ #[serde(default, skip_serializing_if = "Option::is_none")]
12
+ pub servers: Option<Vec<Server>>,
13
+ pub paths: BTreeMap<String, PathItem>,
14
+ #[serde(default, skip_serializing_if = "Option::is_none")]
15
+ pub components: Option<Components>,
16
+ #[serde(default, skip_serializing_if = "Option::is_none")]
17
+ pub tags: Option<Vec<Tag>>,
18
+}
19
+
20
+#[derive(Debug, Clone, Serialize, Deserialize)]
21
+pub struct Info {
22
+ pub title: String,
23
+ pub version: String,
24
+ #[serde(default, skip_serializing_if = "Option::is_none")]
25
+ pub description: Option<String>,
26
+}
27
+
28
+#[derive(Debug, Clone, Serialize, Deserialize)]
29
+pub struct Server {
30
+ pub url: String,
31
+ #[serde(default, skip_serializing_if = "Option::is_none")]
32
+ pub description: Option<String>,
33
+}
34
+
35
+#[derive(Debug, Clone, Serialize, Deserialize)]
36
+pub struct PathItem {
37
+ #[serde(default, skip_serializing_if = "Option::is_none")]
38
+ pub get: Option<Operation>,
39
+ #[serde(default, skip_serializing_if = "Option::is_none")]
40
+ pub post: Option<Operation>,
41
+ #[serde(default, skip_serializing_if = "Option::is_none")]
42
+ pub put: Option<Operation>,
43
+ #[serde(default, skip_serializing_if = "Option::is_none")]
44
+ pub delete: Option<Operation>,
45
+ #[serde(default, skip_serializing_if = "Option::is_none")]
46
+ pub patch: Option<Operation>,
47
+ #[serde(default, skip_serializing_if = "Option::is_none")]
48
+ pub head: Option<Operation>,
49
+ #[serde(default, skip_serializing_if = "Option::is_none")]
50
+ pub options: Option<Operation>,
51
+ /// The HTTP QUERY method (RFC 10008) — safe, idempotent, body-carrying.
52
+ #[serde(default, skip_serializing_if = "Option::is_none")]
53
+ pub query: Option<Operation>,
54
+}
55
+
56
+impl PathItem {
57
+ pub fn insert(&mut self, method: &Method, op: Operation) {
58
+ if *method == crate::query_method() {
59
+ self.query = Some(op);
60
+ return;
61
+ }
62
+ match *method {
63
+ Method::GET => self.get = Some(op),
64
+ Method::POST => self.post = Some(op),
65
+ Method::PUT => self.put = Some(op),
66
+ Method::DELETE => self.delete = Some(op),
67
+ Method::PATCH => self.patch = Some(op),
68
+ Method::HEAD => self.head = Some(op),
69
+ Method::OPTIONS => self.options = Some(op),
70
+ _ => {}
71
+ }
72
+ }
73
+}
74
+
75
+#[derive(Debug, Clone, Serialize, Deserialize)]
76
+pub struct Operation {
77
+ #[serde(default, skip_serializing_if = "Option::is_none")]
78
+ pub summary: Option<String>,
79
+ #[serde(default, skip_serializing_if = "Option::is_none")]
80
+ pub description: Option<String>,
81
+ #[serde(default, skip_serializing_if = "Option::is_none")]
82
+ #[serde(rename = "operationId")]
83
+ pub operation_id: Option<String>,
84
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
85
+ pub tags: Vec<String>,
86
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
87
+ pub parameters: Vec<Parameter>,
88
+ #[serde(default, skip_serializing_if = "Option::is_none")]
89
+ #[serde(rename = "requestBody")]
90
+ pub request_body: Option<RequestBody>,
91
+ #[serde(default)]
92
+ pub responses: BTreeMap<String, Response>,
93
+ #[serde(default, skip_serializing_if = "Option::is_none")]
94
+ pub deprecated: Option<bool>,
95
+}
96
+
97
+#[derive(Debug, Clone, Serialize, Deserialize)]
98
+pub struct Parameter {
99
+ pub name: String,
100
+ #[serde(rename = "in")]
101
+ pub location: ParameterLocation,
102
+ #[serde(default, skip_serializing_if = "Option::is_none")]
103
+ pub description: Option<String>,
104
+ #[serde(default)]
105
+ pub required: bool,
106
+ #[serde(default, skip_serializing_if = "Option::is_none")]
107
+ pub schema: Option<Schema>,
108
+}
109
+
110
+#[derive(Debug, Clone, Serialize, Deserialize)]
111
+#[serde(rename_all = "lowercase")]
112
+pub enum ParameterLocation {
113
+ Query,
114
+ Path,
115
+ Header,
116
+ Cookie,
117
+}
118
+
119
+#[derive(Debug, Clone, Serialize, Deserialize)]
120
+pub struct RequestBody {
121
+ #[serde(default, skip_serializing_if = "Option::is_none")]
122
+ pub description: Option<String>,
123
+ pub content: BTreeMap<String, MediaType>,
124
+ #[serde(default)]
125
+ pub required: bool,
126
+}
127
+
128
+#[derive(Debug, Clone, Serialize, Deserialize)]
129
+pub struct MediaType {
130
+ #[serde(default, skip_serializing_if = "Option::is_none")]
131
+ pub schema: Option<Schema>,
132
+}
133
+
134
+#[derive(Debug, Clone, Serialize, Deserialize)]
135
+pub struct Response {
136
+ pub description: String,
137
+ #[serde(default, skip_serializing_if = "Option::is_none")]
138
+ pub content: Option<BTreeMap<String, MediaType>>,
139
+}
140
+
141
+/// Schema can be either a `$ref` reference or an inline SchemaObject.
142
+#[derive(Debug, Clone, Serialize, Deserialize)]
143
+#[serde(untagged)]
144
+#[allow(clippy::large_enum_variant)]
145
+pub enum Schema {
146
+ Ref {
147
+ #[serde(rename = "$ref")]
148
+ ref_path: String,
149
+ },
150
+ Object(SchemaObject),
151
+}
152
+
153
+impl Schema {
154
+ pub fn ref_path(path: &str) -> Self {
155
+ Self::Ref {
156
+ ref_path: path.to_string(),
157
+ }
158
+ }
159
+
160
+ pub fn object(obj: SchemaObject) -> Self {
161
+ Self::Object(obj)
162
+ }
163
+
164
+ pub fn string() -> Self {
165
+ Self::Object(SchemaObject::string())
166
+ }
167
+
168
+ pub fn integer() -> Self {
169
+ Self::Object(SchemaObject::integer())
170
+ }
171
+
172
+ pub fn number() -> Self {
173
+ Self::Object(SchemaObject::number())
174
+ }
175
+
176
+ pub fn boolean() -> Self {
177
+ Self::Object(SchemaObject::boolean())
178
+ }
179
+
180
+ pub fn object_type() -> Self {
181
+ Self::Object(SchemaObject::object())
182
+ }
183
+
184
+ pub fn array(items: Schema) -> Self {
185
+ Self::Object(SchemaObject::array().with_items(items))
186
+ }
187
+}
188
+
189
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
190
+pub struct SchemaObject {
191
+ #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
192
+ pub schema_type: Option<String>,
193
+ #[serde(default, skip_serializing_if = "Option::is_none")]
194
+ pub format: Option<String>,
195
+ #[serde(default, skip_serializing_if = "Option::is_none")]
196
+ pub title: Option<String>,
197
+ #[serde(default, skip_serializing_if = "Option::is_none")]
198
+ pub description: Option<String>,
199
+ #[serde(default, skip_serializing_if = "Option::is_none")]
200
+ pub properties: Option<BTreeMap<String, Schema>>,
201
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
202
+ pub required: Vec<String>,
203
+ #[serde(default, skip_serializing_if = "Option::is_none")]
204
+ pub items: Option<Box<Schema>>,
205
+ #[serde(default, skip_serializing_if = "Option::is_none")]
206
+ #[serde(rename = "additionalProperties")]
207
+ pub additional_properties: Option<Box<Schema>>,
208
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
209
+ #[serde(rename = "enum")]
210
+ pub enum_values: Vec<serde_json::Value>,
211
+ #[serde(default, skip_serializing_if = "Option::is_none")]
212
+ pub default: Option<serde_json::Value>,
213
+ #[serde(default, skip_serializing_if = "Option::is_none")]
214
+ pub example: Option<serde_json::Value>,
215
+ #[serde(default, skip_serializing_if = "Option::is_none")]
216
+ pub nullable: Option<bool>,
217
+ #[serde(default, skip_serializing_if = "Option::is_none")]
218
+ #[serde(rename = "readOnly")]
219
+ pub read_only: Option<bool>,
220
+ #[serde(default, skip_serializing_if = "Option::is_none")]
221
+ #[serde(rename = "writeOnly")]
222
+ pub write_only: Option<bool>,
223
+ #[serde(default, skip_serializing_if = "Option::is_none")]
224
+ pub minimum: Option<f64>,
225
+ #[serde(default, skip_serializing_if = "Option::is_none")]
226
+ pub maximum: Option<f64>,
227
+ #[serde(default, skip_serializing_if = "Option::is_none")]
228
+ #[serde(rename = "minLength")]
229
+ pub min_length: Option<u32>,
230
+ #[serde(default, skip_serializing_if = "Option::is_none")]
231
+ #[serde(rename = "maxLength")]
232
+ pub max_length: Option<u32>,
233
+ #[serde(default, skip_serializing_if = "Option::is_none")]
234
+ pub pattern: Option<String>,
235
+}
236
+
237
+impl SchemaObject {
238
+ pub fn string() -> Self {
239
+ Self {
240
+ schema_type: Some("string".to_string()),
241
+ ..Default::default()
242
+ }
243
+ }
244
+
245
+ pub fn integer() -> Self {
246
+ Self {
247
+ schema_type: Some("integer".to_string()),
248
+ ..Default::default()
249
+ }
250
+ }
251
+
252
+ pub fn number() -> Self {
253
+ Self {
254
+ schema_type: Some("number".to_string()),
255
+ ..Default::default()
256
+ }
257
+ }
258
+
259
+ pub fn boolean() -> Self {
260
+ Self {
261
+ schema_type: Some("boolean".to_string()),
262
+ ..Default::default()
263
+ }
264
+ }
265
+
266
+ pub fn object() -> Self {
267
+ Self {
268
+ schema_type: Some("object".to_string()),
269
+ ..Default::default()
270
+ }
271
+ }
272
+
273
+ pub fn array() -> Self {
274
+ Self {
275
+ schema_type: Some("array".to_string()),
276
+ ..Default::default()
277
+ }
278
+ }
279
+
280
+ pub fn with_items(mut self, items: Schema) -> Self {
281
+ self.items = Some(Box::new(items));
282
+ self
283
+ }
284
+
285
+ pub fn with_property(mut self, name: &str, prop: Schema) -> Self {
286
+ self.properties
287
+ .get_or_insert_with(BTreeMap::new)
288
+ .insert(name.to_string(), prop);
289
+ self
290
+ }
291
+
292
+ pub fn with_required(mut self, field: &str) -> Self {
293
+ self.required.push(field.to_string());
294
+ self
295
+ }
296
+
297
+ pub fn with_description(mut self, desc: &str) -> Self {
298
+ self.description = Some(desc.to_string());
299
+ self
300
+ }
301
+}
302
+
303
+#[derive(Debug, Clone, Serialize, Deserialize)]
304
+pub struct Components {
305
+ #[serde(default, skip_serializing_if = "Option::is_none")]
306
+ pub schemas: Option<BTreeMap<String, SchemaObject>>,
307
+}
308
+
309
+#[derive(Debug, Clone, Serialize, Deserialize)]
310
+pub struct Tag {
311
+ pub name: String,
312
+ #[serde(default, skip_serializing_if = "Option::is_none")]
313
+ pub description: Option<String>,
314
+}
315
+
316
+// ---------------------------------------------------------------------------
317
+// Builder
318
+// ---------------------------------------------------------------------------
319
+
320
+#[derive(Debug, Clone)]
321
+pub struct OpenApiBuilder {
322
+ info: Info,
323
+ servers: Vec<Server>,
324
+ paths: BTreeMap<String, PathItem>,
325
+ schemas: BTreeMap<String, SchemaObject>,
326
+ tags: Vec<Tag>,
327
+}
328
+
329
+impl OpenApiBuilder {
330
+ pub fn new(title: &str, version: &str) -> Self {
331
+ Self {
332
+ info: Info {
333
+ title: title.to_string(),
334
+ version: version.to_string(),
335
+ description: None,
336
+ },
337
+ servers: Vec::new(),
338
+ paths: BTreeMap::new(),
339
+ schemas: BTreeMap::new(),
340
+ tags: Vec::new(),
341
+ }
342
+ }
343
+
344
+ pub fn description(mut self, desc: &str) -> Self {
345
+ self.info.description = Some(desc.to_string());
346
+ self
347
+ }
348
+
349
+ pub fn server(mut self, url: &str, description: Option<&str>) -> Self {
350
+ self.servers.push(Server {
351
+ url: url.to_string(),
352
+ description: description.map(|s| s.to_string()),
353
+ });
354
+ self
355
+ }
356
+
357
+ pub fn tag(mut self, name: &str, description: Option<&str>) -> Self {
358
+ self.tags.push(Tag {
359
+ name: name.to_string(),
360
+ description: description.map(|s| s.to_string()),
361
+ });
362
+ self
363
+ }
364
+
365
+ pub fn schema(mut self, name: &str, schema: SchemaObject) -> Self {
366
+ self.schemas.insert(name.to_string(), schema);
367
+ self
368
+ }
369
+
370
+ pub fn operation<P: AsRef<str>>(mut self, method: Method, path: P, op: Operation) -> Self {
371
+ let path_str = path.as_ref().to_string();
372
+ let entry = self.paths.entry(path_str).or_insert_with(|| PathItem {
373
+ get: None,
374
+ post: None,
375
+ put: None,
376
+ delete: None,
377
+ patch: None,
378
+ head: None,
379
+ options: None,
380
+ query: None,
381
+ });
382
+ entry.insert(&method, op);
383
+ self
384
+ }
385
+
386
+ pub fn build(self) -> OpenApiDocument {
387
+ let components = if self.schemas.is_empty() {
388
+ None
389
+ } else {
390
+ Some(Components {
391
+ schemas: Some(self.schemas),
392
+ })
393
+ };
394
+
395
+ let tags = if self.tags.is_empty() {
396
+ None
397
+ } else {
398
+ Some(self.tags)
399
+ };
400
+
401
+ let servers = if self.servers.is_empty() {
402
+ None
403
+ } else {
404
+ Some(self.servers)
405
+ };
406
+
407
+ OpenApiDocument {
408
+ openapi: "3.1.0".to_string(),
409
+ info: self.info,
410
+ servers,
411
+ paths: self.paths,
412
+ components,
413
+ tags,
414
+ }
415
+ }
416
+}
417
+
418
+// ---------------------------------------------------------------------------
419
+// Route metadata collection (for native API integration)
420
+// ---------------------------------------------------------------------------
421
+
422
+#[derive(Debug, Clone)]
423
+pub struct RouteMeta {
424
+ pub method: Method,
425
+ pub path: String,
426
+ pub summary: Option<String>,
427
+ pub description: Option<String>,
428
+ pub tags: Vec<String>,
429
+ pub request_body_schema: Option<serde_json::Value>,
430
+ pub response_schema: Option<serde_json::Value>,
431
+ pub deprecated: bool,
432
+ /// When true, the generated operation is tagged `experimental`
433
+ /// (used for recently-standardized methods such as HTTP QUERY).
434
+ pub experimental: bool,
435
+}
436
+
437
+#[derive(Debug, Clone, Default)]
438
+pub struct OpenApiRegistry {
439
+ routes: Vec<RouteMeta>,
440
+}
441
+
442
+impl OpenApiRegistry {
443
+ pub fn new() -> Self {
444
+ Self::default()
445
+ }
446
+
447
+ pub fn register(&mut self, meta: RouteMeta) {
448
+ self.routes.push(meta);
449
+ }
450
+
451
+ pub fn generate(&self, title: &str, version: &str) -> OpenApiDocument {
452
+ let mut builder = OpenApiBuilder::new(title, version)
453
+ .server("http://localhost:8080", Some("Local development"))
454
+ .tag("default", Some("Default endpoint group"));
455
+
456
+ for route in &self.routes {
457
+ let mut op = Operation {
458
+ summary: route.summary.clone(),
459
+ description: route.description.clone(),
460
+ operation_id: Some(format!(
461
+ "{}_{}",
462
+ route.method.as_str().to_lowercase(),
463
+ route
464
+ .path
465
+ .replace(['/', '{', '}', ':'], "_")
466
+ .trim_matches('_')
467
+ )),
468
+ tags: {
469
+ let mut tags = route.tags.clone();
470
+ if route.experimental && !tags.iter().any(|t| t == "experimental") {
471
+ tags.push("experimental".to_string());
472
+ }
473
+ tags
474
+ },
475
+ parameters: Vec::new(),
476
+ request_body: None,
477
+ responses: BTreeMap::new(),
478
+ deprecated: if route.deprecated { Some(true) } else { None },
479
+ };
480
+
481
+ let mut path_params = Vec::new();
482
+ for segment in route.path.split('/') {
483
+ if segment.starts_with('{') && segment.ends_with('}') {
484
+ let name = &segment[1..segment.len() - 1];
485
+ path_params.push(name.to_string());
486
+ } else if let Some(name) = segment.strip_prefix(':') {
487
+ path_params.push(name.to_string());
488
+ }
489
+ }
490
+
491
+ for name in &path_params {
492
+ op.parameters.push(Parameter {
493
+ name: name.clone(),
494
+ location: ParameterLocation::Path,
495
+ description: None,
496
+ required: true,
497
+ schema: Some(Schema::string()),
498
+ });
499
+ }
500
+
501
+ let is_body_method = matches!(route.method, Method::POST | Method::PUT | Method::PATCH)
502
+ || route.method == crate::query_method();
503
+ if is_body_method {
504
+ let body_schema = route.request_body_schema.as_ref().map(json_value_to_schema);
505
+
506
+ let mut content = BTreeMap::new();
507
+ content.insert(
508
+ "application/json".to_string(),
509
+ MediaType {
510
+ schema: body_schema,
511
+ },
512
+ );
513
+ op.request_body = Some(RequestBody {
514
+ description: None,
515
+ content,
516
+ required: true,
517
+ });
518
+ }
519
+
520
+ let mut responses = BTreeMap::new();
521
+ responses.insert(
522
+ "200".to_string(),
523
+ Response {
524
+ description: "Successful response".to_string(),
525
+ content: Some({
526
+ let mut c = BTreeMap::new();
527
+ c.insert(
528
+ "application/json".to_string(),
529
+ MediaType {
530
+ schema: route.response_schema.as_ref().map(json_value_to_schema),
531
+ },
532
+ );
533
+ c
534
+ }),
535
+ },
536
+ );
537
+ responses.insert(
538
+ "400".to_string(),
539
+ Response {
540
+ description: "Bad request".to_string(),
541
+ content: None,
542
+ },
543
+ );
544
+ responses.insert(
545
+ "500".to_string(),
546
+ Response {
547
+ description: "Internal server error".to_string(),
548
+ content: None,
549
+ },
550
+ );
551
+ op.responses = responses;
552
+
553
+ builder = builder.operation(route.method.clone(), &route.path, op);
554
+ }
555
+
556
+ builder.build()
557
+ }
558
+}
559
+
560
+fn json_value_to_schema(value: &serde_json::Value) -> Schema {
561
+ match value {
562
+ serde_json::Value::Null => Schema::Object(SchemaObject {
563
+ schema_type: Some("null".to_string()),
564
+ ..Default::default()
565
+ }),
566
+ serde_json::Value::Bool(_) => Schema::boolean(),
567
+ serde_json::Value::Number(n) => {
568
+ if n.is_f64() {
569
+ Schema::number()
570
+ } else {
571
+ Schema::integer()
572
+ }
573
+ }
574
+ serde_json::Value::String(_) => Schema::string(),
575
+ serde_json::Value::Array(items) => {
576
+ let item_schema = items.first().map(json_value_to_schema);
577
+ Schema::Object(SchemaObject {
578
+ schema_type: Some("array".to_string()),
579
+ items: item_schema.map(Box::new),
580
+ ..Default::default()
581
+ })
582
+ }
583
+ serde_json::Value::Object(map) => {
584
+ let mut obj = SchemaObject::object();
585
+ let mut required = Vec::new();
586
+ for (key, val) in map {
587
+ obj.properties
588
+ .get_or_insert_with(BTreeMap::new)
589
+ .insert(key.clone(), json_value_to_schema(val));
590
+ if !val.is_null() {
591
+ required.push(key.clone());
592
+ }
593
+ }
594
+ obj.required = required;
595
+ Schema::Object(obj)
596
+ }
597
+ }
598
+}
599
+
600
+// ---------------------------------------------------------------------------
601
+// Swagger UI HTML helper
602
+// ---------------------------------------------------------------------------
603
+
604
+const SWAGGER_UI_HTML: &str = r###"<!DOCTYPE html>
605
+<html lang="en">
606
+<head>
607
+ <meta charset="UTF-8">
608
+ <title>JustAPI — Swagger UI</title>
609
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
610
+</head>
611
+<body>
612
+ <div id="swagger-ui"></div>
613
+ <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
614
+ <script>
615
+ SwaggerUIBundle({
616
+ url: '/openapi.json',
617
+ dom_id: '#swagger-ui',
618
+ presets: [
619
+ SwaggerUIBundle.presets.apis,
620
+ SwaggerUIBundle.SwaggerUIStandalonePreset
621
+ ],
622
+ layout: "BaseLayout"
623
+ });
624
+ </script>
625
+</body>
626
+</html>"###;
627
+
628
+const REDOC_HTML: &str = r###"<!DOCTYPE html>
629
+<html lang="en">
630
+<head>
631
+ <meta charset="UTF-8">
632
+ <title>JustAPI — ReDoc</title>
633
+ <meta name="viewport" content="width=device-width, initial-scale=1">
634
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,700|Roboto+Mono">
635
+ <style>
636
+ body { margin: 0; padding: 0; }
637
+ </style>
638
+</head>
639
+<body>
640
+ <div id="redoc-container"></div>
641
+ <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
642
+ <script>
643
+ Redoc.init('/openapi.json', {
644
+ scrollYOffset: 0,
645
+ hideDownloadButton: false,
646
+ expandResponses: "200"
647
+ }, document.getElementById('redoc-container'));
648
+ </script>
649
+</body>
650
+</html>"###;
651
+
652
+pub fn swagger_ui_html() -> &'static str {
653
+ SWAGGER_UI_HTML
654
+}
655
+
656
+pub fn redoc_html() -> &'static str {
657
+ REDOC_HTML
658
+}
659
+
660
+// ---------------------------------------------------------------------------
661
+// Tests
662
+// ---------------------------------------------------------------------------
663
+
664
+#[cfg(test)]
665
+mod tests {
666
+ use super::*;
667
+
668
+ #[test]
669
+ fn test_minimal_document() {
670
+ let doc = OpenApiBuilder::new("Test API", "1.0.0").build();
671
+ let json = serde_json::to_string_pretty(&doc).unwrap();
672
+ assert!(json.contains("3.1.0"));
673
+ assert!(json.contains("Test API"));
674
+ }
675
+
676
+ #[test]
677
+ fn test_with_server_and_tags() {
678
+ let doc = OpenApiBuilder::new("My API", "0.1.0")
679
+ .server("https://api.example.com", Some("Production"))
680
+ .tag("users", Some("User operations"))
681
+ .build();
682
+ assert!(doc.servers.is_some());
683
+ assert_eq!(doc.servers.as_ref().unwrap().len(), 1);
684
+ assert!(doc.tags.is_some());
685
+ }
686
+
687
+ #[test]
688
+ fn test_get_operation() {
689
+ let doc = OpenApiBuilder::new("Test", "1.0")
690
+ .operation(
691
+ Method::GET,
692
+ "/users/{id}",
693
+ Operation {
694
+ summary: Some("Get user by ID".to_string()),
695
+ description: None,
696
+ operation_id: Some("get_users_id".to_string()),
697
+ tags: vec!["users".to_string()],
698
+ parameters: vec![Parameter {
699
+ name: "id".to_string(),
700
+ location: ParameterLocation::Path,
701
+ description: Some("User ID".to_string()),
702
+ required: true,
703
+ schema: Some(Schema::integer()),
704
+ }],
705
+ request_body: None,
706
+ responses: {
707
+ let mut m = BTreeMap::new();
708
+ m.insert(
709
+ "200".to_string(),
710
+ Response {
711
+ description: "User found".to_string(),
712
+ content: None,
713
+ },
714
+ );
715
+ m
716
+ },
717
+ deprecated: None,
718
+ },
719
+ )
720
+ .build();
721
+
722
+ let json = serde_json::to_value(&doc).unwrap();
723
+ let path = json.pointer("/paths/~1users~1{id}").unwrap();
724
+ let get = path.pointer("/get").unwrap();
725
+ assert_eq!(get["summary"], "Get user by ID");
726
+ assert_eq!(get["parameters"][0]["name"], "id");
727
+ assert_eq!(get["parameters"][0]["in"], "path");
728
+ }
729
+
730
+ #[test]
731
+ fn test_post_operation_with_body() {
732
+ let body_schema = serde_json::json!({
733
+ "name": "string",
734
+ "email": "string"
735
+ });
736
+ let response_schema = serde_json::json!({
737
+ "id": 0,
738
+ "name": "string"
739
+ });
740
+
741
+ let mut registry = OpenApiRegistry::new();
742
+ registry.register(RouteMeta {
743
+ method: Method::POST,
744
+ path: "/users".to_string(),
745
+ summary: Some("Create user".to_string()),
746
+ description: None,
747
+ tags: vec!["users".to_string()],
748
+ request_body_schema: Some(body_schema),
749
+ response_schema: Some(response_schema),
750
+ deprecated: false,
751
+ experimental: false,
752
+ });
753
+
754
+ let doc = registry.generate("Test", "1.0.0");
755
+ let json = serde_json::to_value(&doc).unwrap();
756
+ let path = json.pointer("/paths/~1users").unwrap();
757
+ let post = path.pointer("/post").unwrap();
758
+ assert!(post.get("requestBody").is_some());
759
+ // parameters is skipped when empty (Vec::is_empty → skip_serializing_if)
760
+ assert!(
761
+ post.get("parameters").is_none() || post["parameters"].as_array().unwrap().is_empty()
762
+ );
763
+ }
764
+
765
+ #[test]
766
+ fn test_path_parameters_extracted() {
767
+ let mut registry = OpenApiRegistry::new();
768
+ registry.register(RouteMeta {
769
+ method: Method::GET,
770
+ path: "/users/{id}/posts/{postId}".to_string(),
771
+ summary: Some("Get post by user".to_string()),
772
+ description: None,
773
+ tags: vec![],
774
+ request_body_schema: None,
775
+ response_schema: None,
776
+ deprecated: false,
777
+ experimental: false,
778
+ });
779
+
780
+ let doc = registry.generate("Test", "1.0.0");
781
+ let json = serde_json::to_value(&doc).unwrap();
782
+ let params = json
783
+ .pointer("/paths/~1users~1{id}~1posts~1{postId}/get/parameters")
784
+ .unwrap();
785
+ assert_eq!(params.as_array().unwrap().len(), 2);
786
+ assert_eq!(params[0]["name"], "id");
787
+ assert_eq!(params[1]["name"], "postId");
788
+ }
789
+
790
+ #[test]
791
+ fn test_query_field_serialized() {
792
+ let doc = OpenApiBuilder::new("Q", "1.0.0")
793
+ .operation(
794
+ crate::query_method(),
795
+ "/search",
796
+ Operation {
797
+ summary: Some("Search".to_string()),
798
+ description: None,
799
+ operation_id: Some("query_search".to_string()),
800
+ tags: vec![],
801
+ parameters: vec![],
802
+ request_body: Some(RequestBody {
803
+ description: None,
804
+ content: {
805
+ let mut c = std::collections::BTreeMap::new();
806
+ c.insert(
807
+ "application/x-www-form-urlencoded".to_string(),
808
+ MediaType {
809
+ schema: Some(Schema::string()),
810
+ },
811
+ );
812
+ c
813
+ },
814
+ required: true,
815
+ }),
816
+ responses: {
817
+ let mut m = std::collections::BTreeMap::new();
818
+ m.insert(
819
+ "200".to_string(),
820
+ Response {
821
+ description: "ok".to_string(),
822
+ content: None,
823
+ },
824
+ );
825
+ m
826
+ },
827
+ deprecated: None,
828
+ },
829
+ )
830
+ .build();
831
+ let json = serde_json::to_value(&doc).unwrap();
832
+ let op = json
833
+ .pointer("/paths/~1search/query")
834
+ .expect("query op present");
835
+ assert_eq!(op["summary"], "Search");
836
+ assert!(op.get("requestBody").is_some());
837
+ }
838
+
839
+ #[test]
840
+ fn test_query_tagged_experimental() {
841
+ let mut registry = OpenApiRegistry::new();
842
+ registry.register(RouteMeta {
843
+ method: crate::query_method(),
844
+ path: "/search".to_string(),
845
+ summary: Some("Search".to_string()),
846
+ description: None,
847
+ tags: vec![],
848
+ request_body_schema: Some(serde_json::json!({"q": "string"})),
849
+ response_schema: None,
850
+ deprecated: false,
851
+ experimental: true,
852
+ });
853
+
854
+ let doc = registry.generate("Test", "1.0.0");
855
+ let json = serde_json::to_value(&doc).unwrap();
856
+ let op = json
857
+ .pointer("/paths/~1search/query")
858
+ .expect("query op present");
859
+ let tags = op["tags"].as_array().expect("tags array");
860
+ assert!(tags.iter().any(|t| t == "experimental"));
861
+ // QUERY carries a request body per RFC 10008.
862
+ assert!(op.get("requestBody").is_some());
863
+ }
864
+
865
+ #[test]
866
+ fn test_query_not_tagged_when_not_experimental() {
867
+ let mut registry = OpenApiRegistry::new();
868
+ registry.register(RouteMeta {
869
+ method: crate::query_method(),
870
+ path: "/search".to_string(),
871
+ summary: Some("Search".to_string()),
872
+ description: None,
873
+ tags: vec!["search".to_string()],
874
+ request_body_schema: None,
875
+ response_schema: None,
876
+ deprecated: false,
877
+ experimental: false,
878
+ });
879
+
880
+ let doc = registry.generate("Test", "1.0.0");
881
+ let json = serde_json::to_value(&doc).unwrap();
882
+ let op = json
883
+ .pointer("/paths/~1search/query")
884
+ .expect("query op present");
885
+ let tags = op["tags"].as_array().expect("tags array");
886
+ assert!(tags.iter().all(|t| t != "experimental"));
887
+ }
888
+
889
+ #[test]
890
+ fn test_schema_object_builder() {
891
+ let schema = SchemaObject::object()
892
+ .with_property("name", Schema::string())
893
+ .with_property("age", Schema::integer())
894
+ .with_required("name");
895
+
896
+ let json = serde_json::to_value(&schema).unwrap();
897
+ assert_eq!(json["type"], "object");
898
+ assert!(json["properties"]["name"]["type"].as_str().is_some());
899
+ assert_eq!(json["required"][0], "name");
900
+ }
901
+
902
+ #[test]
903
+ fn test_json_value_to_schema() {
904
+ let v =
905
+ serde_json::json!({"name": "Alice", "age": 30, "active": true, "scores": [1, 2, 3]});
906
+ let schema = json_value_to_schema(&v);
907
+ let json = serde_json::to_value(&schema).unwrap();
908
+ if let Some(obj) = json.as_object() {
909
+ assert_eq!(
910
+ obj.get("properties")
911
+ .and_then(|p| p.get("name"))
912
+ .and_then(|n| n.get("type")),
913
+ Some(&serde_json::json!("string"))
914
+ );
915
+ }
916
+ }
917
+
918
+ #[test]
919
+ fn test_generated_json_is_valid_openapi() {
920
+ let doc = OpenApiBuilder::new("Valid API", "2.0.0")
921
+ .server("http://localhost:8080", None)
922
+ .operation(
923
+ Method::GET,
924
+ "/health",
925
+ Operation {
926
+ summary: Some("Health check".to_string()),
927
+ description: None,
928
+ operation_id: Some("get_health".to_string()),
929
+ tags: vec![],
930
+ parameters: vec![],
931
+ request_body: None,
932
+ responses: {
933
+ let mut m = BTreeMap::new();
934
+ m.insert(
935
+ "200".to_string(),
936
+ Response {
937
+ description: "OK".to_string(),
938
+ content: None,
939
+ },
940
+ );
941
+ m
942
+ },
943
+ deprecated: None,
944
+ },
945
+ )
946
+ .build();
947
+
948
+ let json = serde_json::to_value(&doc).unwrap();
949
+ assert_eq!(json["openapi"], "3.1.0");
950
+ assert_eq!(json["info"]["title"], "Valid API");
951
+ assert_eq!(json["info"]["version"], "2.0.0");
952
+ assert!(json.get("paths").is_some());
953
+ assert!(json.get("servers").is_some());
954
+ }
955
+}
0
956
▼crates/justapi-core/src/plugin.rs
+88-0
1
+use std::collections::HashMap;
2
+
3
+use anyhow::Result;
4
+use async_trait::async_trait;
5
+
6
+use crate::Server;
7
+
8
+/// Lifecycle trait for JustAPI plugins.
9
+/// Plugins can hook into the server lifecycle and modify the application setup.
10
+#[async_trait]
11
+pub trait Plugin: Send + Sync {
12
+ /// Unique name of the plugin.
13
+ fn name(&self) -> &'static str;
14
+
15
+ /// Called when the plugin is registered with the server.
16
+ /// The plugin can register middlewares, routers, or modify the Server configuration here.
17
+ fn build(&self, _server: &mut Server) -> Result<()> {
18
+ Ok(())
19
+ }
20
+
21
+ /// Called when the server is starting up.
22
+ async fn on_startup(&self) -> Result<()> {
23
+ Ok(())
24
+ }
25
+
26
+ /// Called when the server is shutting down.
27
+ async fn on_shutdown(&self) -> Result<()> {
28
+ Ok(())
29
+ }
30
+}
31
+
32
+/// A registration entry for a plugin. This is used by the `inventory` crate
33
+/// to collect statically registered plugins at compile time.
34
+pub struct PluginRegistration {
35
+ pub name: &'static str,
36
+ pub builder: fn() -> Box<dyn Plugin>,
37
+}
38
+
39
+inventory::collect!(PluginRegistration);
40
+
41
+/// A registry of loaded plugins.
42
+#[derive(Default)]
43
+pub struct PluginRegistry {
44
+ plugins: HashMap<String, Box<dyn Plugin>>,
45
+}
46
+
47
+impl PluginRegistry {
48
+ pub fn new() -> Self {
49
+ Self::default()
50
+ }
51
+
52
+ /// Register a plugin instance explicitly.
53
+ pub fn register(&mut self, plugin: Box<dyn Plugin>) {
54
+ self.plugins.insert(plugin.name().to_string(), plugin);
55
+ }
56
+
57
+ /// Load all plugins that were registered statically via the `inventory` crate.
58
+ pub fn load_static_plugins(&mut self) {
59
+ for reg in inventory::iter::<PluginRegistration> {
60
+ let plugin = (reg.builder)();
61
+ self.plugins.insert(plugin.name().to_string(), plugin);
62
+ }
63
+ }
64
+
65
+ /// Invoke the `build` lifecycle hook on all registered plugins.
66
+ pub fn build_all(&self, server: &mut Server) -> Result<()> {
67
+ for plugin in self.plugins.values() {
68
+ plugin.build(server)?;
69
+ }
70
+ Ok(())
71
+ }
72
+
73
+ /// Invoke the `on_startup` lifecycle hook on all registered plugins.
74
+ pub async fn on_startup_all(&self) -> Result<()> {
75
+ for plugin in self.plugins.values() {
76
+ plugin.on_startup().await?;
77
+ }
78
+ Ok(())
79
+ }
80
+
81
+ /// Invoke the `on_shutdown` lifecycle hook on all registered plugins.
82
+ pub async fn on_shutdown_all(&self) -> Result<()> {
83
+ for plugin in self.plugins.values() {
84
+ plugin.on_shutdown().await?;
85
+ }
86
+ Ok(())
87
+ }
88
+}
0
89
▼crates/justapi-core/src/resilience.rs
+1578-0
1
+use std::sync::Arc;
2
+use std::time::Duration;
3
+
4
+use anyhow::Result;
5
+use async_trait::async_trait;
6
+use http_body_util::BodyExt;
7
+use hyper::body::Bytes;
8
+use hyper::{Request, Response, StatusCode};
9
+use tokio::sync::{Mutex, Semaphore};
10
+use tokio::time::Instant;
11
+
12
+use crate::middleware::{Middleware, Next};
13
+use crate::ResponseBody;
14
+
15
+// ---------------------------------------------------------------------------
16
+// Circuit Breaker
17
+// ---------------------------------------------------------------------------
18
+
19
+#[derive(Debug, Clone, PartialEq)]
20
+enum BreakerState {
21
+ Closed,
22
+ Open(Instant),
23
+ HalfOpen,
24
+}
25
+
26
+struct BreakerInner {
27
+ state: BreakerState,
28
+ failure_count: u32,
29
+ success_count: u32,
30
+ half_open_allowed: u32,
31
+ half_open_used: u32,
32
+}
33
+
34
+#[derive(Debug, Clone)]
35
+pub struct CircuitBreakerConfig {
36
+ pub failure_threshold: u32,
37
+ pub success_threshold: u32,
38
+ pub open_timeout: Duration,
39
+ pub half_open_max_requests: u32,
40
+}
41
+
42
+impl Default for CircuitBreakerConfig {
43
+ fn default() -> Self {
44
+ Self {
45
+ failure_threshold: 5,
46
+ success_threshold: 3,
47
+ open_timeout: Duration::from_secs(30),
48
+ half_open_max_requests: 3,
49
+ }
50
+ }
51
+}
52
+
53
+#[derive(Clone)]
54
+pub struct CircuitBreaker {
55
+ inner: Arc<Mutex<BreakerInner>>,
56
+ config: CircuitBreakerConfig,
57
+}
58
+
59
+impl CircuitBreaker {
60
+ pub fn new(config: CircuitBreakerConfig) -> Self {
61
+ Self {
62
+ inner: Arc::new(Mutex::new(BreakerInner {
63
+ state: BreakerState::Closed,
64
+ failure_count: 0,
65
+ success_count: 0,
66
+ half_open_allowed: config.half_open_max_requests,
67
+ half_open_used: 0,
68
+ })),
69
+ config,
70
+ }
71
+ }
72
+
73
+ async fn try_acquire(&self) -> bool {
74
+ let mut inner = self.inner.lock().await;
75
+ match &inner.state {
76
+ BreakerState::Closed => true,
77
+ BreakerState::Open(opened_at) => {
78
+ if opened_at.elapsed() >= self.config.open_timeout {
79
+ inner.state = BreakerState::HalfOpen;
80
+ inner.half_open_used = 0;
81
+ inner.success_count = 0;
82
+ true
83
+ } else {
84
+ false
85
+ }
86
+ }
87
+ BreakerState::HalfOpen => {
88
+ if inner.half_open_used < inner.half_open_allowed {
89
+ inner.half_open_used += 1;
90
+ true
91
+ } else {
92
+ false
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ async fn record_success(&self) {
99
+ let mut inner = self.inner.lock().await;
100
+ match &inner.state {
101
+ BreakerState::HalfOpen => {
102
+ inner.success_count += 1;
103
+ if inner.success_count >= self.config.success_threshold {
104
+ inner.state = BreakerState::Closed;
105
+ inner.failure_count = 0;
106
+ inner.success_count = 0;
107
+ }
108
+ }
109
+ BreakerState::Closed => {
110
+ inner.failure_count = 0;
111
+ }
112
+ _ => {}
113
+ }
114
+ }
115
+
116
+ async fn record_failure(&self) {
117
+ let mut inner = self.inner.lock().await;
118
+ match &inner.state {
119
+ BreakerState::Closed => {
120
+ inner.failure_count += 1;
121
+ if inner.failure_count >= self.config.failure_threshold {
122
+ inner.state = BreakerState::Open(Instant::now());
123
+ inner.failure_count = 0;
124
+ }
125
+ }
126
+ BreakerState::HalfOpen => {
127
+ inner.state = BreakerState::Open(Instant::now());
128
+ inner.failure_count = 0;
129
+ }
130
+ _ => {}
131
+ }
132
+ }
133
+}
134
+
135
+pub struct CircuitBreakerMiddleware {
136
+ breaker: CircuitBreaker,
137
+}
138
+
139
+impl CircuitBreakerMiddleware {
140
+ pub fn new(breaker: CircuitBreaker) -> Self {
141
+ Self { breaker }
142
+ }
143
+}
144
+
145
+#[async_trait]
146
+impl<B: Send + Sync + 'static> Middleware<B> for CircuitBreakerMiddleware {
147
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
148
+ if !self.breaker.try_acquire().await {
149
+ return Ok(circuit_open_response());
150
+ }
151
+
152
+ match next.run(req).await {
153
+ Ok(resp) => {
154
+ if resp.status().is_server_error() {
155
+ self.breaker.record_failure().await;
156
+ } else {
157
+ self.breaker.record_success().await;
158
+ }
159
+ Ok(resp)
160
+ }
161
+ Err(e) => {
162
+ self.breaker.record_failure().await;
163
+ Err(e)
164
+ }
165
+ }
166
+ }
167
+}
168
+
169
+pub struct PerRouteCircuitBreakerMiddleware {
170
+ config: CircuitBreakerConfig,
171
+ breakers: std::sync::RwLock<std::collections::HashMap<String, CircuitBreaker>>,
172
+}
173
+
174
+impl PerRouteCircuitBreakerMiddleware {
175
+ pub fn new(config: CircuitBreakerConfig) -> Self {
176
+ Self {
177
+ config,
178
+ breakers: std::sync::RwLock::new(std::collections::HashMap::new()),
179
+ }
180
+ }
181
+
182
+ fn get_breaker(&self, path: &str) -> CircuitBreaker {
183
+ {
184
+ let read = self.breakers.read().unwrap();
185
+ if let Some(cb) = read.get(path) {
186
+ return cb.clone();
187
+ }
188
+ }
189
+ let mut write = self.breakers.write().unwrap();
190
+ // check again to prevent race conditions
191
+ if let Some(cb) = write.get(path) {
192
+ return cb.clone();
193
+ }
194
+ let cb = CircuitBreaker::new(self.config.clone());
195
+ write.insert(path.to_string(), cb.clone());
196
+ cb
197
+ }
198
+}
199
+
200
+#[async_trait]
201
+impl<B: Send + Sync + 'static> Middleware<B> for PerRouteCircuitBreakerMiddleware {
202
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
203
+ let path = req.uri().path().to_string();
204
+ let breaker = self.get_breaker(&path);
205
+
206
+ if !breaker.try_acquire().await {
207
+ return Ok(circuit_open_response());
208
+ }
209
+
210
+ match next.run(req).await {
211
+ Ok(resp) => {
212
+ if resp.status().is_server_error() {
213
+ breaker.record_failure().await;
214
+ } else {
215
+ breaker.record_success().await;
216
+ }
217
+ Ok(resp)
218
+ }
219
+ Err(e) => {
220
+ breaker.record_failure().await;
221
+ Err(e)
222
+ }
223
+ }
224
+ }
225
+}
226
+
227
+fn circuit_open_response() -> Response<ResponseBody> {
228
+ let body = r#"{"error":"service unavailable — circuit breaker open"}"#;
229
+ let mut resp = json_text_response(StatusCode::SERVICE_UNAVAILABLE, body);
230
+ resp.headers_mut()
231
+ .insert("retry-after", "30".parse().unwrap());
232
+ resp
233
+}
234
+
235
+// ---------------------------------------------------------------------------
236
+// Timeout Middleware
237
+// ---------------------------------------------------------------------------
238
+
239
+#[derive(Clone)]
240
+pub struct TimeoutMiddleware {
241
+ duration: Duration,
242
+}
243
+
244
+impl TimeoutMiddleware {
245
+ pub fn new(duration: Duration) -> Self {
246
+ Self { duration }
247
+ }
248
+}
249
+
250
+#[async_trait]
251
+impl<B: Send + 'static> Middleware<B> for TimeoutMiddleware {
252
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
253
+ match tokio::time::timeout(self.duration, next.run(req)).await {
254
+ Ok(result) => result,
255
+ Err(_) => Ok(timeout_response(self.duration)),
256
+ }
257
+ }
258
+}
259
+
260
+fn timeout_response(timeout: Duration) -> Response<ResponseBody> {
261
+ let body = format!(
262
+ r#"{{"error":"request timed out after {}ms"}}"#,
263
+ timeout.as_millis()
264
+ );
265
+ json_text_response(StatusCode::GATEWAY_TIMEOUT, &body)
266
+}
267
+
268
+// ---------------------------------------------------------------------------
269
+// Retry with Backoff
270
+// ---------------------------------------------------------------------------
271
+
272
+#[derive(Debug, Clone)]
273
+pub struct RetryPolicy {
274
+ pub max_retries: u32,
275
+ pub base_delay: Duration,
276
+ pub max_delay: Duration,
277
+ pub jitter: bool,
278
+}
279
+
280
+impl Default for RetryPolicy {
281
+ fn default() -> Self {
282
+ Self {
283
+ max_retries: 3,
284
+ base_delay: Duration::from_millis(100),
285
+ max_delay: Duration::from_secs(10),
286
+ jitter: true,
287
+ }
288
+ }
289
+}
290
+
291
+impl RetryPolicy {
292
+ pub fn delay(&self, attempt: u32) -> Duration {
293
+ let exp = 2u32.saturating_pow(attempt);
294
+ let base_ms = self.base_delay.as_millis() as u64;
295
+ let delay_ms = base_ms.saturating_mul(exp as u64);
296
+ let capped = delay_ms.min(self.max_delay.as_millis() as u64);
297
+ if self.jitter {
298
+ let jitter_range = capped / 5;
299
+ let jitter = if jitter_range > 0 {
300
+ rand::random::<u64>() % jitter_range
301
+ } else {
302
+ 0
303
+ };
304
+ Duration::from_millis(capped + jitter)
305
+ } else {
306
+ Duration::from_millis(capped)
307
+ }
308
+ }
309
+
310
+ pub fn retryable(&self, status: StatusCode) -> bool {
311
+ status.is_server_error()
312
+ }
313
+}
314
+
315
+// ---------------------------------------------------------------------------
316
+// Helpers
317
+// ---------------------------------------------------------------------------
318
+// Bulkhead
319
+// ---------------------------------------------------------------------------
320
+
321
+#[derive(Clone)]
322
+pub struct BulkheadConfig {
323
+ pub max_concurrent: u32,
324
+ pub max_wait: Duration,
325
+}
326
+
327
+impl Default for BulkheadConfig {
328
+ fn default() -> Self {
329
+ Self {
330
+ max_concurrent: 10,
331
+ max_wait: Duration::from_secs(1),
332
+ }
333
+ }
334
+}
335
+
336
+#[derive(Clone)]
337
+pub struct Bulkhead {
338
+ semaphore: Arc<Semaphore>,
339
+ config: BulkheadConfig,
340
+}
341
+
342
+impl Bulkhead {
343
+ pub fn new(config: BulkheadConfig) -> Self {
344
+ Self {
345
+ semaphore: Arc::new(Semaphore::new(config.max_concurrent as usize)),
346
+ config,
347
+ }
348
+ }
349
+}
350
+
351
+pub struct BulkheadMiddleware {
352
+ bulkhead: Bulkhead,
353
+}
354
+
355
+impl BulkheadMiddleware {
356
+ pub fn new(bulkhead: Bulkhead) -> Self {
357
+ Self { bulkhead }
358
+ }
359
+}
360
+
361
+#[async_trait]
362
+impl<B: Send + 'static> Middleware<B> for BulkheadMiddleware {
363
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
364
+ let permit = tokio::time::timeout(
365
+ self.bulkhead.config.max_wait,
366
+ self.bulkhead.semaphore.acquire(),
367
+ )
368
+ .await;
369
+
370
+ match permit {
371
+ Ok(Ok(permit)) => {
372
+ let result = next.run(req).await;
373
+ drop(permit);
374
+ result
375
+ }
376
+ _ => Ok(json_text_response(
377
+ StatusCode::SERVICE_UNAVAILABLE,
378
+ r#"{"error":"too many concurrent requests — bulkhead limit reached"}"#,
379
+ )),
380
+ }
381
+ }
382
+}
383
+
384
+// ---------------------------------------------------------------------------
385
+// Fallback Middleware
386
+// ---------------------------------------------------------------------------
387
+
388
+use std::collections::HashMap;
389
+
390
+#[derive(Clone)]
391
+pub struct FallbackPolicy {
392
+ fallbacks: Arc<HashMap<String, FallbackEntry>>,
393
+ default_fallback: Option<FallbackEntry>,
394
+}
395
+
396
+#[derive(Clone)]
397
+pub struct FallbackEntry {
398
+ pub status: StatusCode,
399
+ pub body: String,
400
+ pub content_type: String,
401
+}
402
+
403
+impl FallbackPolicy {
404
+ pub fn new() -> Self {
405
+ Self {
406
+ fallbacks: Arc::new(HashMap::new()),
407
+ default_fallback: None,
408
+ }
409
+ }
410
+
411
+ pub fn add(mut self, path_prefix: &str, entry: FallbackEntry) -> Self {
412
+ Arc::make_mut(&mut self.fallbacks).insert(path_prefix.to_string(), entry);
413
+ self
414
+ }
415
+
416
+ pub fn with_default(mut self, entry: FallbackEntry) -> Self {
417
+ self.default_fallback = Some(entry);
418
+ self
419
+ }
420
+
421
+ fn get(&self, path: &str) -> Option<&FallbackEntry> {
422
+ for (prefix, entry) in self.fallbacks.iter() {
423
+ if path.starts_with(prefix) {
424
+ return Some(entry);
425
+ }
426
+ }
427
+ self.default_fallback.as_ref()
428
+ }
429
+}
430
+
431
+impl Default for FallbackPolicy {
432
+ fn default() -> Self {
433
+ Self::new()
434
+ }
435
+}
436
+
437
+pub struct FallbackMiddleware {
438
+ policy: FallbackPolicy,
439
+}
440
+
441
+impl FallbackMiddleware {
442
+ pub fn new(policy: FallbackPolicy) -> Self {
443
+ Self { policy }
444
+ }
445
+}
446
+
447
+#[async_trait]
448
+impl<B: Send + 'static> Middleware<B> for FallbackMiddleware {
449
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
450
+ let path = req.uri().path().to_string();
451
+
452
+ match next.run(req).await {
453
+ Ok(resp) => {
454
+ if resp.status().is_server_error() {
455
+ if let Some(fb) = self.policy.get(&path) {
456
+ return Ok(fallback_response(fb));
457
+ }
458
+ }
459
+ Ok(resp)
460
+ }
461
+ Err(_e) => {
462
+ if let Some(fb) = self.policy.get(&path) {
463
+ Ok(fallback_response(fb))
464
+ } else {
465
+ Ok(json_text_response(
466
+ StatusCode::INTERNAL_SERVER_ERROR,
467
+ r#"{"error":"internal server error"}"#,
468
+ ))
469
+ }
470
+ }
471
+ }
472
+ }
473
+}
474
+
475
+fn fallback_response(fb: &FallbackEntry) -> Response<ResponseBody> {
476
+ let body = fb.body.clone();
477
+ let content_type = fb.content_type.clone();
478
+ Response::builder()
479
+ .status(fb.status)
480
+ .header("content-type", content_type)
481
+ .header("content-length", body.len().to_string())
482
+ .body(http_body_util::combinators::UnsyncBoxBody::new(
483
+ http_body_util::Full::new(Bytes::from(body))
484
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
485
+ ))
486
+ .unwrap()
487
+}
488
+
489
+// ---------------------------------------------------------------------------
490
+// Chaos Middleware (testing only — injects failures)
491
+// ---------------------------------------------------------------------------
492
+
493
+#[derive(Debug, Clone)]
494
+pub struct ChaosConfig {
495
+ pub enabled: bool,
496
+ pub latency_p: f64,
497
+ pub latency_min_ms: u64,
498
+ pub latency_max_ms: u64,
499
+ pub error_p: f64,
500
+ pub error_status: StatusCode,
501
+}
502
+
503
+impl Default for ChaosConfig {
504
+ fn default() -> Self {
505
+ Self {
506
+ enabled: false,
507
+ latency_p: 0.0,
508
+ latency_min_ms: 100,
509
+ latency_max_ms: 500,
510
+ error_p: 0.0,
511
+ error_status: StatusCode::INTERNAL_SERVER_ERROR,
512
+ }
513
+ }
514
+}
515
+
516
+pub struct ChaosMiddleware {
517
+ config: ChaosConfig,
518
+}
519
+
520
+impl ChaosMiddleware {
521
+ pub fn new(config: ChaosConfig) -> Self {
522
+ Self { config }
523
+ }
524
+}
525
+
526
+#[async_trait]
527
+impl<B: Send + 'static> Middleware<B> for ChaosMiddleware {
528
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
529
+ if !self.config.enabled {
530
+ return next.run(req).await;
531
+ }
532
+
533
+ if self.config.error_p > 0.0 {
534
+ let roll: f64 = rand::random();
535
+ if roll < self.config.error_p {
536
+ let body = format!(
537
+ r#"{{"error":"chaos injected failure","status":{}}}"#,
538
+ self.config.error_status.as_u16()
539
+ );
540
+ return Ok(json_text_response(self.config.error_status, &body));
541
+ }
542
+ }
543
+
544
+ if self.config.latency_p > 0.0 {
545
+ let roll: f64 = rand::random();
546
+ if roll < self.config.latency_p {
547
+ let delay_ms = self.config.latency_min_ms
548
+ + (rand::random::<u64>()
549
+ % (self.config.latency_max_ms - self.config.latency_min_ms + 1));
550
+ tokio::time::sleep(Duration::from_millis(delay_ms)).await;
551
+ }
552
+ }
553
+
554
+ next.run(req).await
555
+ }
556
+}
557
+
558
+// ---------------------------------------------------------------------------
559
+// Helpers
560
+// ---------------------------------------------------------------------------
561
+
562
+fn json_text_response(status: StatusCode, body: &str) -> Response<ResponseBody> {
563
+ Response::builder()
564
+ .status(status)
565
+ .header("content-type", "application/json")
566
+ .header("content-length", body.len().to_string())
567
+ .body(http_body_util::combinators::UnsyncBoxBody::new(
568
+ http_body_util::Full::new(Bytes::from(body.to_string()))
569
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
570
+ ))
571
+ .unwrap()
572
+}
573
+
574
+// ---------------------------------------------------------------------------
575
+// Tests
576
+// ---------------------------------------------------------------------------
577
+
578
+#[cfg(test)]
579
+mod tests {
580
+ use super::*;
581
+ use crate::json_response;
582
+ use crate::middleware::HandlerFn;
583
+ use std::sync::Arc;
584
+
585
+ type TestBody = http_body_util::Full<Bytes>;
586
+
587
+ fn ok_handler() -> HandlerFn<TestBody> {
588
+ Arc::new(|_req: Request<TestBody>| {
589
+ Box::pin(async { Ok(json_response(StatusCode::OK, r#"{"ok":true}"#)) })
590
+ })
591
+ }
592
+
593
+ fn error_handler() -> HandlerFn<TestBody> {
594
+ Arc::new(|_req: Request<TestBody>| {
595
+ Box::pin(async { Err(anyhow::anyhow!("handler error")) })
596
+ })
597
+ }
598
+
599
+ fn five_hundred_handler() -> HandlerFn<TestBody> {
600
+ Arc::new(|_req: Request<TestBody>| {
601
+ Box::pin(async {
602
+ Ok(json_response(
603
+ StatusCode::INTERNAL_SERVER_ERROR,
604
+ r#"{"error":"server error"}"#,
605
+ ))
606
+ })
607
+ })
608
+ }
609
+
610
+ fn test_req(method: hyper::Method, uri: &str) -> Request<TestBody> {
611
+ Request::builder()
612
+ .method(method)
613
+ .uri(uri)
614
+ .body(TestBody::new(Bytes::new()))
615
+ .unwrap()
616
+ }
617
+
618
+ // -----------------------------------------------------------------------
619
+ // Circuit Breaker tests
620
+ // -----------------------------------------------------------------------
621
+
622
+ #[tokio::test]
623
+ async fn test_circuit_breaker_passes_healthy_requests() {
624
+ let breaker = CircuitBreaker::new(CircuitBreakerConfig::default());
625
+ let mw = CircuitBreakerMiddleware::new(breaker);
626
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
627
+ chain.add(mw);
628
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
629
+ assert_eq!(resp.status(), StatusCode::OK);
630
+ }
631
+
632
+ #[tokio::test]
633
+ async fn test_circuit_breaker_opens_on_too_many_failures() {
634
+ let config = CircuitBreakerConfig {
635
+ failure_threshold: 3,
636
+ success_threshold: 2,
637
+ open_timeout: Duration::from_secs(60),
638
+ half_open_max_requests: 2,
639
+ };
640
+ let breaker = CircuitBreaker::new(config);
641
+ let mw = CircuitBreakerMiddleware::new(breaker.clone());
642
+ let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
643
+ chain.add(mw);
644
+
645
+ // 3 failures to open
646
+ for _ in 0..3 {
647
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
648
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
649
+ }
650
+
651
+ // Now circuit is open, should get 503
652
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
653
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
654
+ }
655
+
656
+ #[tokio::test]
657
+ async fn test_circuit_breaker_recovery() {
658
+ let config = CircuitBreakerConfig {
659
+ failure_threshold: 2,
660
+ success_threshold: 2,
661
+ open_timeout: Duration::from_millis(50),
662
+ half_open_max_requests: 3,
663
+ };
664
+ let breaker = CircuitBreaker::new(config);
665
+
666
+ // We need a setup where we can flip between failing and passing.
667
+ // Use a shared failure flag.
668
+ let failing = Arc::new(std::sync::atomic::AtomicBool::new(true));
669
+ let failing_clone = failing.clone();
670
+
671
+ let dynamic_handler: HandlerFn<TestBody> = Arc::new(move |_req: Request<TestBody>| {
672
+ let f = failing_clone.clone();
673
+ Box::pin(async move {
674
+ if f.load(std::sync::atomic::Ordering::Relaxed) {
675
+ Ok(json_response(
676
+ StatusCode::INTERNAL_SERVER_ERROR,
677
+ r#"{"error":"server error"}"#,
678
+ ))
679
+ } else {
680
+ Ok(json_response(StatusCode::OK, r#"{"ok":true}"#))
681
+ }
682
+ })
683
+ });
684
+
685
+ let mut chain = crate::middleware::MiddlewareChain::new(dynamic_handler);
686
+ chain.add(CircuitBreakerMiddleware::new(breaker.clone()));
687
+
688
+ // 2 failures to open
689
+ for _ in 0..2 {
690
+ chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
691
+ }
692
+
693
+ // Should be open
694
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
695
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
696
+
697
+ // Wait for timeout
698
+ tokio::time::sleep(Duration::from_millis(60)).await;
699
+
700
+ // Handler now succeeds
701
+ failing.store(false, std::sync::atomic::Ordering::Relaxed);
702
+
703
+ // Should be half-open now, allow request
704
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
705
+ assert_eq!(resp.status(), StatusCode::OK);
706
+
707
+ // Second success to close
708
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
709
+ assert_eq!(resp.status(), StatusCode::OK);
710
+
711
+ // Now circuit is closed (success threshold reached)
712
+ // If we fail again, it should reset failure count (closed state resets on success)
713
+ failing.store(true, std::sync::atomic::Ordering::Relaxed);
714
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
715
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
716
+ // One more failure (first one should have reset failure count to 1)
717
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
718
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
719
+ // Should have hit threshold again (2 failures in a row)
720
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
721
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
722
+ }
723
+
724
+ #[tokio::test]
725
+ async fn test_circuit_breaker_handles_errors() {
726
+ let config = CircuitBreakerConfig {
727
+ failure_threshold: 2,
728
+ success_threshold: 1,
729
+ open_timeout: Duration::from_secs(60),
730
+ half_open_max_requests: 1,
731
+ };
732
+ let breaker = CircuitBreaker::new(config);
733
+ let mw = CircuitBreakerMiddleware::new(breaker.clone());
734
+ let mut chain = crate::middleware::MiddlewareChain::new(error_handler());
735
+ chain.add(mw);
736
+
737
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await;
738
+ assert!(resp.is_err());
739
+
740
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await;
741
+ assert!(resp.is_err());
742
+
743
+ // Circuit open — should get 503 even though handler would return Err
744
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
745
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
746
+ }
747
+
748
+ // -----------------------------------------------------------------------
749
+ // Timeout tests
750
+ // -----------------------------------------------------------------------
751
+
752
+ #[tokio::test]
753
+ async fn test_timeout_passes_fast_requests() {
754
+ let mw = TimeoutMiddleware::new(Duration::from_secs(5));
755
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
756
+ chain.add(mw);
757
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
758
+ assert_eq!(resp.status(), StatusCode::OK);
759
+ }
760
+
761
+ #[tokio::test]
762
+ async fn test_timeout_returns_504() {
763
+ let slow_handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| {
764
+ Box::pin(async {
765
+ tokio::time::sleep(Duration::from_secs(10)).await;
766
+ Ok(json_response(StatusCode::OK, r#"{"ok":true}"#))
767
+ })
768
+ });
769
+
770
+ let mw = TimeoutMiddleware::new(Duration::from_millis(10));
771
+ let mut chain = crate::middleware::MiddlewareChain::new(slow_handler);
772
+ chain.add(mw);
773
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
774
+ assert_eq!(resp.status(), StatusCode::GATEWAY_TIMEOUT);
775
+ }
776
+
777
+ // -----------------------------------------------------------------------
778
+ // Retry policy tests
779
+ // -----------------------------------------------------------------------
780
+
781
+ #[tokio::test]
782
+ async fn test_retry_policy_delay() {
783
+ let policy = RetryPolicy {
784
+ max_retries: 3,
785
+ base_delay: Duration::from_millis(10),
786
+ max_delay: Duration::from_secs(10),
787
+ jitter: false,
788
+ };
789
+ assert_eq!(policy.delay(0).as_millis(), 10);
790
+ assert_eq!(policy.delay(1).as_millis(), 20);
791
+ assert_eq!(policy.delay(2).as_millis(), 40);
792
+ }
793
+
794
+ #[tokio::test]
795
+ async fn test_retry_policy_retryable() {
796
+ let policy = RetryPolicy::default();
797
+ assert!(policy.retryable(StatusCode::INTERNAL_SERVER_ERROR));
798
+ assert!(policy.retryable(StatusCode::BAD_GATEWAY));
799
+ assert!(!policy.retryable(StatusCode::BAD_REQUEST));
800
+ assert!(!policy.retryable(StatusCode::OK));
801
+ }
802
+
803
+ #[tokio::test]
804
+ async fn test_retry_policy_delay_with_jitter() {
805
+ let policy = RetryPolicy {
806
+ max_retries: 2,
807
+ base_delay: Duration::from_millis(100),
808
+ max_delay: Duration::from_secs(10),
809
+ jitter: true,
810
+ };
811
+ // jitter adds 0-20% of delay, so delay should be >= base and <= base*1.2
812
+ let d = policy.delay(0);
813
+ assert!(d >= Duration::from_millis(100));
814
+ assert!(d <= Duration::from_millis(120));
815
+ }
816
+
817
+ // -----------------------------------------------------------------------
818
+ // Bulkhead tests
819
+ // -----------------------------------------------------------------------
820
+
821
+ #[tokio::test]
822
+ async fn test_bulkhead_passes_normal_requests() {
823
+ let config = BulkheadConfig {
824
+ max_concurrent: 10,
825
+ max_wait: Duration::from_secs(1),
826
+ };
827
+ let bulkhead = Bulkhead::new(config);
828
+ let mw = BulkheadMiddleware::new(bulkhead);
829
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
830
+ chain.add(mw);
831
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
832
+ assert_eq!(resp.status(), StatusCode::OK);
833
+ }
834
+
835
+ #[tokio::test]
836
+ async fn test_bulkhead_rejects_when_full() {
837
+ let config = BulkheadConfig {
838
+ max_concurrent: 1,
839
+ max_wait: Duration::from_millis(50),
840
+ };
841
+ let bulkhead = Bulkhead::new(config);
842
+ let mw = BulkheadMiddleware::new(bulkhead);
843
+
844
+ let slow_handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| {
845
+ Box::pin(async {
846
+ tokio::time::sleep(Duration::from_millis(200)).await;
847
+ Ok(json_response(StatusCode::OK, r#"{"ok":true}"#))
848
+ })
849
+ });
850
+
851
+ let mut chain = crate::middleware::MiddlewareChain::new(slow_handler);
852
+ chain.add(mw);
853
+
854
+ let chain1 = chain.clone();
855
+ let req1 = test_req(hyper::Method::GET, "/");
856
+ let h1 = tokio::spawn(async move { chain1.run(req1).await });
857
+
858
+ tokio::time::sleep(Duration::from_millis(20)).await;
859
+
860
+ let req2 = test_req(hyper::Method::GET, "/");
861
+ let resp = chain.run(req2).await.unwrap();
862
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
863
+
864
+ h1.await.unwrap().unwrap();
865
+ }
866
+
867
+ // -----------------------------------------------------------------------
868
+ // Fallback tests
869
+ // -----------------------------------------------------------------------
870
+
871
+ #[tokio::test]
872
+ async fn test_fallback_passes_successful_response() {
873
+ let policy = FallbackPolicy::new().add(
874
+ "/",
875
+ FallbackEntry {
876
+ status: StatusCode::OK,
877
+ body: r#"{"cached":true}"#.to_string(),
878
+ content_type: "application/json".to_string(),
879
+ },
880
+ );
881
+ let mw = FallbackMiddleware::new(policy);
882
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
883
+ chain.add(mw);
884
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
885
+ assert_eq!(resp.status(), StatusCode::OK);
886
+ }
887
+
888
+ #[tokio::test]
889
+ async fn test_fallback_serves_on_5xx() {
890
+ let policy = FallbackPolicy::new().add(
891
+ "/",
892
+ FallbackEntry {
893
+ status: StatusCode::OK,
894
+ body: r#"{"cached":true}"#.to_string(),
895
+ content_type: "application/json".to_string(),
896
+ },
897
+ );
898
+ let mw = FallbackMiddleware::new(policy);
899
+ let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
900
+ chain.add(mw);
901
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
902
+ assert_eq!(resp.status(), StatusCode::OK);
903
+ }
904
+
905
+ #[tokio::test]
906
+ async fn test_fallback_serves_on_handler_error() {
907
+ let policy = FallbackPolicy::new().add(
908
+ "/",
909
+ FallbackEntry {
910
+ status: StatusCode::OK,
911
+ body: r#"{"cached":true}"#.to_string(),
912
+ content_type: "application/json".to_string(),
913
+ },
914
+ );
915
+ let mw = FallbackMiddleware::new(policy);
916
+ let mut chain = crate::middleware::MiddlewareChain::new(error_handler());
917
+ chain.add(mw);
918
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
919
+ assert_eq!(resp.status(), StatusCode::OK);
920
+ }
921
+
922
+ #[tokio::test]
923
+ async fn test_fallback_path_matching() {
924
+ let policy = FallbackPolicy::new()
925
+ .add(
926
+ "/api",
927
+ FallbackEntry {
928
+ status: StatusCode::OK,
929
+ body: r#"{"cached":"api"}"#.to_string(),
930
+ content_type: "application/json".to_string(),
931
+ },
932
+ )
933
+ .add(
934
+ "/users",
935
+ FallbackEntry {
936
+ status: StatusCode::OK,
937
+ body: r#"{"cached":"users"}"#.to_string(),
938
+ content_type: "application/json".to_string(),
939
+ },
940
+ );
941
+ let mw = FallbackMiddleware::new(policy);
942
+ let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
943
+ chain.add(mw);
944
+
945
+ let resp = chain
946
+ .run(test_req(hyper::Method::GET, "/api/data"))
947
+ .await
948
+ .unwrap();
949
+ assert!(
950
+ String::from_utf8_lossy(&resp.into_body().collect().await.unwrap().to_bytes())
951
+ .contains(r#""api"#)
952
+ );
953
+
954
+ let resp = chain
955
+ .run(test_req(hyper::Method::GET, "/users/123"))
956
+ .await
957
+ .unwrap();
958
+ assert!(
959
+ String::from_utf8_lossy(&resp.into_body().collect().await.unwrap().to_bytes())
960
+ .contains(r#""users"#)
961
+ );
962
+ }
963
+
964
+ #[tokio::test]
965
+ async fn test_fallback_no_match_returns_original() {
966
+ let policy = FallbackPolicy::default();
967
+ let mw = FallbackMiddleware::new(policy);
968
+ let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
969
+ chain.add(mw);
970
+ let resp = chain
971
+ .run(test_req(hyper::Method::GET, "/no-fallback"))
972
+ .await
973
+ .unwrap();
974
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
975
+ }
976
+
977
+ #[tokio::test]
978
+ async fn test_fallback_default_on_error() {
979
+ let policy = FallbackPolicy::new().with_default(FallbackEntry {
980
+ status: StatusCode::OK,
981
+ body: r#"{"cached":"default"}"#.to_string(),
982
+ content_type: "application/json".to_string(),
983
+ });
984
+ let mw = FallbackMiddleware::new(policy);
985
+ let mut chain = crate::middleware::MiddlewareChain::new(error_handler());
986
+ chain.add(mw);
987
+ let resp = chain
988
+ .run(test_req(hyper::Method::GET, "/anything"))
989
+ .await
990
+ .unwrap();
991
+ assert_eq!(resp.status(), StatusCode::OK);
992
+ }
993
+
994
+ // -----------------------------------------------------------------------
995
+ // Chaos middleware tests
996
+ // -----------------------------------------------------------------------
997
+
998
+ #[tokio::test]
999
+ async fn test_chaos_disabled_passes_through() {
1000
+ let config = ChaosConfig {
1001
+ enabled: false,
1002
+ error_p: 1.0,
1003
+ ..Default::default()
1004
+ };
1005
+ let mw = ChaosMiddleware::new(config);
1006
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1007
+ chain.add(mw);
1008
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1009
+ assert_eq!(resp.status(), StatusCode::OK);
1010
+ }
1011
+
1012
+ #[tokio::test]
1013
+ async fn test_chaos_injects_errors() {
1014
+ let config = ChaosConfig {
1015
+ enabled: true,
1016
+ error_p: 1.0,
1017
+ error_status: StatusCode::INTERNAL_SERVER_ERROR,
1018
+ ..Default::default()
1019
+ };
1020
+ let mw = ChaosMiddleware::new(config);
1021
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1022
+ chain.add(mw);
1023
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1024
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1025
+ }
1026
+
1027
+ #[tokio::test]
1028
+ async fn test_chaos_injects_latency() {
1029
+ let config = ChaosConfig {
1030
+ enabled: true,
1031
+ latency_p: 1.0,
1032
+ latency_min_ms: 20,
1033
+ latency_max_ms: 30,
1034
+ ..Default::default()
1035
+ };
1036
+ let mw = ChaosMiddleware::new(config);
1037
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1038
+ chain.add(mw);
1039
+ let start = std::time::Instant::now();
1040
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1041
+ let elapsed = start.elapsed();
1042
+ assert_eq!(resp.status(), StatusCode::OK);
1043
+ assert!(elapsed >= Duration::from_millis(20));
1044
+ }
1045
+
1046
+ // -----------------------------------------------------------------------
1047
+ // Combined resilience tests
1048
+ // -----------------------------------------------------------------------
1049
+
1050
+ #[tokio::test]
1051
+ async fn test_timeout_and_bulkhead_together() {
1052
+ let timeout_mw = TimeoutMiddleware::new(Duration::from_secs(5));
1053
+ let bulkhead = Bulkhead::new(BulkheadConfig {
1054
+ max_concurrent: 5,
1055
+ ..Default::default()
1056
+ });
1057
+ let bulkhead_mw = BulkheadMiddleware::new(bulkhead);
1058
+
1059
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1060
+ chain.add(timeout_mw);
1061
+ chain.add(bulkhead_mw);
1062
+
1063
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1064
+ assert_eq!(resp.status(), StatusCode::OK);
1065
+ }
1066
+
1067
+ #[tokio::test]
1068
+ async fn test_circuit_breaker_and_fallback_together() {
1069
+ let config = CircuitBreakerConfig {
1070
+ failure_threshold: 2,
1071
+ success_threshold: 1,
1072
+ open_timeout: Duration::from_secs(60),
1073
+ half_open_max_requests: 1,
1074
+ };
1075
+ let breaker = CircuitBreaker::new(config);
1076
+ let cb_mw = CircuitBreakerMiddleware::new(breaker);
1077
+
1078
+ let fallback = FallbackPolicy::new().with_default(FallbackEntry {
1079
+ status: StatusCode::OK,
1080
+ body: r#"{"cached":"fallback"}"#.to_string(),
1081
+ content_type: "application/json".to_string(),
1082
+ });
1083
+ let fb_mw = FallbackMiddleware::new(fallback);
1084
+
1085
+ let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
1086
+ chain.add(fb_mw);
1087
+ chain.add(cb_mw);
1088
+
1089
+ for _ in 0..2 {
1090
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1091
+ assert_eq!(resp.status(), StatusCode::OK);
1092
+ }
1093
+
1094
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1095
+ assert_eq!(resp.status(), StatusCode::OK);
1096
+ assert!(
1097
+ String::from_utf8_lossy(&resp.into_body().collect().await.unwrap().to_bytes())
1098
+ .contains(r#""fallback"#)
1099
+ );
1100
+ }
1101
+
1102
+ // -----------------------------------------------------------------------
1103
+ // Additional Circuit Breaker edge case tests
1104
+ // -----------------------------------------------------------------------
1105
+
1106
+ #[tokio::test]
1107
+ async fn test_circuit_breaker_half_open_failure_reopens() {
1108
+ let config = CircuitBreakerConfig {
1109
+ failure_threshold: 1,
1110
+ success_threshold: 1,
1111
+ open_timeout: Duration::from_millis(20),
1112
+ half_open_max_requests: 1,
1113
+ };
1114
+ let breaker = CircuitBreaker::new(config);
1115
+
1116
+ // Force open
1117
+ let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
1118
+ chain.add(CircuitBreakerMiddleware::new(breaker.clone()));
1119
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1120
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1121
+ // Now open
1122
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1123
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1124
+
1125
+ // Wait for half-open
1126
+ tokio::time::sleep(Duration::from_millis(25)).await;
1127
+
1128
+ // In half-open, handler still fails → goes back to open
1129
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1130
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1131
+
1132
+ // Should be open again
1133
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1134
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1135
+ }
1136
+
1137
+ #[tokio::test]
1138
+ async fn test_circuit_breaker_half_open_limited_probe() {
1139
+ let config = CircuitBreakerConfig {
1140
+ failure_threshold: 1,
1141
+ success_threshold: 5,
1142
+ open_timeout: Duration::from_millis(20),
1143
+ half_open_max_requests: 2,
1144
+ };
1145
+ let breaker = CircuitBreaker::new(config);
1146
+
1147
+ // Dynamic handler: fails on first call, succeeds on subsequent
1148
+ let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
1149
+ let cc = call_count.clone();
1150
+ let handler: HandlerFn<TestBody> = Arc::new(move |_req: Request<TestBody>| {
1151
+ let c = cc.clone();
1152
+ Box::pin(async move {
1153
+ let n = c.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1154
+ if n == 0 {
1155
+ Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, "err"))
1156
+ } else {
1157
+ Ok(json_response(StatusCode::OK, "ok"))
1158
+ }
1159
+ })
1160
+ });
1161
+ let mut chain = crate::middleware::MiddlewareChain::new(handler);
1162
+ chain.add(CircuitBreakerMiddleware::new(breaker.clone()));
1163
+
1164
+ // First call: failure → opens breaker
1165
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1166
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1167
+ // Already open → 503
1168
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1169
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1170
+
1171
+ // Wait for half-open
1172
+ tokio::time::sleep(Duration::from_millis(25)).await;
1173
+
1174
+ // 1st request transitions Open→HalfOpen (doesn't count against limit)
1175
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1176
+ assert_eq!(resp.status(), StatusCode::OK);
1177
+
1178
+ // 2nd request: HalfOpen, half_open_used=1
1179
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1180
+ assert_eq!(resp.status(), StatusCode::OK);
1181
+
1182
+ // 3rd request: HalfOpen, half_open_used=2
1183
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1184
+ assert_eq!(resp.status(), StatusCode::OK);
1185
+
1186
+ // 4th request: half_open_used=2 >= half_open_allowed=2 → blocked
1187
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1188
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1189
+ }
1190
+
1191
+ #[tokio::test]
1192
+ async fn test_circuit_breaker_zero_threshold() {
1193
+ let config = CircuitBreakerConfig {
1194
+ failure_threshold: 0,
1195
+ success_threshold: 1,
1196
+ open_timeout: Duration::from_secs(60),
1197
+ half_open_max_requests: 1,
1198
+ };
1199
+ let breaker = CircuitBreaker::new(config);
1200
+ let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
1201
+ chain.add(CircuitBreakerMiddleware::new(breaker));
1202
+ // With 0 threshold, even a single failure may cause unexpected behavior.
1203
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1204
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1205
+ }
1206
+
1207
+ #[tokio::test]
1208
+ async fn test_circuit_breaker_success_resets_failure_count() {
1209
+ let config = CircuitBreakerConfig {
1210
+ failure_threshold: 3,
1211
+ success_threshold: 1,
1212
+ open_timeout: Duration::from_secs(60),
1213
+ half_open_max_requests: 1,
1214
+ };
1215
+ let breaker = CircuitBreaker::new(config);
1216
+ let failing = Arc::new(std::sync::atomic::AtomicBool::new(true));
1217
+ let f = failing.clone();
1218
+ let handler: HandlerFn<TestBody> = Arc::new(move |req: Request<TestBody>| {
1219
+ let _ = req;
1220
+ let g = f.clone();
1221
+ Box::pin(async move {
1222
+ if g.load(std::sync::atomic::Ordering::Relaxed) {
1223
+ Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, "err"))
1224
+ } else {
1225
+ Ok(json_response(StatusCode::OK, "ok"))
1226
+ }
1227
+ })
1228
+ });
1229
+ let mut chain = crate::middleware::MiddlewareChain::new(handler);
1230
+ chain.add(CircuitBreakerMiddleware::new(breaker.clone()));
1231
+
1232
+ // 2 failures
1233
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1234
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1235
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1236
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1237
+
1238
+ // Success resets failure count
1239
+ failing.store(false, std::sync::atomic::Ordering::Relaxed);
1240
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1241
+ assert_eq!(resp.status(), StatusCode::OK);
1242
+
1243
+ // More failures should need 3 again (failure count was reset)
1244
+ failing.store(true, std::sync::atomic::Ordering::Relaxed);
1245
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1246
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1247
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1248
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1249
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1250
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1251
+ // Now open
1252
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1253
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1254
+ }
1255
+
1256
+ // -----------------------------------------------------------------------
1257
+ // Additional Timeout edge case tests
1258
+ // -----------------------------------------------------------------------
1259
+
1260
+ #[tokio::test]
1261
+ async fn test_timeout_zero_duration() {
1262
+ let slow_handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| {
1263
+ Box::pin(async {
1264
+ tokio::time::sleep(Duration::from_millis(5)).await;
1265
+ Ok(json_response(StatusCode::OK, "ok"))
1266
+ })
1267
+ });
1268
+ let mw = TimeoutMiddleware::new(Duration::from_millis(0));
1269
+ let mut chain = crate::middleware::MiddlewareChain::new(slow_handler);
1270
+ chain.add(mw);
1271
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1272
+ assert_eq!(resp.status(), StatusCode::GATEWAY_TIMEOUT);
1273
+ }
1274
+
1275
+ #[tokio::test]
1276
+ async fn test_timeout_very_long() {
1277
+ let mw = TimeoutMiddleware::new(Duration::from_secs(60));
1278
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1279
+ chain.add(mw);
1280
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1281
+ assert_eq!(resp.status(), StatusCode::OK);
1282
+ }
1283
+
1284
+ // -----------------------------------------------------------------------
1285
+ // Additional Retry Policy edge case tests
1286
+ // -----------------------------------------------------------------------
1287
+
1288
+ #[test]
1289
+ fn test_retry_policy_max_delay_cap() {
1290
+ let policy = RetryPolicy {
1291
+ max_retries: 10,
1292
+ base_delay: Duration::from_secs(10),
1293
+ max_delay: Duration::from_secs(5),
1294
+ jitter: false,
1295
+ };
1296
+ // Even at attempt 0, delay is capped at max_delay
1297
+ assert_eq!(policy.delay(0), Duration::from_secs(5));
1298
+ assert_eq!(policy.delay(10), Duration::from_secs(5));
1299
+ }
1300
+
1301
+ #[test]
1302
+ fn test_retry_policy_zero_base_delay() {
1303
+ let policy = RetryPolicy {
1304
+ max_retries: 3,
1305
+ base_delay: Duration::from_millis(0),
1306
+ max_delay: Duration::from_secs(10),
1307
+ jitter: false,
1308
+ };
1309
+ assert_eq!(policy.delay(0), Duration::from_millis(0));
1310
+ assert_eq!(policy.delay(1), Duration::from_millis(0));
1311
+ }
1312
+
1313
+ #[test]
1314
+ fn test_retry_policy_non_retryable_statuses() {
1315
+ let policy = RetryPolicy::default();
1316
+ assert!(!policy.retryable(StatusCode::CONTINUE));
1317
+ assert!(!policy.retryable(StatusCode::SWITCHING_PROTOCOLS));
1318
+ assert!(!policy.retryable(StatusCode::FOUND));
1319
+ assert!(!policy.retryable(StatusCode::NOT_MODIFIED));
1320
+ assert!(!policy.retryable(StatusCode::TEMPORARY_REDIRECT));
1321
+ assert!(!policy.retryable(StatusCode::PERMANENT_REDIRECT));
1322
+ assert!(!policy.retryable(StatusCode::TOO_MANY_REQUESTS));
1323
+ assert!(!policy.retryable(StatusCode::REQUEST_TIMEOUT));
1324
+ assert!(policy.retryable(StatusCode::SERVICE_UNAVAILABLE));
1325
+ assert!(policy.retryable(StatusCode::BAD_GATEWAY));
1326
+ assert!(policy.retryable(StatusCode::GATEWAY_TIMEOUT));
1327
+ }
1328
+
1329
+ // -----------------------------------------------------------------------
1330
+ // Additional Bulkhead edge case tests
1331
+ // -----------------------------------------------------------------------
1332
+
1333
+ #[tokio::test]
1334
+ async fn test_bulkhead_zero_concurrent() {
1335
+ let config = BulkheadConfig {
1336
+ max_concurrent: 0,
1337
+ max_wait: Duration::from_millis(10),
1338
+ };
1339
+ let bulkhead = Bulkhead::new(config);
1340
+ let mw = BulkheadMiddleware::new(bulkhead);
1341
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1342
+ chain.add(mw);
1343
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1344
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1345
+ }
1346
+
1347
+ #[tokio::test]
1348
+ async fn test_bulkhead_release_on_error() {
1349
+ let config = BulkheadConfig {
1350
+ max_concurrent: 1,
1351
+ max_wait: Duration::from_millis(50),
1352
+ };
1353
+ let bulkhead = Bulkhead::new(config);
1354
+ let mw = BulkheadMiddleware::new(bulkhead);
1355
+
1356
+ let error_handler: HandlerFn<TestBody> =
1357
+ Arc::new(|_req: Request<TestBody>| Box::pin(async { Err(anyhow::anyhow!("oops")) }));
1358
+ let mut chain = crate::middleware::MiddlewareChain::new(error_handler);
1359
+ chain.add(mw);
1360
+
1361
+ // First request fails, permit is released on drop
1362
+ let chain1 = chain.clone();
1363
+ let h1 = tokio::spawn(async move { chain1.run(test_req(hyper::Method::GET, "/")).await });
1364
+ tokio::time::sleep(Duration::from_millis(20)).await;
1365
+ // Second request — permit should have been released
1366
+ let result = chain.run(test_req(hyper::Method::GET, "/")).await;
1367
+ assert!(result.is_err() || result.unwrap().status().is_success());
1368
+ // First request returned Err
1369
+ assert!(h1.await.unwrap().is_err());
1370
+ }
1371
+
1372
+ // -----------------------------------------------------------------------
1373
+ // Additional Fallback edge case tests
1374
+ // -----------------------------------------------------------------------
1375
+
1376
+ #[tokio::test]
1377
+ async fn test_fallback_empty_path_prefix() {
1378
+ let policy = FallbackPolicy::new().add(
1379
+ "",
1380
+ FallbackEntry {
1381
+ status: StatusCode::OK,
1382
+ body: r#"{"cached":"root"}"#.to_string(),
1383
+ content_type: "application/json".to_string(),
1384
+ },
1385
+ );
1386
+ let mw = FallbackMiddleware::new(policy);
1387
+ let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
1388
+ chain.add(mw);
1389
+ let resp = chain
1390
+ .run(test_req(hyper::Method::GET, "/anything"))
1391
+ .await
1392
+ .unwrap();
1393
+ assert_eq!(resp.status(), StatusCode::OK);
1394
+ }
1395
+
1396
+ #[tokio::test]
1397
+ async fn test_fallback_content_type_preserved() {
1398
+ let policy = FallbackPolicy::new().add(
1399
+ "/api",
1400
+ FallbackEntry {
1401
+ status: StatusCode::OK,
1402
+ body: "<cached>data</cached>".to_string(),
1403
+ content_type: "application/xml".to_string(),
1404
+ },
1405
+ );
1406
+ let mw = FallbackMiddleware::new(policy);
1407
+ let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
1408
+ chain.add(mw);
1409
+ let resp = chain
1410
+ .run(test_req(hyper::Method::GET, "/api/data"))
1411
+ .await
1412
+ .unwrap();
1413
+ assert_eq!(resp.status(), StatusCode::OK);
1414
+ assert_eq!(resp.headers()["content-type"], "application/xml");
1415
+ let body = resp.into_body().collect().await.unwrap().to_bytes();
1416
+ assert_eq!(&body[..], b"<cached>data</cached>");
1417
+ }
1418
+
1419
+ // -----------------------------------------------------------------------
1420
+ // Additional Chaos edge case tests
1421
+ // -----------------------------------------------------------------------
1422
+
1423
+ #[tokio::test]
1424
+ async fn test_chaos_zero_probability_with_enabled() {
1425
+ let config = ChaosConfig {
1426
+ enabled: true,
1427
+ error_p: 0.0,
1428
+ latency_p: 0.0,
1429
+ latency_min_ms: 100,
1430
+ latency_max_ms: 200,
1431
+ error_status: StatusCode::INTERNAL_SERVER_ERROR,
1432
+ };
1433
+ let mw = ChaosMiddleware::new(config);
1434
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1435
+ chain.add(mw);
1436
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1437
+ assert_eq!(resp.status(), StatusCode::OK);
1438
+ }
1439
+
1440
+ #[tokio::test]
1441
+ async fn test_chaos_custom_error_status() {
1442
+ let config = ChaosConfig {
1443
+ enabled: true,
1444
+ error_p: 1.0,
1445
+ error_status: StatusCode::BAD_GATEWAY,
1446
+ ..Default::default()
1447
+ };
1448
+ let mw = ChaosMiddleware::new(config);
1449
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1450
+ chain.add(mw);
1451
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1452
+ assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
1453
+ }
1454
+
1455
+ // -----------------------------------------------------------------------
1456
+ // Additional combined resilience tests
1457
+ // -----------------------------------------------------------------------
1458
+
1459
+ #[tokio::test]
1460
+ async fn test_timeout_bulkhead_circuit_breaker_combined() {
1461
+ let timeout_mw = TimeoutMiddleware::new(Duration::from_secs(5));
1462
+ let bulkhead = Bulkhead::new(BulkheadConfig {
1463
+ max_concurrent: 2,
1464
+ max_wait: Duration::from_secs(1),
1465
+ });
1466
+ let bulkhead_mw = BulkheadMiddleware::new(bulkhead);
1467
+ let breaker = CircuitBreaker::new(CircuitBreakerConfig {
1468
+ failure_threshold: 5,
1469
+ success_threshold: 1,
1470
+ open_timeout: Duration::from_secs(60),
1471
+ half_open_max_requests: 3,
1472
+ });
1473
+ let cb_mw = CircuitBreakerMiddleware::new(breaker);
1474
+
1475
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1476
+ chain.add(timeout_mw);
1477
+ chain.add(bulkhead_mw);
1478
+ chain.add(cb_mw);
1479
+
1480
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1481
+ assert_eq!(resp.status(), StatusCode::OK);
1482
+ }
1483
+
1484
+ #[tokio::test]
1485
+ async fn test_chaos_fallback_combined() {
1486
+ // Chaos injects error, fallback catches it
1487
+ let chaos = ChaosConfig {
1488
+ enabled: true,
1489
+ error_p: 1.0,
1490
+ error_status: StatusCode::INTERNAL_SERVER_ERROR,
1491
+ ..Default::default()
1492
+ };
1493
+ let chaos_mw = ChaosMiddleware::new(chaos);
1494
+ let policy = FallbackPolicy::new().add(
1495
+ "/",
1496
+ FallbackEntry {
1497
+ status: StatusCode::OK,
1498
+ body: r#"{"cached":"survived"}"#.to_string(),
1499
+ content_type: "application/json".to_string(),
1500
+ },
1501
+ );
1502
+ let fb_mw = FallbackMiddleware::new(policy);
1503
+
1504
+ let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1505
+ chain.add(fb_mw);
1506
+ chain.add(chaos_mw);
1507
+
1508
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1509
+ assert_eq!(resp.status(), StatusCode::OK);
1510
+ }
1511
+
1512
+ #[tokio::test]
1513
+ async fn test_bulkhead_timeout_interaction() {
1514
+ // A slow handler that would be blocked by bulkhead, but timeout should fire first
1515
+ let config = BulkheadConfig {
1516
+ max_concurrent: 1,
1517
+ max_wait: Duration::from_millis(100),
1518
+ };
1519
+ let bulkhead = Bulkhead::new(config);
1520
+ let bulkhead_mw = BulkheadMiddleware::new(bulkhead);
1521
+ let timeout_mw = TimeoutMiddleware::new(Duration::from_millis(200));
1522
+
1523
+ let slow_handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| {
1524
+ Box::pin(async {
1525
+ tokio::time::sleep(Duration::from_secs(10)).await;
1526
+ Ok(json_response(StatusCode::OK, "ok"))
1527
+ })
1528
+ });
1529
+
1530
+ let mut chain = crate::middleware::MiddlewareChain::new(slow_handler);
1531
+ chain.add(bulkhead_mw);
1532
+ chain.add(timeout_mw);
1533
+
1534
+ let chain1 = chain.clone();
1535
+ let h1 = tokio::spawn(async move { chain1.run(test_req(hyper::Method::GET, "/")).await });
1536
+ tokio::time::sleep(Duration::from_millis(20)).await;
1537
+
1538
+ // This request should get 503 from bulkhead (not 504 from timeout)
1539
+ let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1540
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
1541
+ h1.await.unwrap().unwrap();
1542
+ }
1543
+
1544
+ // -----------------------------------------------------------------------
1545
+ // Circuit breaker config defaults test
1546
+ // -----------------------------------------------------------------------
1547
+
1548
+ #[test]
1549
+ fn test_circuit_breaker_config_defaults() {
1550
+ let config = CircuitBreakerConfig::default();
1551
+ assert_eq!(config.failure_threshold, 5);
1552
+ assert_eq!(config.success_threshold, 3);
1553
+ assert_eq!(config.open_timeout, Duration::from_secs(30));
1554
+ assert_eq!(config.half_open_max_requests, 3);
1555
+ }
1556
+
1557
+ #[test]
1558
+ fn test_bulkhead_config_defaults() {
1559
+ let config = BulkheadConfig::default();
1560
+ assert_eq!(config.max_concurrent, 10);
1561
+ assert_eq!(config.max_wait, Duration::from_secs(1));
1562
+ }
1563
+
1564
+ #[test]
1565
+ fn test_chaos_config_defaults() {
1566
+ let config = ChaosConfig::default();
1567
+ assert!(!config.enabled);
1568
+ }
1569
+
1570
+ #[test]
1571
+ fn test_retry_policy_defaults() {
1572
+ let policy = RetryPolicy::default();
1573
+ assert_eq!(policy.max_retries, 3);
1574
+ assert_eq!(policy.base_delay, Duration::from_millis(100));
1575
+ assert_eq!(policy.max_delay, Duration::from_secs(10));
1576
+ assert!(policy.jitter);
1577
+ }
1578
+}
0
1579
▼crates/justapi-core/src/static_files.rs
+477-0
1
+use std::path::{Path, PathBuf};
2
+use std::sync::Arc;
3
+
4
+use anyhow::Result;
5
+use futures::StreamExt;
6
+use http_body_util::combinators::UnsyncBoxBody;
7
+use http_body_util::{BodyExt, Full};
8
+use hyper::body::{Bytes, Frame};
9
+use hyper::{Request, Response, StatusCode};
10
+use tokio::io::{AsyncReadExt, AsyncSeekExt};
11
+
12
+use crate::ResponseBody;
13
+
14
+/// Serve static files from a root directory.
15
+#[derive(Clone)]
16
+pub struct StaticDir {
17
+ root: Arc<PathBuf>,
18
+}
19
+
20
+impl StaticDir {
21
+ pub fn new(root: impl Into<PathBuf>) -> Self {
22
+ Self {
23
+ root: Arc::new(root.into()),
24
+ }
25
+ }
26
+
27
+ /// Resolve a URI path to a file path, preventing directory traversal.
28
+ pub fn resolve(&self, uri_path: &str) -> Option<PathBuf> {
29
+ let relative = uri_path.trim_start_matches('/');
30
+ if relative.is_empty() {
31
+ return None;
32
+ }
33
+ // Block directory traversal
34
+ if relative.contains("..") {
35
+ return None;
36
+ }
37
+ let path = self.root.join(relative);
38
+ // Ensure the resolved path is still under root
39
+ if path.starts_with(&*self.root) {
40
+ Some(path)
41
+ } else {
42
+ None
43
+ }
44
+ }
45
+
46
+ /// Generate an ETag from file metadata.
47
+ fn make_etag(metadata: &std::fs::Metadata) -> String {
48
+ format!(
49
+ "\"{:x}-{:x}\"",
50
+ metadata.len(),
51
+ metadata
52
+ .modified()
53
+ .ok()
54
+ .and_then(|t| t.elapsed().ok())
55
+ .map(|d| d.as_secs())
56
+ .unwrap_or(0)
57
+ )
58
+ }
59
+
60
+ /// Serve a file at the given path. Returns a streaming response.
61
+ /// Supports Range requests for partial content (HTTP 206).
62
+ pub async fn serve_file(&self, path: &Path) -> Result<Response<ResponseBody>> {
63
+ let metadata = tokio::fs::metadata(path).await?;
64
+ if !metadata.is_file() {
65
+ return not_found();
66
+ }
67
+
68
+ let etag = Self::make_etag(&metadata);
69
+ let content_type = guess_content_type(path);
70
+ let total_len = metadata.len();
71
+
72
+ // For small files (< 1MB), read into memory for better compression support
73
+ if total_len <= 1024 * 1024 {
74
+ let bytes = tokio::fs::read(path).await?;
75
+ let body = UnsyncBoxBody::new(
76
+ http_body_util::Full::new(Bytes::from(bytes))
77
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
78
+ );
79
+ let mut resp = Response::new(body);
80
+ *resp.status_mut() = StatusCode::OK;
81
+ resp.headers_mut()
82
+ .insert("content-type", content_type.parse().unwrap());
83
+ resp.headers_mut()
84
+ .insert("content-length", total_len.to_string().parse().unwrap());
85
+ resp.headers_mut().insert("etag", etag.parse().unwrap());
86
+ resp.headers_mut()
87
+ .insert("cache-control", "public, max-age=3600".parse().unwrap());
88
+ return Ok(resp);
89
+ }
90
+
91
+ // For large files, use streaming
92
+ let file = tokio::fs::File::open(path).await?;
93
+ let stream = tokio_util::io::ReaderStream::new(file);
94
+ let body_stream = stream.map(|r| r.map(Frame::data).map_err(|e| anyhow::anyhow!(e)));
95
+ let body = UnsyncBoxBody::new(http_body_util::StreamBody::new(body_stream));
96
+
97
+ let mut resp = Response::new(body);
98
+ *resp.status_mut() = StatusCode::OK;
99
+ resp.headers_mut()
100
+ .insert("content-type", content_type.parse().unwrap());
101
+ resp.headers_mut()
102
+ .insert("content-length", total_len.to_string().parse().unwrap());
103
+ resp.headers_mut().insert("etag", etag.parse().unwrap());
104
+ resp.headers_mut()
105
+ .insert("cache-control", "public, max-age=3600".parse().unwrap());
106
+
107
+ Ok(resp)
108
+ }
109
+
110
+ /// Handle an HTTP request for a static file.
111
+ /// Supports Range requests (HTTP 206), If-None-Match (HTTP 304),
112
+ /// and proper cache headers.
113
+ pub async fn handle(
114
+ &self,
115
+ req: Request<hyper::body::Incoming>,
116
+ ) -> Result<Response<ResponseBody>> {
117
+ let uri_path = req.uri().path().to_string();
118
+
119
+ let path = match self.resolve(&uri_path) {
120
+ Some(p) => p,
121
+ None => return not_found(),
122
+ };
123
+
124
+ // ETag / If-None-Match check
125
+ let metadata = match tokio::fs::metadata(&path).await {
126
+ Ok(m) if m.is_file() => m,
127
+ _ => return not_found(),
128
+ };
129
+
130
+ let etag = Self::make_etag(&metadata);
131
+
132
+ // Conditional request: If-None-Match
133
+ if let Some(if_none_match) = req.headers().get("if-none-match") {
134
+ if let Ok(val) = if_none_match.to_str() {
135
+ if val == etag {
136
+ return Ok(Response::builder()
137
+ .status(StatusCode::NOT_MODIFIED)
138
+ .header("etag", &etag)
139
+ .header("cache-control", "public, max-age=3600")
140
+ .body(UnsyncBoxBody::new(Full::new(Bytes::new()).map_err(
141
+ |e: std::convert::Infallible| -> anyhow::Error { match e {} },
142
+ )))
143
+ .unwrap());
144
+ }
145
+ }
146
+ }
147
+
148
+ let content_type = guess_content_type(&path);
149
+ let total_len = metadata.len();
150
+
151
+ // Range request support
152
+ if let Some(range_header) = req.headers().get("range") {
153
+ if let Ok(range_str) = range_header.to_str() {
154
+ if let Some(range) = parse_range(range_str, total_len) {
155
+ return self
156
+ .serve_range(&path, &etag, content_type, total_len, range)
157
+ .await;
158
+ }
159
+ }
160
+ }
161
+
162
+ // Full file response
163
+ // For small files, read into memory (better for compression)
164
+ if total_len <= 1024 * 1024 {
165
+ let bytes = tokio::fs::read(&path).await?;
166
+ let body = UnsyncBoxBody::new(
167
+ http_body_util::Full::new(Bytes::from(bytes))
168
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
169
+ );
170
+ let mut resp = Response::new(body);
171
+ *resp.status_mut() = StatusCode::OK;
172
+ resp.headers_mut()
173
+ .insert("content-type", content_type.parse().unwrap());
174
+ resp.headers_mut()
175
+ .insert("content-length", total_len.to_string().parse().unwrap());
176
+ resp.headers_mut().insert("etag", etag.parse().unwrap());
177
+ resp.headers_mut()
178
+ .insert("cache-control", "public, max-age=3600".parse().unwrap());
179
+ resp.headers_mut()
180
+ .insert("accept-ranges", "bytes".parse().unwrap());
181
+ return Ok(resp);
182
+ }
183
+
184
+ // Large files: stream
185
+ let file = tokio::fs::File::open(&path).await?;
186
+ let stream = tokio_util::io::ReaderStream::new(file);
187
+ let body_stream = stream.map(|r| r.map(Frame::data).map_err(|e| anyhow::anyhow!(e)));
188
+ let body = UnsyncBoxBody::new(http_body_util::StreamBody::new(body_stream));
189
+
190
+ let mut resp = Response::new(body);
191
+ *resp.status_mut() = StatusCode::OK;
192
+ resp.headers_mut()
193
+ .insert("content-type", content_type.parse().unwrap());
194
+ resp.headers_mut()
195
+ .insert("content-length", total_len.to_string().parse().unwrap());
196
+ resp.headers_mut().insert("etag", etag.parse().unwrap());
197
+ resp.headers_mut()
198
+ .insert("cache-control", "public, max-age=3600".parse().unwrap());
199
+ resp.headers_mut()
200
+ .insert("accept-ranges", "bytes".parse().unwrap());
201
+
202
+ Ok(resp)
203
+ }
204
+
205
+ /// Serve a range of bytes from a file (HTTP 206 Partial Content).
206
+ async fn serve_range(
207
+ &self,
208
+ path: &Path,
209
+ etag: &str,
210
+ content_type: &str,
211
+ total_len: u64,
212
+ range: ByteRange,
213
+ ) -> Result<Response<ResponseBody>> {
214
+ let (start, end) = range.into_abs_range(total_len);
215
+ let len = end - start + 1;
216
+
217
+ let mut file = tokio::fs::File::open(path).await?;
218
+ file.seek(std::io::SeekFrom::Start(start)).await?;
219
+
220
+ let mut buf = vec![0u8; len as usize];
221
+ file.read_exact(&mut buf).await?;
222
+
223
+ let body = UnsyncBoxBody::new(
224
+ http_body_util::Full::new(Bytes::from(buf))
225
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
226
+ );
227
+
228
+ let content_range = format!("bytes {}-{}/{}", start, end, total_len);
229
+
230
+ let resp = Response::builder()
231
+ .status(StatusCode::PARTIAL_CONTENT)
232
+ .header("content-type", content_type)
233
+ .header("content-length", len.to_string())
234
+ .header("content-range", &content_range)
235
+ .header("etag", etag)
236
+ .header("accept-ranges", "bytes")
237
+ .header("cache-control", "public, max-age=3600")
238
+ .body(body)
239
+ .unwrap();
240
+
241
+ Ok(resp)
242
+ }
243
+}
244
+
245
+/// Parsed byte range from a Range header.
246
+pub(crate) enum ByteRange {
247
+ /// bytes=start-end
248
+ StartEnd(u64, u64),
249
+ /// bytes=start-
250
+ StartFrom(u64),
251
+ /// bytes=-suffix
252
+ Suffix(u64),
253
+}
254
+
255
+impl ByteRange {
256
+ /// Convert to an absolute (start, end) range clamped to file size.
257
+ pub(crate) fn into_abs_range(self, total_len: u64) -> (u64, u64) {
258
+ match self {
259
+ ByteRange::StartEnd(start, end) => {
260
+ let start = start.min(total_len.saturating_sub(1));
261
+ let end = end.min(total_len.saturating_sub(1));
262
+ (start, end.max(start))
263
+ }
264
+ ByteRange::StartFrom(start) => {
265
+ let start = start.min(total_len.saturating_sub(1));
266
+ (start, total_len.saturating_sub(1))
267
+ }
268
+ ByteRange::Suffix(len) => {
269
+ let start = total_len.saturating_sub(len);
270
+ (start, total_len.saturating_sub(1))
271
+ }
272
+ }
273
+ }
274
+}
275
+
276
+/// Parse a Range header value like "bytes=0-499" or "bytes=500-" or "bytes=-500".
277
+fn parse_range(range_str: &str, total_len: u64) -> Option<ByteRange> {
278
+ let range_str = range_str.trim();
279
+ if !range_str.starts_with("bytes=") {
280
+ return None;
281
+ }
282
+ let range_spec = &range_str[6..];
283
+ let parts: Vec<&str> = range_spec.split('-').collect();
284
+ if parts.len() != 2 {
285
+ return None;
286
+ }
287
+ let start_str = parts[0].trim();
288
+ let end_str = parts[1].trim();
289
+
290
+ if start_str.is_empty() {
291
+ // Suffix range: bytes=-500
292
+ let suffix_len: u64 = end_str.parse().ok()?;
293
+ if suffix_len == 0 || suffix_len > total_len {
294
+ return None;
295
+ }
296
+ Some(ByteRange::Suffix(suffix_len))
297
+ } else if end_str.is_empty() {
298
+ // Open-ended range: bytes=500-
299
+ let start: u64 = start_str.parse().ok()?;
300
+ Some(ByteRange::StartFrom(start))
301
+ } else {
302
+ // Explicit range: bytes=0-499
303
+ let start: u64 = start_str.parse().ok()?;
304
+ let end: u64 = end_str.parse().ok()?;
305
+ if start > end {
306
+ return None;
307
+ }
308
+ Some(ByteRange::StartEnd(start, end))
309
+ }
310
+}
311
+
312
+fn not_found() -> Result<Response<ResponseBody>> {
313
+ Ok(crate::json_response(
314
+ StatusCode::NOT_FOUND,
315
+ r#"{"error":"not found"}"#,
316
+ ))
317
+}
318
+
319
+fn guess_content_type(path: &Path) -> &'static str {
320
+ match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
321
+ "html" | "htm" => "text/html; charset=utf-8",
322
+ "css" => "text/css; charset=utf-8",
323
+ "js" | "mjs" => "application/javascript; charset=utf-8",
324
+ "json" => "application/json; charset=utf-8",
325
+ "png" => "image/png",
326
+ "jpg" | "jpeg" => "image/jpeg",
327
+ "gif" => "image/gif",
328
+ "svg" => "image/svg+xml",
329
+ "ico" => "image/x-icon",
330
+ "woff" => "font/woff",
331
+ "woff2" => "font/woff2",
332
+ "ttf" => "font/ttf",
333
+ "txt" => "text/plain; charset=utf-8",
334
+ "xml" => "application/xml; charset=utf-8",
335
+ "wasm" => "application/wasm",
336
+ "mp4" => "video/mp4",
337
+ "webp" => "image/webp",
338
+ _ => "application/octet-stream",
339
+ }
340
+}
341
+
342
+#[cfg(test)]
343
+mod tests {
344
+ use super::*;
345
+
346
+ #[test]
347
+ fn test_resolve_basic() {
348
+ let dir = StaticDir::new("/tmp/static");
349
+ assert_eq!(
350
+ dir.resolve("/index.html"),
351
+ Some(PathBuf::from("/tmp/static/index.html"))
352
+ );
353
+ assert_eq!(
354
+ dir.resolve("css/style.css"),
355
+ Some(PathBuf::from("/tmp/static/css/style.css"))
356
+ );
357
+ }
358
+
359
+ #[test]
360
+ fn test_resolve_traversal_blocked() {
361
+ let dir = StaticDir::new("/tmp/static");
362
+ assert_eq!(dir.resolve("/../../../etc/passwd"), None);
363
+ assert_eq!(dir.resolve("/foo/../../bar"), None);
364
+ }
365
+
366
+ #[test]
367
+ fn test_resolve_empty() {
368
+ let dir = StaticDir::new("/tmp/static");
369
+ assert_eq!(dir.resolve("/"), None);
370
+ assert_eq!(dir.resolve(""), None);
371
+ }
372
+
373
+ #[test]
374
+ fn test_guess_content_type() {
375
+ assert_eq!(
376
+ guess_content_type(Path::new("index.html")),
377
+ "text/html; charset=utf-8"
378
+ );
379
+ assert_eq!(
380
+ guess_content_type(Path::new("style.css")),
381
+ "text/css; charset=utf-8"
382
+ );
383
+ assert_eq!(
384
+ guess_content_type(Path::new("app.js")),
385
+ "application/javascript; charset=utf-8"
386
+ );
387
+ assert_eq!(guess_content_type(Path::new("image.png")), "image/png");
388
+ assert_eq!(guess_content_type(Path::new("font.woff2")), "font/woff2");
389
+ assert_eq!(
390
+ guess_content_type(Path::new("unknown.xyz")),
391
+ "application/octet-stream"
392
+ );
393
+ }
394
+
395
+ // --- parse_range tests ---
396
+
397
+ #[test]
398
+ fn test_parse_range_explicit() {
399
+ let r = parse_range("bytes=0-499", 1000).unwrap();
400
+ assert!(matches!(r, ByteRange::StartEnd(0, 499)));
401
+ }
402
+
403
+ #[test]
404
+ fn test_parse_range_open_ended() {
405
+ let r = parse_range("bytes=500-", 1000).unwrap();
406
+ assert!(matches!(r, ByteRange::StartFrom(500)));
407
+ }
408
+
409
+ #[test]
410
+ fn test_parse_range_suffix() {
411
+ let r = parse_range("bytes=-500", 1000).unwrap();
412
+ assert!(matches!(r, ByteRange::Suffix(500)));
413
+ }
414
+
415
+ #[test]
416
+ fn test_parse_range_single_byte() {
417
+ let r = parse_range("bytes=0-0", 1000).unwrap();
418
+ assert!(matches!(r, ByteRange::StartEnd(0, 0)));
419
+ }
420
+
421
+ #[test]
422
+ fn test_parse_range_invalid_start_gt_end() {
423
+ assert!(parse_range("bytes=5-3", 1000).is_none());
424
+ }
425
+
426
+ #[test]
427
+ fn test_parse_range_invalid_non_bytes_prefix() {
428
+ assert!(parse_range("bits=0-499", 1000).is_none());
429
+ }
430
+
431
+ #[test]
432
+ fn test_parse_range_invalid_non_numeric() {
433
+ assert!(parse_range("bytes=abc-def", 1000).is_none());
434
+ }
435
+
436
+ #[test]
437
+ fn test_parse_range_suffix_zero() {
438
+ assert!(parse_range("bytes=-0", 1000).is_none());
439
+ }
440
+
441
+ #[test]
442
+ fn test_parse_range_suffix_exceeds_total() {
443
+ assert!(parse_range("bytes=-9999", 100).is_none());
444
+ }
445
+
446
+ // --- ByteRange::into_abs_range tests ---
447
+
448
+ #[test]
449
+ fn test_abs_range_explicit() {
450
+ let r = ByteRange::StartEnd(10, 20).into_abs_range(100);
451
+ assert_eq!(r, (10, 20));
452
+ }
453
+
454
+ #[test]
455
+ fn test_abs_range_explicit_clamped() {
456
+ let r = ByteRange::StartEnd(50, 999).into_abs_range(100);
457
+ assert_eq!(r, (50, 99));
458
+ }
459
+
460
+ #[test]
461
+ fn test_abs_range_start_from() {
462
+ let r = ByteRange::StartFrom(50).into_abs_range(100);
463
+ assert_eq!(r, (50, 99));
464
+ }
465
+
466
+ #[test]
467
+ fn test_abs_range_suffix() {
468
+ let r = ByteRange::Suffix(30).into_abs_range(100);
469
+ assert_eq!(r, (70, 99));
470
+ }
471
+
472
+ #[test]
473
+ fn test_abs_range_suffix_equal_to_total() {
474
+ let r = ByteRange::Suffix(100).into_abs_range(100);
475
+ assert_eq!(r, (0, 99));
476
+ }
477
+}
0
478
▼crates/justapi-core/src/test_codec.rs
+47-0
1
+use bytes::{Buf, BufMut};
2
+use tonic::codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder};
3
+use tonic::Status;
4
+
5
+#[derive(Default, Clone)]
6
+pub struct RawBytesCodec;
7
+
8
+impl Codec for RawBytesCodec {
9
+ type Encode = Vec<u8>;
10
+ type Decode = Vec<u8>;
11
+ type Encoder = RawBytesEncoder;
12
+ type Decoder = RawBytesDecoder;
13
+
14
+ fn encoder(&mut self) -> Self::Encoder {
15
+ RawBytesEncoder
16
+ }
17
+ fn decoder(&mut self) -> Self::Decoder {
18
+ RawBytesDecoder
19
+ }
20
+}
21
+
22
+#[derive(Default, Clone)]
23
+pub struct RawBytesEncoder;
24
+
25
+impl Encoder for RawBytesEncoder {
26
+ type Item = Vec<u8>;
27
+ type Error = Status;
28
+
29
+ fn encode(&mut self, item: Self::Item, dst: &mut EncodeBuf<'_>) -> Result<(), Self::Error> {
30
+ dst.put_slice(&item);
31
+ Ok(())
32
+ }
33
+}
34
+
35
+#[derive(Default, Clone)]
36
+pub struct RawBytesDecoder;
37
+
38
+impl Decoder for RawBytesDecoder {
39
+ type Item = Vec<u8>;
40
+ type Error = Status;
41
+
42
+ fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result<Option<Self::Item>, Self::Error> {
43
+ let chunk = src.chunk().to_vec();
44
+ src.advance(chunk.len());
45
+ Ok(Some(chunk))
46
+ }
47
+}
0
48
▼crates/justapi-core/src/testing.rs
+201-0
1
+//! Testing utilities — `TestClient` for in-process request/response.
2
+//!
3
+//! Uses a tokio duplex pipe to send raw HTTP bytes through the hyper HTTP/1.1
4
+//! parser, producing real `Incoming` bodies without a TCP socket.
5
+
6
+use hyper::{Method, Request};
7
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
8
+
9
+use hyper_util::rt::TokioIo;
10
+
11
+use crate::middleware::HandlerFn;
12
+
13
+/// An in-memory HTTP test response.
14
+#[derive(Debug, Clone)]
15
+pub struct TestResponse {
16
+ pub status: u16,
17
+ pub headers: Vec<(String, String)>,
18
+ pub body: Vec<u8>,
19
+}
20
+
21
+/// A test client that sends requests through the full JustAPI pipeline
22
+/// without opening a TCP socket.
23
+#[derive(Clone)]
24
+pub struct TestClient {
25
+ handler: HandlerFn,
26
+}
27
+
28
+impl TestClient {
29
+ pub fn new(handler: HandlerFn) -> Self {
30
+ Self { handler }
31
+ }
32
+
33
+ pub async fn get(&self, path: &str) -> Result<TestResponse, anyhow::Error> {
34
+ self.request(Method::GET, path, Vec::new(), &[]).await
35
+ }
36
+
37
+ /// GET with extra request headers (e.g. to exercise header-aware routing
38
+ /// or coalescing keys).
39
+ pub async fn get_with(
40
+ &self,
41
+ path: &str,
42
+ headers: &[(&str, &str)],
43
+ ) -> Result<TestResponse, anyhow::Error> {
44
+ self.request(Method::GET, path, Vec::new(), headers).await
45
+ }
46
+
47
+ pub async fn post(&self, path: &str, body: Vec<u8>) -> Result<TestResponse, anyhow::Error> {
48
+ self.request(Method::POST, path, body, &[]).await
49
+ }
50
+
51
+ pub async fn put(&self, path: &str, body: Vec<u8>) -> Result<TestResponse, anyhow::Error> {
52
+ self.request(Method::PUT, path, body, &[]).await
53
+ }
54
+
55
+ pub async fn delete(&self, path: &str) -> Result<TestResponse, anyhow::Error> {
56
+ self.request(Method::DELETE, path, Vec::new(), &[]).await
57
+ }
58
+
59
+ async fn request(
60
+ &self,
61
+ method: Method,
62
+ path: &str,
63
+ body: Vec<u8>,
64
+ headers: &[(&str, &str)],
65
+ ) -> Result<TestResponse, anyhow::Error> {
66
+ let handler = self.handler.clone();
67
+ let (mut client, server) = tokio::io::duplex(65536);
68
+
69
+ // Spawn the server side: feed the handler through hyper HTTP/1.1
70
+ tokio::spawn(async move {
71
+ let svc = hyper::service::service_fn(move |req: Request<hyper::body::Incoming>| {
72
+ let handler = handler.clone();
73
+ async move { handler(req).await }
74
+ });
75
+ let io = TokioIo::new(server);
76
+ match hyper::server::conn::http1::Builder::new()
77
+ .serve_connection(io, svc)
78
+ .await
79
+ {
80
+ Ok(()) => {}
81
+ Err(e) => {
82
+ panic!("Test server connection error: {}", e);
83
+ }
84
+ }
85
+ });
86
+
87
+ // Build and send the raw HTTP request with Connection: close
88
+ let method_str = method.to_string();
89
+ let body_len = body.len();
90
+ let mut request_bytes = format!(
91
+ "{} {} HTTP/1.1\r\nHost: test\r\nConnection: close\r\n",
92
+ method_str, path,
93
+ )
94
+ .into_bytes();
95
+ for (name, value) in headers {
96
+ request_bytes.extend_from_slice(format!("{}: {}\r\n", name, value).as_bytes());
97
+ }
98
+ request_bytes.extend_from_slice(format!("Content-Length: {}\r\n\r\n", body_len).as_bytes());
99
+ if body_len > 0 {
100
+ request_bytes.extend_from_slice(&body);
101
+ }
102
+
103
+ client.write_all(&request_bytes).await?;
104
+
105
+ // Wait for the server to process the request and write the response.
106
+ // The server end of the duplex is dropped when serve_connection returns,
107
+ // which signals EOF on our read half.
108
+ let mut buf = Vec::new();
109
+ client.read_to_end(&mut buf).await?;
110
+
111
+ // Parse the raw HTTP response
112
+ parse_response(&buf)
113
+ }
114
+}
115
+
116
+fn parse_response(raw: &[u8]) -> Result<TestResponse, anyhow::Error> {
117
+ let header_end = find_double_crlf(raw)
118
+ .ok_or_else(|| anyhow::anyhow!("No header/body separator found in response"))?;
119
+
120
+ let header_section = &raw[..header_end];
121
+ let body = &raw[header_end + 4..];
122
+
123
+ let header_str = std::str::from_utf8(header_section)
124
+ .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in response headers: {}", e))?;
125
+
126
+ let mut lines = header_str.lines();
127
+
128
+ // Parse status line: "HTTP/1.1 200 OK"
129
+ let status_line = lines.next().unwrap_or("");
130
+ let status: u16 = status_line
131
+ .split_whitespace()
132
+ .nth(1)
133
+ .unwrap_or("500")
134
+ .parse()
135
+ .unwrap_or(500);
136
+
137
+ // Parse headers
138
+ let headers: Vec<(String, String)> = lines
139
+ .filter(|l| !l.is_empty())
140
+ .filter_map(|l| {
141
+ let mut parts = l.splitn(2, ": ");
142
+ let name = parts.next()?.to_string();
143
+ let value = parts.next().unwrap_or("").to_string();
144
+ Some((name, value))
145
+ })
146
+ .collect();
147
+
148
+ Ok(TestResponse {
149
+ status,
150
+ headers,
151
+ body: body.to_vec(),
152
+ })
153
+}
154
+
155
+fn find_double_crlf(data: &[u8]) -> Option<usize> {
156
+ data.windows(4).position(|w| w == b"\r\n\r\n")
157
+}
158
+
159
+#[cfg(test)]
160
+mod tests {
161
+ use super::*;
162
+ use crate::json_response;
163
+ use crate::middleware::HandlerFn;
164
+ use hyper::StatusCode;
165
+
166
+ fn ok_handler() -> HandlerFn {
167
+ std::sync::Arc::new(|_req| {
168
+ Box::pin(async move { Ok(json_response(StatusCode::OK, r#"{"status":"ok"}"#)) })
169
+ })
170
+ }
171
+
172
+ #[tokio::test]
173
+ async fn test_test_client_get() {
174
+ let client = TestClient::new(ok_handler());
175
+ let resp = client.get("/test").await.unwrap();
176
+ assert_eq!(resp.status, 200);
177
+ assert_eq!(resp.body, br#"{"status":"ok"}"#);
178
+ }
179
+
180
+ #[tokio::test]
181
+ async fn test_test_client_post() {
182
+ let client = TestClient::new(ok_handler());
183
+ let resp = client.post("/test", b"{}".to_vec()).await.unwrap();
184
+ assert_eq!(resp.status, 200);
185
+ }
186
+
187
+ #[tokio::test]
188
+ async fn test_test_client_404() {
189
+ let handler: HandlerFn = std::sync::Arc::new(|_req| {
190
+ Box::pin(async move {
191
+ Ok(json_response(
192
+ StatusCode::NOT_FOUND,
193
+ r#"{"error":"not found"}"#,
194
+ ))
195
+ })
196
+ });
197
+ let client = TestClient::new(handler);
198
+ let resp = client.get("/nonexistent").await.unwrap();
199
+ assert_eq!(resp.status, 404);
200
+ }
201
+}
0
202
▼crates/justapi-core/src/validate.rs
+366-0
1
+use std::fmt;
2
+
3
+use http_body_util::BodyExt;
4
+use http_body_util::Full;
5
+use hyper::body::Bytes;
6
+use hyper::{Response, StatusCode};
7
+use serde::de::DeserializeOwned;
8
+
9
+use crate::ResponseBody;
10
+
11
+// ---------------------------------------------------------------------------
12
+// Validation error types
13
+// ---------------------------------------------------------------------------
14
+
15
+/// A structured validation error with field-level messages (RFC 9457 format).
16
+#[derive(Debug, Clone)]
17
+pub struct ValidationError {
18
+ pub errors: Vec<FieldError>,
19
+}
20
+
21
+impl ValidationError {
22
+ pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
23
+ Self {
24
+ errors: vec![FieldError {
25
+ field: field.into(),
26
+ message: message.into(),
27
+ }],
28
+ }
29
+ }
30
+
31
+ pub fn multiple(errors: Vec<FieldError>) -> Self {
32
+ Self { errors }
33
+ }
34
+
35
+ pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
36
+ self.errors.push(FieldError {
37
+ field: field.into(),
38
+ message: message.into(),
39
+ });
40
+ }
41
+
42
+ pub fn is_empty(&self) -> bool {
43
+ self.errors.is_empty()
44
+ }
45
+}
46
+
47
+#[derive(Debug, Clone)]
48
+pub struct FieldError {
49
+ pub field: String,
50
+ pub message: String,
51
+}
52
+
53
+impl fmt::Display for ValidationError {
54
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55
+ for (i, err) in self.errors.iter().enumerate() {
56
+ if i > 0 {
57
+ write!(f, "; ")?;
58
+ }
59
+ write!(f, "{}: {}", err.field, err.message)?;
60
+ }
61
+ Ok(())
62
+ }
63
+}
64
+
65
+impl std::error::Error for ValidationError {}
66
+
67
+/// Build an RFC 9457 Problem Details response for validation failures (422).
68
+pub fn validation_error_response(err: &ValidationError) -> Response<ResponseBody> {
69
+ let body = serde_json::json!({
70
+ "type": "https://justapi.dev/errors/validation",
71
+ "title": "Validation Error",
72
+ "status": 422,
73
+ "detail": err.to_string(),
74
+ "errors": err.errors.iter().map(|e| {
75
+ serde_json::json!({
76
+ "field": e.field,
77
+ "message": e.message,
78
+ })
79
+ }).collect::<Vec<_>>(),
80
+ })
81
+ .to_string();
82
+
83
+ Response::builder()
84
+ .status(StatusCode::UNPROCESSABLE_ENTITY)
85
+ .header("content-type", "application/problem+json")
86
+ .header("content-length", body.len().to_string())
87
+ .body(crate::UnsyncBoxBody::new(
88
+ Full::new(Bytes::from(body))
89
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
90
+ ))
91
+ .unwrap()
92
+}
93
+
94
+// ---------------------------------------------------------------------------
95
+// Schema trait
96
+// ---------------------------------------------------------------------------
97
+
98
+/// A type that can be parsed from raw HTTP data and validated.
99
+///
100
+/// Automatically blanket-implemented for any `DeserializeOwned + Send + Sync + 'static`.
101
+pub trait Schema: DeserializeOwned + Send + Sync + 'static {
102
+ /// Validate the parsed value. Returns `Ok(())` by default.
103
+ fn validate(&self) -> Result<(), ValidationError> {
104
+ Ok(())
105
+ }
106
+
107
+ /// Parse from a JSON byte slice, then validate.
108
+ fn parse_body(body: &[u8]) -> Result<Self, ValidationError> {
109
+ let value: Self = serde_json::from_slice(body)
110
+ .map_err(|e| ValidationError::new("body", format!("Invalid JSON: {}", e)))?;
111
+ value.validate()?;
112
+ Ok(value)
113
+ }
114
+}
115
+
116
+/// Blanket implementation: any `DeserializeOwned + Send + Sync + 'static` is a Schema.
117
+impl<T: DeserializeOwned + Send + Sync + 'static> Schema for T {}
118
+
119
+// ---------------------------------------------------------------------------
120
+// Type coercion (string → T)
121
+// ---------------------------------------------------------------------------
122
+
123
+/// Trait for types that can be coerced from a URL path/query parameter string.
124
+pub trait Coerce: Sized {
125
+ fn coerce(s: &str) -> Result<Self, ValidationError>;
126
+}
127
+
128
+macro_rules! impl_coerce_via_from_str {
129
+ ($($t:ty),* $(,)?) => {
130
+ $(
131
+ impl Coerce for $t {
132
+ fn coerce(s: &str) -> Result<Self, ValidationError> {
133
+ s.parse().map_err(|_| {
134
+ ValidationError::new("", format!("invalid value: expected {}, got '{}'", stringify!($t), s))
135
+ })
136
+ }
137
+ }
138
+ )*
139
+ };
140
+}
141
+
142
+impl_coerce_via_from_str!(String, i8, i16, i32, i64, i128, u8, u16, u32, u64, u128, f32, f64, bool);
143
+
144
+impl Coerce for char {
145
+ fn coerce(s: &str) -> Result<Self, ValidationError> {
146
+ let mut chars = s.chars();
147
+ match (chars.next(), chars.next()) {
148
+ (Some(c), None) => Ok(c),
149
+ _ => Err(ValidationError::new(
150
+ "",
151
+ format!("invalid value: expected a single character, got '{}'", s),
152
+ )),
153
+ }
154
+ }
155
+}
156
+
157
+// ---------------------------------------------------------------------------
158
+// Query parameter parsing
159
+// ---------------------------------------------------------------------------
160
+
161
+/// Parse query string into a typed struct using serde.
162
+///
163
+/// Supports `?key=value&key2=value2` format with URL-encoding.
164
+/// Handles nested structs, optional fields, and type coercion.
165
+pub fn parse_query<T: DeserializeOwned>(query: &str) -> Result<T, ValidationError> {
166
+ let cleaned = query.trim_start_matches('?');
167
+ serde_urlencoded::from_str(cleaned)
168
+ .map_err(|e| ValidationError::new("query", format!("parse error: {}", e)))
169
+}
170
+
171
+// ---------------------------------------------------------------------------
172
+// JSON Schema validation
173
+// ---------------------------------------------------------------------------
174
+
175
+/// Validate a JSON byte slice against a JSON Schema string.
176
+///
177
+/// Returns `ValidationError` with field-level errors on failure.
178
+/// The schema should be a valid JSON Schema (Draft 2020-12 or earlier).
179
+pub fn validate_json_schema(body: &[u8], schema_json: &str) -> Result<(), ValidationError> {
180
+ let body_value: serde_json::Value = serde_json::from_slice(body)
181
+ .map_err(|e| ValidationError::new("body", format!("Invalid JSON: {}", e)))?;
182
+
183
+ let schema_value: serde_json::Value = serde_json::from_str(schema_json)
184
+ .map_err(|e| ValidationError::new("schema", format!("Invalid schema JSON: {}", e)))?;
185
+
186
+ let validator = jsonschema::options()
187
+ .build(&schema_value)
188
+ .map_err(|e| ValidationError::new("schema", format!("Schema compilation error: {}", e)))?;
189
+
190
+ let mut verr = ValidationError { errors: Vec::new() };
191
+ for error in validator.iter_errors(&body_value) {
192
+ let path = error.instance_path().to_string();
193
+ let field = if path.is_empty() || path == "/" || path == "#" {
194
+ "body".to_string()
195
+ } else {
196
+ path.trim_start_matches('/').to_string()
197
+ };
198
+ verr.add(field, error.to_string());
199
+ }
200
+ if verr.is_empty() {
201
+ Ok(())
202
+ } else {
203
+ Err(verr)
204
+ }
205
+}
206
+
207
+// ---------------------------------------------------------------------------
208
+// Tests
209
+// ---------------------------------------------------------------------------
210
+
211
+#[cfg(test)]
212
+mod tests {
213
+ use super::*;
214
+ use serde::Deserialize;
215
+
216
+ #[derive(Debug, Deserialize, PartialEq)]
217
+ struct UserParams {
218
+ name: String,
219
+ age: Option<i32>,
220
+ }
221
+
222
+ #[test]
223
+ fn test_path_param_coerce_string() {
224
+ assert_eq!(String::coerce("hello").unwrap(), "hello");
225
+ }
226
+
227
+ #[test]
228
+ fn test_path_param_coerce_i32() {
229
+ assert_eq!(i32::coerce("42").unwrap(), 42i32);
230
+ assert!(i32::coerce("not_a_number").is_err());
231
+ }
232
+
233
+ #[test]
234
+ fn test_path_param_coerce_i64() {
235
+ assert_eq!(i64::coerce("9999999999").unwrap(), 9_999_999_999i64);
236
+ }
237
+
238
+ #[test]
239
+ fn test_path_param_coerce_f64() {
240
+ assert_eq!(f64::coerce("2.5").unwrap(), 2.5_f64);
241
+ }
242
+
243
+ #[test]
244
+ fn test_path_param_coerce_bool() {
245
+ assert!(bool::coerce("true").unwrap());
246
+ assert!(!bool::coerce("false").unwrap());
247
+ assert!(bool::coerce("invalid").is_err());
248
+ }
249
+
250
+ #[test]
251
+ fn test_schema_parse_valid_json() {
252
+ let json = br#"{"name": "Alice", "age": 30}"#;
253
+ let params: UserParams = Schema::parse_body(json).unwrap();
254
+ assert_eq!(params.name, "Alice");
255
+ assert_eq!(params.age, Some(30));
256
+ }
257
+
258
+ #[test]
259
+ fn test_schema_parse_invalid_json() {
260
+ let json = b"not json";
261
+ let result: Result<UserParams, ValidationError> = Schema::parse_body(json);
262
+ assert!(result.is_err());
263
+ assert!(result.unwrap_err().to_string().contains("Invalid JSON"));
264
+ }
265
+
266
+ #[test]
267
+ fn test_validation_error_display() {
268
+ let err = ValidationError::multiple(vec![
269
+ FieldError {
270
+ field: "name".into(),
271
+ message: "required".into(),
272
+ },
273
+ FieldError {
274
+ field: "age".into(),
275
+ message: "must be positive".into(),
276
+ },
277
+ ]);
278
+ let msg = err.to_string();
279
+ assert!(msg.contains("name: required"));
280
+ assert!(msg.contains("age: must be positive"));
281
+ }
282
+
283
+ #[test]
284
+ fn test_validation_error_response_format() {
285
+ let err = ValidationError::new("email", "invalid email address");
286
+ let resp = validation_error_response(&err);
287
+ assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
288
+ let content_type = resp.headers().get("content-type").unwrap();
289
+ assert_eq!(content_type, "application/problem+json");
290
+ }
291
+
292
+ #[test]
293
+ fn test_query_parse_simple() {
294
+ #[derive(Debug, Deserialize, PartialEq)]
295
+ struct Simple {
296
+ name: String,
297
+ age: i32,
298
+ }
299
+ let result: Simple = parse_query("name=Alice&age=30").unwrap();
300
+ assert_eq!(result.name, "Alice");
301
+ assert_eq!(result.age, 30);
302
+ }
303
+
304
+ #[test]
305
+ fn test_query_parse_percent_encoded() {
306
+ #[derive(Debug, Deserialize, PartialEq)]
307
+ struct Q {
308
+ name: String,
309
+ }
310
+ let result: Q = parse_query("name=Hello%20World").unwrap();
311
+ assert_eq!(result.name, "Hello World");
312
+ }
313
+
314
+ #[test]
315
+ fn test_query_parse_optional_fields() {
316
+ #[derive(Debug, Deserialize, PartialEq)]
317
+ struct WithOptional {
318
+ required: String,
319
+ optional: Option<String>,
320
+ }
321
+ let result: WithOptional = parse_query("required=hello").unwrap();
322
+ assert_eq!(result.required, "hello");
323
+ assert_eq!(result.optional, None);
324
+
325
+ let result: WithOptional = parse_query("required=hello&optional=world").unwrap();
326
+ assert_eq!(result.required, "hello");
327
+ assert_eq!(result.optional, Some("world".into()));
328
+ }
329
+
330
+ #[test]
331
+ fn test_validate_json_schema_valid() {
332
+ let body = br#"{"name": "Alice", "email": "[email protected]"}"#;
333
+ let schema = r#"{"type": "object", "properties": {"name": {"type": "string"}, "email": {"type": "string"}}, "required": ["name", "email"], "additionalProperties": false}"#;
334
+ assert!(validate_json_schema(body, schema).is_ok());
335
+ }
336
+
337
+ #[test]
338
+ fn test_validate_json_schema_missing_field() {
339
+ let body = br#"{"name": "Alice"}"#;
340
+ let schema = r#"{"type": "object", "properties": {"name": {"type": "string"}, "email": {"type": "string"}}, "required": ["name", "email"]}"#;
341
+ let result = validate_json_schema(body, schema);
342
+ assert!(result.is_err());
343
+ let err = result.unwrap_err();
344
+ assert!(err.to_string().contains("email"));
345
+ assert!(err.to_string().contains("required"));
346
+ }
347
+
348
+ #[test]
349
+ fn test_validate_json_schema_wrong_type() {
350
+ let body = br#"{"name": "Alice", "email": "[email protected]", "age": "not a number"}"#;
351
+ let schema = r#"{"type": "object", "properties": {"name": {"type": "string"}, "email": {"type": "string"}, "age": {"type": "integer"}}, "required": ["name", "email"]}"#;
352
+ let result = validate_json_schema(body, schema);
353
+ assert!(result.is_err());
354
+ let err = result.unwrap_err();
355
+ assert!(err.to_string().contains("not of type"));
356
+ }
357
+
358
+ #[test]
359
+ fn test_validate_json_schema_invalid_body() {
360
+ let body = b"not json";
361
+ let schema = r#"{"type": "object"}"#;
362
+ let result = validate_json_schema(body, schema);
363
+ assert!(result.is_err());
364
+ assert!(result.unwrap_err().to_string().contains("Invalid JSON"));
365
+ }
366
+}
0
367
▼crates/justapi-core/src/wasm.rs
+78-0
1
+use anyhow::{Context, Result};
2
+use wasmtime::*;
3
+
4
+pub struct WasmEngine {
5
+ engine: Engine,
6
+ module: Module,
7
+}
8
+
9
+impl WasmEngine {
10
+ pub fn new(wasm_bytes: &[u8]) -> Result<Self> {
11
+ let mut config = Config::new();
12
+ config.consume_fuel(true);
13
+
14
+ let engine = Engine::new(&config)?;
15
+ let module = Module::new(&engine, wasm_bytes)?;
16
+
17
+ Ok(Self { engine, module })
18
+ }
19
+
20
+ /// Evaluates the middleware on the request headers/metadata.
21
+ /// In a real implementation this would use WIT (Wasm Interface Types) or a structured memory exchange.
22
+ /// For this phase, we simply pass a JSON string of headers into WASM memory and read a JSON response.
23
+ pub async fn execute_middleware(&self, request_json: &str) -> Result<String> {
24
+ let mut store = Store::new(&self.engine, ());
25
+ store.set_fuel(10_000_000)?; // Prevent infinite loops
26
+
27
+ let mut linker = Linker::new(&self.engine);
28
+ // Link host functions if needed (e.g., logging)
29
+ linker.func_wrap(
30
+ "env",
31
+ "log",
32
+ |_caller: Caller<'_, ()>, _ptr: i32, _len: i32| {
33
+ // Placeholder for host logging from WASM
34
+ },
35
+ )?;
36
+
37
+ let instance = linker.instantiate_async(&mut store, &self.module).await?;
38
+
39
+ // We assume the WASM module exports:
40
+ // - `allocate(size: i32) -> i32`
41
+ // - `deallocate(ptr: i32, size: i32)`
42
+ // - `process_request(ptr: i32, len: i32) -> i64` (returns (ptr << 32) | len)
43
+
44
+ let memory = instance
45
+ .get_memory(&mut store, "memory")
46
+ .context("Failed to find memory")?;
47
+
48
+ let allocate = instance
49
+ .get_typed_func::<i32, i32>(&mut store, "allocate")
50
+ .map_err(|e| anyhow::anyhow!("{}", e))
51
+ .context("Missing allocate")?;
52
+
53
+ let process_request = instance
54
+ .get_typed_func::<(i32, i32), i64>(&mut store, "process_request")
55
+ .map_err(|e| anyhow::anyhow!("{}", e))
56
+ .context("Missing process_request")?;
57
+
58
+ // Write the request JSON into WASM memory
59
+ let bytes = request_json.as_bytes();
60
+ let len = bytes.len() as i32;
61
+ let ptr = allocate.call_async(&mut store, len).await?;
62
+
63
+ memory.write(&mut store, ptr as usize, bytes)?;
64
+
65
+ // Call the processing function
66
+ let result = process_request.call_async(&mut store, (ptr, len)).await?;
67
+
68
+ let out_ptr = (result >> 32) as i32;
69
+ let out_len = (result & 0xFFFFFFFF) as i32;
70
+
71
+ let mut out_bytes = vec![0u8; out_len as usize];
72
+ memory.read(&mut store, out_ptr as usize, &mut out_bytes)?;
73
+
74
+ let out_str = String::from_utf8(out_bytes)?;
75
+
76
+ Ok(out_str)
77
+ }
78
+}
0
79