arcature 0.1.0

Arcature: an opinionated full-stack Rust web framework. One package, batteries included.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
//! The request pipeline: the order in which layers wrap the router.
//!
//! # The order is a contract
//!
//! An application's behaviour depends on layer order as much as on which
//! layers are present, so the order is fixed here rather than being whatever
//! order the builder methods happened to be called in. `.inertia()` before
//! `.csrf()` and `.csrf()` before `.inertia()` produce the same pipeline.
//!
//! Outermost first — a request travels down this list and a response travels
//! back up it:
//!
//! | # | Stage | Why here |
//! |---|---|---|
//! | 1 | `DevProxy` | Vite requests must never reach application routing. Outermost so it also catches the HMR WebSocket upgrade. Only with the `dev-proxy` feature, and a pass-through unless `arc dev` set an IPC endpoint. |
//! | 2 | `Proxy` | Pre-routing: it rewrites the URI, so it has to run before route selection or the rewrite would miss. |
//! | 3 | `Health` | Merged *beside* the application router rather than layered over it, so `/up/live` and `/up/ready` answer from the lifecycle alone — no session load, no maintenance `503`, no access-log line, on a request an orchestrator makes every few seconds. See [`crate::application::health`]. |
//! | 4 | `UagEndpoint` | Merged beside the router for the same reason health is, one stage further in: `/_arcature/uag.json` describes the process, so it must not be shaped by a session, a maintenance `503` or a rate limit. Only with the `uag` feature, only after an explicit `.uag_endpoint(..)`, and only in a debug build. See [`crate::application::uag_endpoint`]. |
//! | 5 | `Compression` | Outermost of the response-shaping layers, so it sees the final body — including one a layer below produced instead of a handler. |
//! | 6 | `SecurityHeaders` | Outside the body limit and the timeout **on purpose**: a `413` and a `408` are responses a browser renders too, and they need `nosniff` and a framing policy as much as a page does. |
//! | 7 | `CORS` | Answers a preflight without waking anything below. Inside `SecurityHeaders` so the preflight response still carries them. |
//! | 8 | `RequestId` | Before the access log, which reads the id out of extensions — and before everything that can produce a response, so every response carries `x-request-id`. |
//! | 9 | `AccessLog` | Directly inside `RequestId`. Outside the panic catcher, the body limit and the timeout, so a `500`, a `413` and a `408` are all logged rather than vanishing. |
//! | 10 | `CatchPanic` | Turns a panic below into a `500` instead of a dropped connection. Inside the access log so the `500` is recorded; outside everything that runs application code. |
//! | 11 | `ErrorMapping` | Gives an RFC 9457 body to every bodiless error produced below it — the bare `404`, `405`, `408` and `413` that axum and `tower-http` emit — and redacts `text/plain` 5xx bodies in release. Inside the panic catcher, which already produces a `Problem`. |
//! | 12 | `BodyLimit` | Before anything that reads a body, so an oversized upload is rejected without being buffered. |
//! | 13 | `Timeout` | Bounds everything inside it. Outside the router so a slow handler cannot hold a connection open. |
//! | 14 | `Maintenance` | Outside the session and CSRF: a maintenance `503` must not depend on a session store that may be part of what is being maintained, and a form POST arriving during the window must get the `503` rather than a CSRF `419`. |
//! | 15 | `RateLimit` | Inside maintenance, so a request answered by a maintenance `503` costs no quota, and outside the session, so a refused request never touches the session store. After the health merge -- a throttled health probe is a self-inflicted outage. |
//! | 16 | `Session` | Must load the session before CSRF (token lookup) and before handlers extract it. |
//! | 17 | `CSRF` | After the session, before the handler: an unsafe request is rejected before it can act. |
//! | 18 | `Inertia` | Inserts `InertiaConfig` and `InertiaRequest` into extensions; the `Inertia` extractor fails without it. Innermost of the framework layers so a rejection from CSRF or a timeout is *not* dressed up as an Inertia response. |
//! | 19 | `PageContracts` | An extension carrying the [`ContractArtifact`](crate::inertia::contracts::ContractArtifact), for the dev-only UAG endpoint and `arc typegen` to read. Data, not behaviour, so its position only has to be somewhere a handler can see it. |
//! | 20 | `RedirectMapper` | Finishes a [`RedirectResponse`](crate::http::response::RedirectResponse): resolves `redirect().route(..)` against the route table and writes the flash data through the session. Inside `Inertia` because Inertia's 303-for-`PUT`/`PATCH`/`DELETE` rule has to see the *finished* redirect, not the placeholder `into_response` produced; inside `Session` because that is where the flash goes; outside the router because the builder is only readable once the handler has returned. Installed by default -- an application that never redirects by name pays one extension lookup per response. See [`crate::routing::redirect_mapper`]. |
//! | 21 | user `.layer()`s | Applied in call order, wrapping the router directly. Innermost by design: a user layer sees a request that has already been limited, timed, and authenticated. |
//! | 22 | Router | Route matching and the handler. |
//! | 23 | `StaticFiles` | The router's *fallback*, so it only sees requests no route matched. Inside every layer above, which is what gives a served file the same compression, headers and access log as a handler response. |
//!
//! Stages 1 and 2 wrap the router *as a service* (they are `tower::Layer`s
//! over the whole `axum::Router`, applied after `with_state`); stages 5-21
//! wrap it as a `Router`. That split is why this module has two functions
//! rather than one. Stages 3 and 4 are neither: they are `merge`s, which is
//! exactly what makes them exempt from the layers below them.
//!
//! Every stage from 5 down is **off unless asked for**, with one exception:
//! stage 20 is on unless refused, because `redirect().route(..)` reading as
//! broken in a default build is not a decision anyone would make on purpose.
//! An application that calls nothing but `.routes()` gets a bare router, the
//! health endpoints and the redirect mapper, which is what makes the order
//! above readable: every other entry is a decision someone made.
//!
//! # Where the layers come from
//!
//! [`RouterLayer`] type-erases each layer to `Fn(Router<S>) -> Router<S>`,
//! which is what lets `InertiaLayer`, `SessionManagerLayer`, `CsrfLayer` and a
//! user's own `tower::Layer` — none of which share a type — sit in one ordered
//! struct.

