Skip to main content

rainier_framework/
groups.rs

1//! The middleware groups Rainier ships — `web` and `api`, as
2//! functions rather than as keys in a map.
3//!
4//! ```ignore
5//! use rainier_framework::groups;
6//!
7//! router.group(GroupAttributes::new().middleware(groups::web()), |router| {
8//!     router.get("/", home);
9//! });
10//! ```
11//!
12//! Three things follow from a group being a function.
13//!
14//! **You can find it.** "Go to definition" on `groups::web()` lands on the
15//! list. A string key would send you grepping the kernel instead.
16//!
17//! **You cannot misspell it.** `groups::wev()` does not compile. `'wev'` boots
18//! fine and serves every page in that group with no session and no security
19//! headers.
20//!
21//! **Overriding is composition, not replacement.** Reassigning a
22//! `'web'` key in a middleware map replaces the whole list, which is how
23//! people accidentally drop `StartSession` while adding one thing. Here an
24//! application writes its own function and says what it is starting from:
25//!
26//! ```ignore
27//! pub fn web() -> MiddlewareStack {
28//!     rainier_framework::groups::web().with(RequestId::new())
29//! }
30//! ```
31
32use std::sync::Arc;
33
34use rainier_middleware::{
35    AddHeaders, ConvertEmptyStringsToNull, HandleCors, MiddlewareStack, ThrottleRequests,
36    TrimStrings, TrustProxies,
37};
38use rainier_session::{SessionManager, StartSession};
39
40/// Security headers plus a session — what a browser-facing page wants.
41///
42/// The session stage is [resolved from the
43/// container](rainier_middleware::MiddlewareStack::resolved) when the router
44/// compiles, because the store is chosen in `bootstrap.rs` and does not exist
45/// while routes are being declared.
46pub fn web() -> MiddlewareStack {
47    MiddlewareStack::new().with(AddHeaders::security_defaults()).with_stack(session())
48}
49
50/// CORS plus a rate limit — what a JSON API wants.
51///
52/// No session on purpose: an API authenticates per request with a token, so a
53/// session row and a `Set-Cookie` per call would be pure overhead.
54pub fn api() -> MiddlewareStack {
55    api_throttled(60)
56}
57
58/// [`api`] with an explicit per-minute limit.
59///
60/// The thing a name in a registry could not do: a group that takes an argument.
61/// A string-keyed registry spells this `'throttle:60,1'` and parses the limit
62/// back out of the name.
63pub fn api_throttled(per_minute: u32) -> MiddlewareStack {
64    MiddlewareStack::new()
65        .with(HandleCors::any_origin())
66        .with(ThrottleRequests::per_minute(per_minute))
67}
68
69/// Start a session, using whichever store the application bound.
70pub fn session() -> MiddlewareStack {
71    MiddlewareStack::new()
72        .resolved(|manager: Arc<SessionManager>| StartSession::from_manager(&manager))
73}
74
75/// Normalise input — trim strings, and turn `""` into `null`.
76///
77/// Registered globally by the builder. Here as a value too, so an application
78/// that wants it on some routes and not others can say so.
79pub fn normalise_input() -> MiddlewareStack {
80    MiddlewareStack::new().with(TrimStrings::new()).with(ConvertEmptyStringsToNull)
81}
82
83/// Trust `X-Forwarded-*` from the loopback and the private ranges.
84///
85/// The shape of "a proxy on this host or this network", which is where a proxy
86/// nearly always is. For anything else name them:
87/// `TrustProxies::these(["10.0.0.0/8"])`, and read
88/// [the deployment notes](https://github.com/safewords/rainier-framework/blob/main/docs/deployment.md)
89/// before reaching for `TrustProxies::all()`.
90pub fn trust_local_proxies() -> MiddlewareStack {
91    MiddlewareStack::new().with(TrustProxies::these([
92        "127.0.0.0/8",
93        "::1",
94        "10.0.0.0/8",
95        "172.16.0.0/12",
96        "192.168.0.0/16",
97        "fd00::/8",
98    ]))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use rainier_container::Container;
105    use rainier_session::MemorySessionStore;
106
107    #[test]
108    fn web_is_security_headers_and_a_session() {
109        assert_eq!(web().labels(), vec!["AddHeaders", "StartSession"]);
110    }
111
112    #[test]
113    fn api_has_no_session() {
114        // The property worth pinning: a group that quietly started sessions
115        // would put a row and a `Set-Cookie` on every API call.
116        assert!(!api().labels().contains(&"StartSession"));
117        assert_eq!(api().labels(), vec!["HandleCors", "ThrottleRequests"]);
118    }
119
120    #[test]
121    fn a_group_can_take_an_argument() {
122        // What a name in a registry cannot do without parsing itself back out
123        // of a string.
124        assert_eq!(api_throttled(10).len(), 2);
125    }
126
127    #[test]
128    fn the_session_stage_is_built_from_the_container() {
129        let container = Container::new();
130        container.instance(SessionManager::new(Arc::new(MemorySessionStore::default())));
131
132        let built = session().resolve(&container).expect("resolves");
133        assert_eq!(built.len(), 1);
134    }
135
136    #[test]
137    fn a_missing_session_manager_fails_with_a_readable_message() {
138        let err = session().resolve(&Container::new()).err().expect("should fail");
139
140        assert!(err.message().contains("StartSession"), "{}", err.message());
141        assert!(err.message().contains("SessionManager"), "{}", err.message());
142    }
143
144    #[test]
145    fn an_application_extends_a_group_rather_than_replacing_it() {
146        // The mistake a string-keyed map invites: adding one thing to `web` by
147        // rewriting the list, and dropping the session on the way.
148        let extended = web().with(AddHeaders::new());
149
150        assert_eq!(extended.labels(), vec!["AddHeaders", "StartSession", "AddHeaders"]);
151    }
152}