Skip to main content

rainier_framework/
groups.rs

1//! The middleware groups Rainier ships — Laravel's `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. Finding out what `'web'` means in Laravel means grepping the kernel.
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.** Laravel's
22//! `$middlewareGroups['web'] = [...]` 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/// Laravel spells this `'throttle:60,1'` and parses it back out of the string.
62pub fn api_throttled(per_minute: u32) -> MiddlewareStack {
63    MiddlewareStack::new()
64        .with(HandleCors::any_origin())
65        .with(ThrottleRequests::per_minute(per_minute))
66}
67
68/// Start a session, using whichever store the application bound.
69pub fn session() -> MiddlewareStack {
70    MiddlewareStack::new()
71        .resolved(|manager: Arc<SessionManager>| StartSession::from_manager(&manager))
72}
73
74/// Normalise input — trim strings, and turn `""` into `null`.
75///
76/// Registered globally by the builder. Here as a value too, so an application
77/// that wants it on some routes and not others can say so.
78pub fn normalise_input() -> MiddlewareStack {
79    MiddlewareStack::new().with(TrimStrings::new()).with(ConvertEmptyStringsToNull)
80}
81
82/// Trust `X-Forwarded-*` from the loopback and the private ranges.
83///
84/// The shape of "a proxy on this host or this network", which is where a proxy
85/// nearly always is. For anything else name them:
86/// `TrustProxies::these(["10.0.0.0/8"])`, and read
87/// [the deployment notes](https://github.com/safewords/rainier-framework/blob/main/docs/deployment.md)
88/// before reaching for `TrustProxies::all()`.
89pub fn trust_local_proxies() -> MiddlewareStack {
90    MiddlewareStack::new().with(TrustProxies::these([
91        "127.0.0.0/8",
92        "::1",
93        "10.0.0.0/8",
94        "172.16.0.0/12",
95        "192.168.0.0/16",
96        "fd00::/8",
97    ]))
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use rainier_container::Container;
104    use rainier_session::MemorySessionStore;
105
106    #[test]
107    fn web_is_security_headers_and_a_session() {
108        assert_eq!(web().labels(), vec!["AddHeaders", "StartSession"]);
109    }
110
111    #[test]
112    fn api_has_no_session() {
113        // The property worth pinning: a group that quietly started sessions
114        // would put a row and a `Set-Cookie` on every API call.
115        assert!(!api().labels().contains(&"StartSession"));
116        assert_eq!(api().labels(), vec!["HandleCors", "ThrottleRequests"]);
117    }
118
119    #[test]
120    fn a_group_can_take_an_argument() {
121        // What a name in a registry cannot do without parsing itself back out
122        // of a string.
123        assert_eq!(api_throttled(10).len(), 2);
124    }
125
126    #[test]
127    fn the_session_stage_is_built_from_the_container() {
128        let container = Container::new();
129        container.instance(SessionManager::new(Arc::new(MemorySessionStore::default())));
130
131        let built = session().resolve(&container).expect("resolves");
132        assert_eq!(built.len(), 1);
133    }
134
135    #[test]
136    fn a_missing_session_manager_fails_with_a_readable_message() {
137        let err = session().resolve(&Container::new()).err().expect("should fail");
138
139        assert!(err.message().contains("StartSession"), "{}", err.message());
140        assert!(err.message().contains("SessionManager"), "{}", err.message());
141    }
142
143    #[test]
144    fn an_application_extends_a_group_rather_than_replacing_it() {
145        // The mistake Laravel's map invites: adding one thing to `web` by
146        // rewriting the list, and dropping the session on the way.
147        let extended = web().with(AddHeaders::new());
148
149        assert_eq!(extended.labels(), vec!["AddHeaders", "StartSession", "AddHeaders"]);
150    }
151}