use crate::routing::{RouterLayer, RouterState};
use axum::Router;

/// The layers an [`ApplicationBuilder`](super::ApplicationBuilder) has
/// collected, held in slots rather than in call order so that
/// [`Pipeline::apply`] can impose the documented order.
pub(crate) struct Pipeline<S: RouterState> {
    /// The health endpoints, merged outside every router-level stage.
    /// `None` only when the application called `.health(false)`.
    pub health: Option<crate::application::health::Health>,
    /// The dev-only application-graph endpoint, merged just inside health.
    /// `Some` only after an explicit `.uag_endpoint(..)` in a debug build.
    #[cfg(feature = "uag")]
    pub uag: Option<crate::application::uag_endpoint::UagEndpoint>,
    /// Response compression, enabled by `.compression()`.
    pub compression: bool,
    /// Response security headers, set by `.security_headers(..)`.
    pub security_headers: Option<crate::http::SecurityHeaders>,
    /// The CORS layer, built by `.cors(..)`.
    pub cors: Option<RouterLayer<S>>,
    /// Request-id generation and echo, enabled by `.request_id()`.
    #[cfg(feature = "observe")]
    pub request_id: bool,
    /// One access-log line per request, enabled by `.access_log()`.
    #[cfg(feature = "observe")]
    pub access_log: bool,
    /// Turn a panic into a `500`, enabled by `.catch_panic()`.
    pub catch_panic: bool,
    /// Error-response mapping, set by `.error_mapping(..)`.
    pub error_mapping: Option<crate::http::ErrorMapping>,
    /// Maximum request body size in bytes. `None` leaves the body unbounded.
    pub body_limit: Option<usize>,
    /// Whole-request timeout. `None` leaves requests unbounded.
    pub timeout: Option<std::time::Duration>,
    /// The maintenance switch, set by `.maintenance(..)`.
    pub maintenance: Option<crate::http::Maintenance>,
    /// The application-wide rate limit, set by `.rate_limit(..)`.
    pub rate_limit: Option<crate::routing::RateLimit>,
    /// The session layer, built by `.session(config, store)`.
    pub session: Option<RouterLayer<S>>,
    /// The CSRF layer, built by `.csrf(config)`.
    pub csrf: Option<RouterLayer<S>>,
    /// The Inertia layer, built by `.inertia(config)`.
    pub inertia: Option<RouterLayer<S>>,
    /// The page-contract artifact, set by `.page_contracts(..)`.
    #[cfg(feature = "inertia")]
    pub page_contracts: Option<std::sync::Arc<crate::inertia::contracts::ContractArtifact>>,
    /// The named-route redirect resolver, built by `build()` from the route
    /// table. `None` only when the application called `.redirect_mapper(false)`.
    pub redirect_mapper: Option<crate::routing::RedirectMapper>,
    /// User layers, in the order `.layer()` was called.
    pub user: Vec<RouterLayer<S>>,
    /// The document-root file server, installed as the router's fallback.
    /// `None` leaves whatever fallback the routes defined.
    pub static_files: Option<crate::assets::StaticFiles>,
}

