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}