Skip to main content

jerrycan_core/
app.rs

1//! `App` (spec §4.1): assembles mounted modules + app-level routes, validates
2//! the route table at build time (fail loud), and dispatches requests.
3
4use crate::dep::{AnyArc, DepEnv, DepFactory, DepResolver, TaskContext};
5use crate::error::{Error, Result};
6use crate::extract::{BodyLane, RequestCtx};
7use crate::handler::BoxHandlerFn;
8use crate::middleware::{Middleware, Next};
9use crate::module::{FlatRoute, Module};
10use crate::response::{IntoResponse, Response};
11use crate::router::{Endpoint, MethodRouter, RouteMatch, Trie};
12use crate::serve;
13#[cfg(test)]
14use crate::serve::is_transient_accept_error;
15#[cfg(test)]
16use bytes::Bytes;
17use std::any::TypeId;
18use std::collections::HashMap;
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::Arc;
22
23/// A serve-time background task: given a [`TaskContext`] (app-level deps) and a
24/// shutdown watch, runs until the future completes. Boxed `FnOnce` because each
25/// task is launched exactly once when serving starts.
26pub(crate) type BackgroundFactory = Box<
27    dyn FnOnce(
28            TaskContext,
29            tokio::sync::watch::Receiver<bool>,
30        ) -> Pin<Box<dyn Future<Output = ()> + Send>>
31        + Send,
32>;
33
34/// An app-level error-body mapper (`App::map_error_body`). Given a
35/// framework-emitted error's status, stable code, and message, it returns
36/// `Some(body)` to reshape the JSON response into the app's own wire envelope,
37/// or `None` to keep jerrycan's default `{code, message[, details]}` body (so a
38/// mapper that only remaps some errors leaves the rest — and their `details` —
39/// untouched). Applied to EVERY error response (framework guards/extractors AND
40/// handler-returned `Error`) at the single dispatch exit.
41pub type ErrorBodyMapper =
42    dyn Fn(http::StatusCode, &str, &str) -> Option<serde_json::Value> + Send + Sync;
43
44/// The application builder. Generated `app/src/main.rs` is exactly this:
45/// provide app-level deps, mount modules, serve.
46pub struct App {
47    routes: Vec<(String, MethodRouter)>,
48    mounts: Vec<(String, Module)>,
49    env: DepEnv,
50    middleware: Vec<Arc<dyn Middleware>>,
51    security_headers: bool,
52    cors: Option<std::sync::Arc<crate::cors::CorsConfig>>,
53    handler_timeout: std::time::Duration,
54    body_read_timeout: std::time::Duration,
55    write_stall_timeout: std::time::Duration,
56    /// App-level error-body mapper (spec §4.1 error envelope). None ⇒ the flat
57    /// `{code, message}` body. Shared into the `Arc`-cloned `BuiltApp`.
58    error_mapper: Option<Arc<ErrorBodyMapper>>,
59    /// Serve-time-only background tasks (spec: `on_serve`). They are taken off
60    /// the builder by the serve engine before `build()`; `into_test` drops them.
61    background: Vec<(&'static str, BackgroundFactory)>,
62}
63
64impl Default for App {
65    fn default() -> Self {
66        // The real clock is a singleton provided up front so handlers can take
67        // `Dep<Clock>` without any wiring. A later `.provide(Clock::test())`
68        // (or `into_test`'s override) replaces it: `insert_value` is
69        // last-write-wins on the type, and overrides outrank singletons.
70        let mut env = DepEnv::default();
71        env.insert_value(crate::clock::Clock::system());
72        Self {
73            routes: Vec::new(),
74            mounts: Vec::new(),
75            env,
76            middleware: Vec::new(),
77            security_headers: true,
78            cors: None,
79            handler_timeout: std::time::Duration::from_secs(30),
80            body_read_timeout: std::time::Duration::from_secs(30),
81            write_stall_timeout: std::time::Duration::from_secs(30),
82            error_mapper: None,
83            background: Vec::new(),
84        }
85    }
86}
87
88/// Spec §6: capabilities register through one seam. An extension receives the
89/// builder and returns it — providers, routes, middleware, anything.
90pub trait Extension {
91    fn register(self, app: App) -> App;
92}
93
94impl App {
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Attach an extension: `App::new().extend(Db::from_env().await?)`.
100    pub fn extend<E: Extension>(self, extension: E) -> App {
101        extension.register(self)
102    }
103
104    /// Secure-by-default headers on every response (spec §4.4). Opting out
105    /// must be explicit — that is the contract.
106    pub fn security_headers(mut self, on: bool) -> Self {
107        self.security_headers = on;
108        self
109    }
110
111    /// Register an app-level error-body mapper so framework-emitted errors (the
112    /// auth guard's 401, extractor 4xx, …) render in the app's OWN wire envelope
113    /// instead of the flat `{code, message}` body — the only way to make those
114    /// framework errors conform. `f` receives each error's status, stable code,
115    /// and message; return `Some(json)` to reshape the body, or `None` to keep
116    /// the default (preserving `details`). Applies to every error response.
117    pub fn map_error_body<F>(mut self, f: F) -> Self
118    where
119        F: Fn(http::StatusCode, &str, &str) -> Option<serde_json::Value> + Send + Sync + 'static,
120    {
121        self.error_mapper = Some(Arc::new(f));
122        self
123    }
124
125    /// Install a CORS policy (spec §v2.2). Preflight `OPTIONS` is answered before
126    /// routing; actual cross-origin responses (including 404/405) are decorated
127    /// with the CORS headers. `allow_credentials(true)` with `CorsOrigins::any()`
128    /// is a build error.
129    pub fn cors(mut self, config: crate::cors::CorsConfig) -> Self {
130        self.cors = Some(std::sync::Arc::new(config));
131        self
132    }
133
134    /// Per-request handler time budget (default 30s — spec §4.4). Exceeding it
135    /// returns 503 JC0503 without killing the connection or the server.
136    pub fn handler_timeout(mut self, budget: std::time::Duration) -> Self {
137        self.handler_timeout = budget;
138        self
139    }
140
141    /// Time budget for reading a request body (default 30s — spec §4.4).
142    pub fn body_read_timeout(mut self, budget: std::time::Duration) -> Self {
143        self.body_read_timeout = budget;
144        self
145    }
146
147    /// Maximum time a connection's socket write may stall (client not reading)
148    /// before the connection is dropped. Protects streaming downloads — and all
149    /// responses — from slow-reader clients. Default 30s.
150    pub fn write_stall_timeout(mut self, budget: std::time::Duration) -> Self {
151        self.write_stall_timeout = budget;
152        self
153    }
154
155    /// App-level route (prefer modules; this exists for tiny services and tests).
156    pub fn route(mut self, path: &str, methods: MethodRouter) -> Self {
157        self.routes.push((path.to_string(), methods));
158        self
159    }
160
161    /// Mount a module at a prefix (spec §4.2).
162    pub fn mount(mut self, prefix: &str, module: Module) -> Self {
163        self.mounts.push((prefix.to_string(), module));
164        self
165    }
166
167    /// App-level singleton value dependency.
168    pub fn provide<T: Send + Sync + 'static>(mut self, value: T) -> Self {
169        self.env.insert_value(value);
170        self
171    }
172
173    /// App-level async factory dependency (request scope).
174    pub fn provide_dep<F, Args, T>(mut self, factory: F) -> Self
175    where
176        F: DepFactory<Args, T>,
177        T: Send + Sync + 'static,
178    {
179        self.env.insert_factory(factory);
180        self
181    }
182
183    /// App-level middleware — outermost ring of every route's chain.
184    pub fn middleware<M: Middleware>(mut self, mw: M) -> Self {
185        self.middleware.push(Arc::new(mw));
186        self
187    }
188
189    /// Register a background task that runs for the lifetime of `serve`.
190    ///
191    /// The task is launched once when serving starts, receives a
192    /// [`TaskContext`] (resolving app-level deps registered via
193    /// `provide`/`provide_dep`) and a `watch::Receiver<bool>` that flips to
194    /// `true` when shutdown begins. It runs under the same drain governance as
195    /// connections: shutdown gives it the 10s drain cap to finish before the
196    /// server aborts remaining work.
197    ///
198    /// Background tasks run ONLY under `serve` — `into_test` ignores them, so
199    /// drive the task's logic directly in tests rather than relying on it
200    /// firing. The `name` is retained for future observability.
201    pub fn on_serve<F, Fut>(mut self, name: &'static str, f: F) -> App
202    where
203        F: FnOnce(TaskContext, tokio::sync::watch::Receiver<bool>) -> Fut + Send + 'static,
204        Fut: Future<Output = ()> + Send + 'static,
205    {
206        let factory: BackgroundFactory = Box::new(move |ctx, shutdown| Box::pin(f(ctx, shutdown)));
207        self.background.push((name, factory));
208        self
209    }
210
211    /// Serve engine hook: lift the registered background tasks off the builder
212    /// before `build()` consumes it. They are FnOnce and serve-time-only, so
213    /// they deliberately do not travel into the `Arc`-shared `BuiltApp`.
214    pub(crate) fn take_background(&mut self) -> Vec<(&'static str, BackgroundFactory)> {
215        std::mem::take(&mut self.background)
216    }
217
218    /// Flatten modules, validate the route table, freeze the dispatch trie.
219    /// All conflicts surface HERE — before serving (spec §4.1 "fail loud").
220    pub fn build(self) -> Result<BuiltApp> {
221        if let Some(c) = &self.cors {
222            c.validate()?;
223        }
224        let mut trie = Trie::default();
225        let app_env = Arc::new(self.env.clone());
226        let app_mw: Arc<[Arc<dyn Middleware>]> = Arc::from(self.middleware.clone());
227
228        for (path, methods) in self.routes {
229            let body_limit = methods.body_limit;
230            let handler_timeout = methods.handler_timeout;
231            let body_read_timeout = methods.body_read_timeout;
232            insert_flat(
233                &mut trie,
234                FlatRoute {
235                    path,
236                    methods,
237                    env: app_env.clone(),
238                    middleware: app_mw.clone(),
239                    body_limit,
240                    handler_timeout,
241                    body_read_timeout,
242                },
243            )?;
244        }
245        for (prefix, module) in self.mounts {
246            for flat in module.flatten(&prefix, &self.env, &self.middleware) {
247                insert_flat(&mut trie, flat)?;
248            }
249        }
250        Ok(BuiltApp {
251            trie,
252            app_env,
253            overrides: Arc::new(HashMap::new()),
254            security_headers: self.security_headers,
255            cors: self.cors.clone(),
256            handler_timeout: self.handler_timeout,
257            body_read_timeout: self.body_read_timeout,
258            write_stall_timeout: self.write_stall_timeout,
259            error_mapper: self.error_mapper.clone(),
260        })
261    }
262
263    /// Bind from config and serve until Ctrl-C, then drain gracefully.
264    /// Address: `JERRYCAN_ADDR` env var, default `127.0.0.1:8000`. (Full layered
265    /// config lands in Phase 1; the env-var layer is the contract that already works.)
266    pub async fn serve(self) -> Result<()> {
267        let addr = std::env::var("JERRYCAN_ADDR").unwrap_or_else(|_| "127.0.0.1:8000".to_string());
268        let listener = tokio::net::TcpListener::bind(&addr)
269            .await
270            .map_err(|e| Error::internal(format!("failed to bind {addr}: {e}")))?;
271        self.serve_with_shutdown(listener, serve::shutdown_signal())
272            .await
273    }
274
275    /// Serve on an existing listener forever (tests, port 0, socket activation).
276    pub async fn serve_with(self, listener: tokio::net::TcpListener) -> Result<()> {
277        self.serve_with_shutdown(listener, std::future::pending())
278            .await
279    }
280
281    /// The serve engine: accept until `shutdown` resolves, then stop accepting,
282    /// drain in-flight connections (10s cap), and return.
283    pub async fn serve_with_shutdown(
284        self,
285        listener: tokio::net::TcpListener,
286        shutdown: impl std::future::Future<Output = ()> + Send,
287    ) -> Result<()> {
288        serve::run_with_shutdown(self, listener, shutdown).await
289    }
290}
291
292fn insert_flat(trie: &mut Trie, flat: FlatRoute) -> Result<()> {
293    let stream_body = flat.methods.stream_body;
294    let mut methods = HashMap::new();
295    for (m, h) in flat.methods.handlers {
296        if methods.insert(m.clone(), h).is_some() {
297            return Err(Error::internal(format!(
298                "duplicate method {m} for `{}`",
299                flat.path
300            )));
301        }
302    }
303    trie.insert(
304        &flat.path,
305        Endpoint {
306            methods,
307            env: flat.env,
308            middleware: flat.middleware,
309            body_limit: flat.body_limit,
310            stream_body,
311            handler_timeout: flat.handler_timeout,
312            body_read_timeout: flat.body_read_timeout,
313        },
314    )
315}
316
317/// The frozen, immutable runtime form. Cheap to share across connections.
318pub struct BuiltApp {
319    pub(crate) trie: Trie,
320    /// App-level providers only (those registered via `App::provide`/`provide_dep`).
321    /// Module-scoped providers live per-endpoint in the trie and are deliberately
322    /// absent here — `task_context` resolves against this app-level env alone.
323    pub(crate) app_env: Arc<DepEnv>,
324    pub(crate) overrides: Arc<HashMap<TypeId, AnyArc>>,
325    pub(crate) security_headers: bool,
326    /// Installed CORS policy (spec §v2.2). When set, `route_policy` answers a
327    /// CORS preflight `OPTIONS` directly — before the trie's 405 path.
328    pub(crate) cors: Option<std::sync::Arc<crate::cors::CorsConfig>>,
329    pub(crate) handler_timeout: std::time::Duration,
330    pub(crate) body_read_timeout: std::time::Duration,
331    pub(crate) write_stall_timeout: std::time::Duration,
332    /// App-level error-body mapper, applied at the dispatch exit to every error
333    /// response so framework errors render in the app's wire envelope.
334    pub(crate) error_mapper: Option<Arc<ErrorBodyMapper>>,
335}
336
337// The trie holds type-erased handler fns and overrides are `dyn Any`, so the
338// internals can't be formatted. A marker impl lets `build().unwrap()` work.
339impl std::fmt::Debug for BuiltApp {
340    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341        f.debug_struct("BuiltApp").finish_non_exhaustive()
342    }
343}
344
345/// The global request-body cap (spec §4.4). A route's `.body_limit` overrides
346/// it; absent that, this is the ceiling. Single source of truth for serve.rs
347/// and `route_policy`.
348pub(crate) const BODY_LIMIT: usize = 1024 * 1024; // 1 MiB
349
350/// The pre-read routing decision (spec §4.4 two-phase read). Computed from the
351/// request head ALONE — before a single body byte is read — so an unmatched
352/// path, wrong method, or malformed path never forces the body to be drained.
353pub(crate) enum Policy {
354    /// The route exists: read its body up to `limit`, then dispatch. When
355    /// `stream` is set the body is NOT collected upfront — serve hands the live
356    /// stream lane (`Limited` cap + per-frame deadline inside) to dispatch.
357    /// `body_read_timeout` is the resolved per-frame read deadline for THIS route
358    /// (per-route override of the app-global, #111), fed into the stream lane's
359    /// `TimedRecvBody` and the buffered read's collect timeout.
360    Route {
361        limit: usize,
362        stream: bool,
363        body_read_timeout: std::time::Duration,
364    },
365    /// The request is answered from the head alone (404 / 405 / 400). The body
366    /// is never read. The response already carries security headers.
367    Reject(Response),
368}
369
370/// Defaults chosen for API-only services; handler-set values always win.
371pub(crate) fn apply_security_headers(res: &mut Response) {
372    const DEFAULTS: [(&str, &str); 5] = [
373        ("x-content-type-options", "nosniff"),
374        ("x-frame-options", "DENY"),
375        ("referrer-policy", "no-referrer"),
376        ("content-security-policy", "default-src 'none'"),
377        ("cache-control", "no-store"),
378    ];
379    for (name, value) in DEFAULTS {
380        let header_name = http::HeaderName::from_static(name);
381        if !res.headers().contains_key(&header_name) {
382            res.headers_mut()
383                .insert(header_name, http::HeaderValue::from_static(value));
384        }
385    }
386}
387
388impl BuiltApp {
389    /// A [`TaskContext`] for resolving dependencies
390    /// OUTSIDE an HTTP request — background jobs, startup wiring, CLI commands.
391    ///
392    /// Only **app-level** dependencies (those registered with `App::provide` /
393    /// `App::provide_dep`) are resolvable; module-scoped providers are not in
394    /// scope here. Any factory that pulls an HTTP extractor (`Json`/`Path`/
395    /// `Query`/`Headers`) fails with `JC1003` — it needs a real request.
396    pub fn task_context(&self) -> crate::dep::TaskContext {
397        crate::dep::TaskContext::new(DepResolver::new(
398            self.app_env.clone(),
399            self.overrides.clone(),
400        ))
401    }
402
403    /// Reshape an error response's body through the app-level mapper — the seam
404    /// that lets an app render framework-emitted errors in its own wire envelope
405    /// (spec §4.1). A no-op unless a mapper is registered AND the response
406    /// carries [`ErrorParts`] (i.e. it IS an error — success responses are never
407    /// touched); a `None` from the mapper keeps the default body and its
408    /// `details`. Called at BOTH error exits (routing rejects + the handler
409    /// pipeline) so no framework error escapes it.
410    pub(crate) fn apply_error_mapper(&self, response: &mut Response) {
411        let Some(mapper) = &self.error_mapper else {
412            return;
413        };
414        let Some(parts) = response
415            .extensions()
416            .get::<crate::response::ErrorParts>()
417            .cloned()
418        else {
419            return;
420        };
421        if let Some(body) = mapper(response.status(), parts.code, &parts.message) {
422            let bytes = serde_json::to_vec(&body).unwrap_or_default();
423            *response.body_mut() = crate::response::JcBody::full(bytes);
424        }
425    }
426
427    /// Phase 1 of the two-phase read (spec §4.4): decide what to do with the
428    /// request from its HEAD alone, before any body byte is read. A match
429    /// yields the body limit to read up to; anything else yields a finished,
430    /// security-headered response so the caller can answer without draining
431    /// the body (routing wins over the body cap).
432    ///
433    /// Cost note: this walks the trie, and `dispatch` walks it again in phase
434    /// 2 (~650ns each). Threading the matched `&Endpoint` through would mean
435    /// holding a non-`'static` borrow across serve.rs's `tokio::spawn` panic
436    /// boundary — not possible without `Arc`-ing endpoints and rippling the
437    /// trie. The walk is cheap; we eat the double walk for v2.0b.
438    pub(crate) fn route_policy(&self, parts: &http::request::Parts) -> Policy {
439        let path = parts.uri.path();
440        // CORS preflight is answered HERE, before the trie's 405 path: an OPTIONS
441        // to a method-mismatched route would otherwise be rejected 405 before any
442        // middleware runs. The preflight 204 is returned DIRECTLY (not through the
443        // security-headering `reject` closure) — its only headers are CORS ones.
444        if let Some(config) = &self.cors
445            && crate::cors::is_preflight(parts)
446        {
447            let origin = parts
448                .headers
449                .get(http::header::ORIGIN)
450                .and_then(|v| v.to_str().ok())
451                .unwrap_or("");
452            if config.allows_origin(origin)
453                && let Some(methods) = self.trie.methods_for(path)
454            {
455                let acrh = parts
456                    .headers
457                    .get(http::header::ACCESS_CONTROL_REQUEST_HEADERS)
458                    .and_then(|v| v.to_str().ok());
459                return Policy::Reject(crate::cors::preflight_response(
460                    config, origin, acrh, &methods,
461                ));
462            }
463            // Disallowed origin or unknown path: bare 204 with NO CORS headers.
464            // The browser blocks the request; we leak neither a 404 nor a 405.
465            let mut r = http::Response::new(crate::response::JcBody::empty());
466            *r.status_mut() = http::StatusCode::NO_CONTENT;
467            return Policy::Reject(r);
468        }
469        let reject = |response: Response| -> Policy {
470            let mut response = response;
471            // Routing rejects (404/405/400) are framework-emitted errors too —
472            // reshape them through the app's envelope before decorating headers.
473            self.apply_error_mapper(&mut response);
474            if self.security_headers {
475                apply_security_headers(&mut response);
476            }
477            // A cross-origin request that 404/405/400s still carries the CORS
478            // headers, so the browser surfaces the real status to JS rather than
479            // hiding it behind a CORS error. The preflight branch above returns
480            // directly (its own complete response), so it is not double-decorated.
481            if let Some(config) = &self.cors {
482                crate::cors::apply_cors(
483                    &mut response,
484                    parts.headers.get(http::header::ORIGIN),
485                    config,
486                );
487            }
488            Policy::Reject(response)
489        };
490        match self.trie.find(path, &parts.method) {
491            RouteMatch::Found { endpoint, .. } => Policy::Route {
492                limit: endpoint.body_limit.unwrap_or(BODY_LIMIT),
493                stream: endpoint.stream_body,
494                body_read_timeout: endpoint.body_read_timeout.unwrap_or(self.body_read_timeout),
495            },
496            RouteMatch::NotFound => reject(Error::not_found().into_response()),
497            RouteMatch::MethodMissing => reject(Error::method_not_allowed().into_response()),
498            RouteMatch::Malformed => {
499                reject(Error::bad_request("malformed percent-encoding in path").into_response())
500            }
501        }
502    }
503
504    /// Route + run middleware chain + handler for one request, then apply
505    /// secure-by-default headers at the single dispatch exit (spec §4.4). The
506    /// body arrives as a [`BodyLane`]: `Buffered` for the upfront-read path,
507    /// `Stream` for `.stream_body()` routes.
508    pub(crate) async fn dispatch(&self, parts: http::request::Parts, lane: BodyLane) -> Response {
509        // Capture the request Origin BEFORE the ctx consumes `parts`, so the
510        // dispatch exit can decorate an actual cross-origin response with CORS
511        // headers (the other half of preflight, handled in `route_policy`).
512        let origin = parts.headers.get(http::header::ORIGIN).cloned();
513        let mut response = self.dispatch_inner(parts, lane).await;
514        // Reshape any error body (guard 401, extractor 4xx, handler Error) into
515        // the app's wire envelope before headers/CORS decorate it.
516        self.apply_error_mapper(&mut response);
517        if self.security_headers {
518            apply_security_headers(&mut response);
519        }
520        if let Some(config) = &self.cors {
521            crate::cors::apply_cors(&mut response, origin.as_ref(), config);
522        }
523        response
524    }
525
526    async fn dispatch_inner(&self, parts: http::request::Parts, lane: BodyLane) -> Response {
527        let method = parts.method.clone();
528        let path = parts.uri.path().to_string();
529        match self.trie.find(&path, &method) {
530            RouteMatch::NotFound => Error::not_found().into_response(),
531            RouteMatch::MethodMissing => Error::method_not_allowed().into_response(),
532            RouteMatch::Malformed => {
533                Error::bad_request("malformed percent-encoding in path").into_response()
534            }
535            RouteMatch::Found { endpoint, params } => {
536                // Per-route override of the app-global handler budget (#111); the
537                // route's `.handler_timeout(..)` wins, else the app default.
538                let budget = endpoint.handler_timeout.unwrap_or(self.handler_timeout);
539                let mut ctx = RequestCtx::with_lane(
540                    parts,
541                    lane,
542                    DepResolver::new(endpoint.env.clone(), self.overrides.clone()),
543                );
544                ctx.params = params;
545                let handler: &BoxHandlerFn = endpoint
546                    .methods
547                    .get(&method)
548                    .expect("find() checked the method");
549                let run = Next {
550                    chain: &endpoint.middleware,
551                    endpoint: handler,
552                }
553                .run(&mut ctx);
554                match tokio::time::timeout(budget, run).await {
555                    Ok(response) => response,
556                    Err(_) => Error::handler_timeout().into_response(),
557                }
558            }
559        }
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use crate::response::Json;
567    use crate::router::get;
568    use crate::{Dep, Path};
569    use std::sync::Mutex;
570
571    #[derive(Default)]
572    struct Store {
573        items: Mutex<Vec<String>>,
574    }
575
576    async fn list(store: Dep<Store>) -> Json<Vec<String>> {
577        Json(store.items.lock().unwrap().clone())
578    }
579
580    async fn create(store: Dep<Store>, Json(item): Json<String>) -> crate::Result<Json<usize>> {
581        let mut items = store.items.lock().unwrap();
582        items.push(item);
583        Ok(Json(items.len()))
584    }
585
586    async fn show(store: Dep<Store>, Path(ix): Path<usize>) -> crate::Result<Json<String>> {
587        store
588            .items
589            .lock()
590            .unwrap()
591            .get(ix)
592            .cloned()
593            .map(Json)
594            .ok_or_else(Error::not_found)
595    }
596
597    fn crud_app() -> App {
598        App::new().provide(Store::default()).mount(
599            "/todos",
600            Module::new("todos")
601                .route("/", get(list).post(create))
602                .route("/{ix}", get(show)),
603        )
604    }
605
606    async fn dispatch(built: &BuiltApp, method: http::Method, path: &str, body: &str) -> Response {
607        let req = http::Request::builder()
608            .method(method)
609            .uri(path)
610            .body(())
611            .unwrap();
612        let (parts, ()) = req.into_parts();
613        built
614            .dispatch(parts, BodyLane::Buffered(Bytes::from(body.to_string())))
615            .await
616    }
617
618    #[tokio::test]
619    async fn crud_round_trip_in_process() {
620        let built = crud_app().build().unwrap();
621        let r = dispatch(&built, http::Method::POST, "/todos/", r#""write spike""#).await;
622        assert_eq!(r.status(), http::StatusCode::OK);
623        let r = dispatch(&built, http::Method::GET, "/todos/0", "").await;
624        assert_eq!(r.status(), http::StatusCode::OK);
625        let r = dispatch(&built, http::Method::GET, "/todos/9", "").await;
626        assert_eq!(r.status(), http::StatusCode::NOT_FOUND);
627        let r = dispatch(&built, http::Method::PATCH, "/todos/", "").await;
628        assert_eq!(r.status(), http::StatusCode::METHOD_NOT_ALLOWED);
629        let r = dispatch(&built, http::Method::GET, "/nope", "").await;
630        assert_eq!(r.status(), http::StatusCode::NOT_FOUND);
631    }
632
633    async fn body_string(r: Response) -> String {
634        use http_body_util::BodyExt;
635        let bytes = r.into_body().collect().await.unwrap().to_bytes();
636        String::from_utf8(bytes.to_vec()).unwrap()
637    }
638
639    /// A registered `map_error_body` reshapes FRAMEWORK-emitted error bodies (the
640    /// guard 401, a routing 404) into the app's own wire envelope — the whole
641    /// point of issue #13: those errors never pass through a handler, so an app
642    /// with a `{"error":{code,message}}` envelope could not otherwise make them
643    /// conform. Without a mapper the flat body is unchanged; a mapper returning
644    /// `None` keeps the default body (and its `details`).
645    #[tokio::test]
646    async fn registered_error_mapper_reshapes_framework_error_bodies() {
647        async fn guarded() -> crate::Result<&'static str> {
648            Err(Error::unauthorized()) // stands in for the auth guard's 401
649        }
650        let secure = || Module::new("secure").route("/", get(guarded));
651
652        let mapped = App::new()
653            .map_error_body(|_status, code, message| {
654                Some(serde_json::json!({ "error": { "code": code, "message": message } }))
655            })
656            .mount("/secure", secure())
657            .build()
658            .unwrap();
659        // The framework guard 401 renders in the app's envelope.
660        let r = dispatch(&mapped, http::Method::GET, "/secure/", "").await;
661        assert_eq!(r.status(), http::StatusCode::UNAUTHORIZED);
662        assert_eq!(
663            body_string(r).await,
664            r#"{"error":{"code":"JC0401","message":"authentication required"}}"#
665        );
666        // A routing 404 (framework-emitted, never reaches a handler) too.
667        let r = dispatch(&mapped, http::Method::GET, "/nope", "").await;
668        let body = body_string(r).await;
669        assert!(
670            body.contains(r#""error""#) && body.contains("JC0404"),
671            "routing 404 must be reshaped: {body}"
672        );
673
674        // No mapper → the flat default body, unchanged.
675        let plain = App::new().mount("/secure", secure()).build().unwrap();
676        let r = dispatch(&plain, http::Method::GET, "/secure/", "").await;
677        assert_eq!(
678            body_string(r).await,
679            r#"{"code":"JC0401","message":"authentication required"}"#
680        );
681    }
682
683    /// A mapper that returns `None` for an error leaves the default body — and
684    /// its `details` — intact, so an app can remap only the errors it cares about.
685    #[tokio::test]
686    async fn error_mapper_returning_none_preserves_the_default_body_and_details() {
687        async fn detailed() -> crate::Result<&'static str> {
688            Err(Error::unprocessable("bad")
689                .with_details(serde_json::json!([{ "field": "x", "message": "required" }])))
690        }
691        let built = App::new()
692            .map_error_body(|_s, _c, _m| None)
693            .mount("/v", Module::new("v").route("/", get(detailed)))
694            .build()
695            .unwrap();
696        let body = body_string(dispatch(&built, http::Method::GET, "/v/", "").await).await;
697        assert!(
698            body.contains("JC0422") && body.contains("details") && body.contains("\"field\""),
699            "None must keep the default body with details: {body}"
700        );
701    }
702
703    #[test]
704    fn conflicting_routes_fail_at_build_not_at_request_time() {
705        let app = App::new()
706            .route("/x", get(|| async { "a" }))
707            .route("/x", get(|| async { "b" }));
708        let err = app.build().unwrap_err();
709        assert!(err.message().contains("/x"));
710    }
711
712    #[test]
713    fn wildcard_origin_with_credentials_is_a_build_error() {
714        let err = App::new()
715            .cors(
716                crate::cors::CorsConfig::new(crate::cors::CorsOrigins::any())
717                    .allow_credentials(true),
718            )
719            .build()
720            .unwrap_err();
721        assert!(
722            err.to_string().to_lowercase().contains("credential"),
723            "{err}"
724        );
725    }
726
727    #[test]
728    fn allowlist_origin_with_credentials_builds() {
729        assert!(
730            App::new()
731                .cors(
732                    crate::cors::CorsConfig::new(crate::cors::CorsOrigins::list([
733                        "https://app.example"
734                    ]))
735                    .allow_credentials(true)
736                )
737                .build()
738                .is_ok()
739        );
740    }
741
742    #[tokio::test]
743    async fn extensions_register_through_extend() {
744        struct Greeting(&'static str);
745        struct GreetingExt;
746        impl Extension for GreetingExt {
747            fn register(self, app: App) -> App {
748                app.provide(Greeting("from-extension"))
749            }
750        }
751        async fn read(g: crate::Dep<Greeting>) -> String {
752            // `Dep`'s own `.0` (the inner `Arc`) is `pub(crate)`, so inside this
753            // crate it shadows the field access; deref explicitly to the value.
754            (*g).0.to_string()
755        }
756        let t = App::new()
757            .extend(GreetingExt)
758            .route("/", crate::router::get(read))
759            .into_test();
760        assert_eq!(t.get("/").await.text(), "from-extension");
761    }
762
763    #[test]
764    fn accept_error_classification_matches_unix_reality() {
765        use std::io::{Error as IoError, ErrorKind};
766        for transient in [
767            IoError::from(ErrorKind::ConnectionAborted),
768            IoError::from(ErrorKind::ConnectionReset),
769            IoError::from(ErrorKind::Interrupted),
770            IoError::from_raw_os_error(24), // EMFILE
771            IoError::from_raw_os_error(23), // ENFILE
772        ] {
773            assert!(is_transient_accept_error(&transient), "{transient:?}");
774        }
775        assert!(!is_transient_accept_error(&IoError::from(
776            ErrorKind::InvalidInput
777        )));
778        assert!(!is_transient_accept_error(&IoError::from(
779            ErrorKind::PermissionDenied
780        )));
781    }
782}