impl<S: RouterState> Pipeline<S> {
    /// An empty pipeline: no framework layers, no user layers.
    pub fn new() -> Self {
        Pipeline {
            health: None,
            #[cfg(feature = "uag")]
            uag: None,
            compression: false,
            security_headers: None,
            cors: None,
            #[cfg(feature = "observe")]
            request_id: false,
            #[cfg(feature = "observe")]
            access_log: false,
            catch_panic: false,
            error_mapping: None,
            body_limit: None,
            timeout: None,
            maintenance: None,
            rate_limit: None,
            session: None,
            csrf: None,
            inertia: None,
            #[cfg(feature = "inertia")]
            page_contracts: None,
            redirect_mapper: None,
            user: Vec::new(),
            static_files: None,
        }
    }

    /// Wrap `router` in the router-level stages (5 through 21 in the table
    /// above) and merge the two exempt routers -- the UAG endpoint (stage 4)
    /// and the health endpoints (stage 3) -- beside the result.
    ///
    /// `Router::layer` wraps everything already on the router, so the *last*
    /// layer applied ends up outermost. The stages are therefore applied
    /// inside-out: user layers first, compression last.
    pub fn apply(self, router: Router<S>) -> Router<S> {
        // 23 — the document root, as the router's fallback. Set first so
        // every layer below wraps it too: a file served from `public/` gets
        // the same treatment as a handler response.
        let router = match self.static_files {
            Some(service) => router.fallback_service(service),
            None => router,
        };

        // 21 — user layers. Applied in reverse of call order so that the first
        // `.layer()` call ends up outermost among them, matching the reading
        // order of the builder chain.
        let router = self
            .user
            .into_iter()
            .rev()
            .fold(router, |router, layer| layer.apply(router));

        // 20 — the redirect mapper. Outside the user layers so that a
        // redirect a user layer produced is finished too, and inside Inertia
        // so Inertia sees a real `Location` and a real status when it decides
        // whether a redirect after a `PUT` has to become a `303`.
        let router = match self.redirect_mapper {
            Some(mapper) => router.layer(mapper),
            None => router,
        };

        // 19 — the page-contract artifact, as a request extension.
        #[cfg(feature = "inertia")]
        let router = match self.page_contracts {
            Some(artifact) => router.layer(axum::Extension(artifact)),
            None => router,
        };

        // 18 — Inertia.
        let router = match self.inertia {
            Some(layer) => layer.apply(router),
            None => router,
        };

        // 17 — CSRF.
        let router = match self.csrf {
            Some(layer) => layer.apply(router),
            None => router,
        };

        // 16 — session.
        let router = match self.session {
            Some(layer) => layer.apply(router),
            None => router,
        };

        // 15 — the rate limit.
        let router = match self.rate_limit {
            Some(limit) => router.layer(limit),
            None => router,
        };

        // 14 — maintenance.
        let router = match self.maintenance {
            Some(maintenance) => router.layer(maintenance),
            None => router,
        };

        // 13 — timeout.
        let router = match self.timeout {
            Some(duration) => router.layer(tower_http::timeout::TimeoutLayer::with_status_code(
                axum::http::StatusCode::REQUEST_TIMEOUT,
                duration,
            )),
            None => router,
        };

        // 12 — body limit.
        let router = match self.body_limit {
            Some(bytes) => router.layer(tower_http::limit::RequestBodyLimitLayer::new(bytes)),
            None => router,
        };

        // 11 — error mapping.
        let router = match self.error_mapping {
            Some(mapping) => router.layer(mapping),
            None => router,
        };

        // 10 — panic catcher. The default responder is replaced so the body is
        // a `Problem`, not the panic message: a panic payload routinely
        // carries a file path, a SQL fragment, or a value that was never meant
        // to leave the process.
        let router = if self.catch_panic {
            router.layer(tower_http::catch_panic::CatchPanicLayer::custom(
                panic_response,
            ))
        } else {
            router
        };

        // 9 — access log.
        #[cfg(feature = "observe")]
        let router = if self.access_log {
            router.layer(crate::observe::AccessLogLayer)
        } else {
            router
        };

        // 8 — request id.
        #[cfg(feature = "observe")]
        let router = if self.request_id {
            router.layer(crate::observe::RequestIdLayer)
        } else {
            router
        };

        // 7 — CORS.
        let router = match self.cors {
            Some(layer) => layer.apply(router),
            None => router,
        };

        // 6 — security headers.
        let router = match self.security_headers {
            Some(headers) => router.layer(headers),
            None => router,
        };

        // 5 — compression.
        let router = if self.compression {
            router.layer(tower_http::compression::CompressionLayer::new())
        } else {
            router
        };

        // 4 — the application-graph endpoint, merged rather than layered for
        // the same reason health is: it describes the process, so a session
        // load, a maintenance `503` or a rate limit would all be answering a
        // different question than the one asked. On the left of the merge,
        // like health, so the application router's fallback is the one that
        // survives.
        #[cfg(feature = "uag")]
        let router = match self.uag {
            Some(uag) => uag.router::<S>().merge(router),
            None => router,
        };

        // 3 — the health endpoints, merged rather than layered. This is the
        // whole point: an orchestrator's probe must not depend on a session
        // store, must not be turned into a maintenance `503`, and must not
        // write an access-log line every two seconds.
        match self.health {
            // Health on the *left*: `Router::merge` resolves two default
            // fallbacks by taking the right-hand one, and the application
            // router's default fallback is the one the stages above have
            // been layered onto. Merged the other way round, every bodiless
            // `404` would escape `ErrorMapping`, the access log and the
            // security headers.
            Some(health) => health.router::<S>().merge(router),
            None => router,
        }
    }
}

