086453c
commit 086453c
17 files changed
+6413 -0
Showing 17 changed files+6413-0
crates/justapi-core/src/batching.rscrates/justapi-core/src/coalesce.rscrates/justapi-core/src/dx.rscrates/justapi-core/src/gateway.rscrates/justapi-core/src/graphql.rscrates/justapi-core/src/grpc.rscrates/justapi-core/src/memory.rscrates/justapi-core/src/multipart.rscrates/justapi-core/src/openai.rscrates/justapi-core/src/openapi.rscrates/justapi-core/src/plugin.rscrates/justapi-core/src/resilience.rscrates/justapi-core/src/static_files.rscrates/justapi-core/src/test_codec.rscrates/justapi-core/src/testing.rscrates/justapi-core/src/validate.rscrates/justapi-core/src/wasm.rs
▼crates/justapi-core/src/batching.rs
+112-0
| @@ -0,0 +1,112 @@ | ||
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
| @@ -0,0 +1,449 @@ | ||
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
| @@ -0,0 +1,324 @@ | ||
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
| @@ -0,0 +1,186 @@ | ||
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
| @@ -0,0 +1,77 @@ | ||
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
| @@ -0,0 +1,139 @@ | ||
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
| @@ -0,0 +1,252 @@ | ||
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
| @@ -0,0 +1,219 @@ | ||
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
| @@ -0,0 +1,865 @@ | ||
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
| @@ -0,0 +1,955 @@ | ||
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
| @@ -0,0 +1,88 @@ | ||
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
| @@ -0,0 +1,1578 @@ | ||
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
| @@ -0,0 +1,477 @@ | ||
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
| @@ -0,0 +1,47 @@ | ||
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
| @@ -0,0 +1,201 @@ | ||
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
| @@ -0,0 +1,366 @@ | ||
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
| @@ -0,0 +1,78 @@ | ||
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 | |