/// Turn a caught panic into an RFC 9457 `Problem`.
///
/// The payload is deliberately discarded rather than reported: a panic message
/// is written for a developer reading a backtrace, and routinely contains a
/// file path, a SQL fragment, or the value that caused the panic. The details
/// still reach the operator -- `tower-http` logs the panic and its backtrace --
/// they just do not reach the client.
fn panic_response(_payload: Box<dyn std::any::Any + Send + 'static>) -> axum::response::Response {
    use axum::response::IntoResponse as _;
    crate::api::Problem::of(crate::api::ProblemKind::Internal).into_response()
}

impl<S: RouterState> Default for Pipeline<S> {
    fn default() -> Self {
        Self::new()
    }
}

/// Wrap a stateless router in the service-level stages (1 and 2 in the table
/// above) and return the service to serve.
///
/// Both stages are zero-overhead pass-throughs when unconfigured: `ProxyLayer`
/// with `None` forwards, and `DevProxyLayer` with no endpoint forwards. This
/// is shared by [`Application::serve`](super::Application::serve) and
/// [`Application::run_with_state`](super::Application::run_with_state) so the
/// two entry points cannot drift apart.
#[cfg(feature = "macros")]
pub(crate) fn compose_service(
    router: Router<()>,
    proxy: Option<crate::proxy::ProxyFn>,
    #[cfg(feature = "dev-proxy")] dev_proxy: Option<crate::dev_proxy::endpoint::IpcEndpoint>,
) -> impl tower::Service<
    axum::extract::Request,
    Response = axum::response::Response,
    Error = std::convert::Infallible,
    Future: Send,
> + Clone
+ Send
+ 'static {
    use tower::Layer as _;

    // 2 — the pre-routing proxy, immediately outside the router.
    let service = crate::proxy::ProxyLayer::new(proxy).layer(router.into_service());

    // 1 — the dev proxy, outermost.
    #[cfg(feature = "dev-proxy")]
    let service = crate::dev_proxy::DevProxyLayer::new(dev_proxy).layer(service);

    service
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn an_empty_pipeline_leaves_the_router_alone() {
        let router: Router<()> = Router::new().route("/", axum::routing::get(|| async { "ok" }));
        let _ = Pipeline::new().apply(router);
    }

    /// The pipeline that a plain `.routes(..).build()` produces has to finish
    /// a named-route redirect, because stage 20 is on unless refused.
    #[tokio::test]
    async fn a_default_application_resolves_a_named_route_redirect() {
        use tower::ServiceExt as _;

        let routes: crate::routing::Routes = crate::routing::Routes::new([
            crate::routing::Route::get("/users/{id}", || async { "user" }).name("users.show"),
            crate::routing::Route::get("/go", || async {
                crate::http::response::redirect().route("users.show", 7u64)
            }),
        ]);
        let router = crate::application::Application::new()
            .routes(routes)
            .build()
            .into_router();

        let response = router
            .oneshot(
                axum::http::Request::builder()
                    .uri("/go")
                    .body(axum::body::Body::empty())
                    .expect("a GET with an empty body is a valid request"),
            )
            .await
            .expect("the router is infallible");

        assert_eq!(
            response
                .headers()
                .get(axum::http::header::LOCATION)
                .and_then(|value| value.to_str().ok()),
            Some("/users/7"),
            "the mapper should have turned the name into a path"
        );
    }

    /// The escape hatch has to actually remove the layer, or an application
    /// that installs its own mapper ends up running two.
    #[tokio::test]
    async fn refusing_the_mapper_leaves_the_documented_unmapped_failure() {
        use tower::ServiceExt as _;

        let routes: crate::routing::Routes = crate::routing::Routes::new([
            crate::routing::Route::get("/users/{id}", || async { "user" }).name("users.show"),
            crate::routing::Route::get("/go", || async {
                crate::http::response::redirect().route("users.show", 7u64)
            }),
        ]);
        let router = crate::application::Application::new()
            .routes(routes)
            .redirect_mapper(false)
            .build()
            .into_router();

        let response = router
            .oneshot(
                axum::http::Request::builder()
                    .uri("/go")
                    .body(axum::body::Body::empty())
                    .expect("a GET with an empty body is a valid request"),
            )
            .await
            .expect("the router is infallible");

        assert_eq!(
            response.status(),
            axum::http::StatusCode::BAD_REQUEST,
            "without the mapper a named route is the fallback `400`"
        );
    }

    /// Routes merged after the first call have to be in the snapshot too,
    /// which is why the mapper is built in `build()` and not in `.routes()`.
    #[tokio::test]
    async fn a_name_declared_by_merge_routes_is_still_resolvable() {
        use tower::ServiceExt as _;

        let first: crate::routing::Routes =
            crate::routing::Routes::new([crate::routing::Route::get("/go", || async {
                crate::http::response::redirect().route("users.show", 7u64)
            })]);
        let second: crate::routing::Routes =
            crate::routing::Routes::new([crate::routing::Route::get("/users/{id}", || async {
                "user"
            })
            .name("users.show")]);
        let router = crate::application::Application::new()
            .routes(first)
            .merge_routes(second)
            .build()
            .into_router();

        let response = router
            .oneshot(
                axum::http::Request::builder()
                    .uri("/go")
                    .body(axum::body::Body::empty())
                    .expect("a GET with an empty body is a valid request"),
            )
            .await
            .expect("the router is infallible");

        assert_eq!(
            response
                .headers()
                .get(axum::http::header::LOCATION)
                .and_then(|value| value.to_str().ok()),
            Some("/users/7")
        );
    }
}