1use crate::flags::{Flags, ScopedFlags, missing_registry};
23use crate::scope::Scope;
24use rustlavel_auth::guard::AuthExt;
25use rustlavel_core::{Error, Result};
26use rustlavel_http::handler::BoxFuture;
27use rustlavel_http::middleware::{Middleware, Next};
28use rustlavel_http::response::IntoResponse;
29use rustlavel_http::{Request, Response};
30
31#[derive(Debug, Clone)]
58pub struct WhenActive {
59 flag: String,
60}
61
62impl WhenActive {
63 pub fn new(flag: impl Into<String>) -> Self {
65 WhenActive { flag: flag.into() }
66 }
67
68 pub fn flag(&self) -> &str {
70 &self.flag
71 }
72}
73
74impl Middleware for WhenActive {
75 fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
76 let flag = self.flag.clone();
77
78 Box::pin(async move {
79 let mut request = request;
80
81 let scoped = match request.scoped_flags() {
82 Ok(scoped) => scoped,
83 Err(error) => return error.into_response(),
84 };
85
86 match scoped.active(&flag).await {
87 Ok(true) => {
88 request.extend(scoped);
91 next.run(request).await
92 }
93 Ok(false) => Response::not_found(),
94 Err(error) => Error::msg(format!(
103 "checking the `{flag}` feature flag failed: {error}"
104 ))
105 .into_response(),
106 }
107 })
108 }
109}
110
111pub trait FlagsExt {
122 fn flags(&self) -> Result<Flags>;
124
125 fn flag_scope(&self) -> Scope;
133
134 fn scoped_flags(&self) -> Result<ScopedFlags>;
142
143 fn flag(&self, flag: &str) -> impl Future<Output = Result<bool>> + Send;
145
146 fn flag_off(&self, flag: &str) -> impl Future<Output = Result<bool>> + Send;
148}
149
150impl FlagsExt for Request {
151 fn flags(&self) -> Result<Flags> {
152 self.state::<Flags>().cloned().ok_or_else(missing_registry)
153 }
154
155 fn flag_scope(&self) -> Scope {
156 if let Some(identity) = self.identity() {
157 return Scope::user(identity.id());
158 }
159
160 match self.try_auth().and_then(|guard| guard.user_id()) {
161 Some(id) => Scope::user(id),
162 None => Scope::none(),
163 }
164 }
165
166 fn scoped_flags(&self) -> Result<ScopedFlags> {
167 if let Some(scoped) = self.extension::<ScopedFlags>() {
168 return Ok(scoped.clone());
169 }
170
171 Ok(self.flags()?.for_scope(self.flag_scope()))
172 }
173
174 async fn flag(&self, flag: &str) -> Result<bool> {
175 self.scoped_flags()?.active(flag).await
176 }
177
178 async fn flag_off(&self, flag: &str) -> Result<bool> {
179 Ok(!self.flag(flag).await?)
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::store::MemoryStore;
187 use rustlavel_auth::key::AppKey;
188 use rustlavel_auth::store::MemoryStore as SessionStore;
189 use rustlavel_auth::SessionManager;
190 use rustlavel_core::Context;
191 use rustlavel_http::{Method, Router, TestClient, TestResponse};
192 use std::sync::Arc;
193 use std::sync::atomic::{AtomicUsize, Ordering};
194
195 fn flags() -> Flags {
197 Flags::new()
198 .store(MemoryStore::new())
199 .define("new-checkout", |scope: Scope| async move { scope.id().ends_with('7') })
200 .define("beta-search", |_| async { false })
201 }
202
203 fn client(flags: Option<Flags>) -> TestClient {
205 let mut router = Router::new();
206 router.middleware(SessionManager::new(&AppKey::from_bytes([9u8; 32]), SessionStore::new()));
207
208 router.get("/login-as/{id}", |request: Request| async move {
209 let id = request.param("id").unwrap_or_default().to_string();
210 request.auth().login_using_id(id);
211 "logged in"
212 });
213
214 router.group("/checkout", |r| {
215 r.middleware(WhenActive::new("new-checkout"));
216 r.get("", |_request: Request| async { "the new checkout" });
217 });
218
219 router.get("/who", |request: Request| async move {
220 let scope = request.flag_scope();
221 let on = request.flag("new-checkout").await?;
222 Ok::<_, Error>(format!("{scope}|new-checkout={on}"))
223 });
224
225 let mut builder = Context::builder();
226 if let Some(flags) = flags {
227 builder = builder.state(flags);
228 }
229
230 TestClient::new(router).with_context(builder.build())
231 }
232
233 fn cookie_of(response: &TestResponse) -> String {
234 response
235 .header("set-cookie")
236 .expect("the session middleware sets a cookie")
237 .split(';')
238 .next()
239 .unwrap()
240 .to_string()
241 }
242
243 async fn logged_in(client: &TestClient, id: &str) -> String {
244 cookie_of(&client.get(&format!("/login-as/{id}")).await.assert_ok())
245 }
246
247 async fn get_as(client: &TestClient, cookie: &str, path: &str) -> TestResponse {
248 client.send(Request::new(Method::Get, path).with_header("cookie", cookie)).await
249 }
250
251 #[tokio::test]
252 async fn a_route_behind_a_flag_that_is_on_is_reachable() {
253 let client = client(Some(flags()));
254 let cookie = logged_in(&client, "17").await;
255
256 get_as(&client, &cookie, "/checkout").await.assert_ok().assert_see("the new checkout");
257 }
258
259 #[tokio::test]
260 async fn a_route_behind_a_flag_that_is_off_answers_404() {
261 let client = client(Some(flags()));
262 let cookie = logged_in(&client, "18").await;
263
264 get_as(&client, &cookie, "/checkout").await.assert_not_found();
265 }
266
267 #[tokio::test]
268 async fn the_404_is_indistinguishable_from_a_route_that_never_existed() {
269 let client = client(Some(flags()));
273 let cookie = logged_in(&client, "18").await;
274
275 let flagged = get_as(&client, &cookie, "/checkout").await;
276 let imaginary = get_as(&client, &cookie, "/no-such-route").await;
277
278 assert_eq!(flagged.status(), imaginary.status());
279 assert_eq!(flagged.body(), imaginary.body());
280 assert_eq!(flagged.header("content-type"), imaginary.header("content-type"));
281 }
282
283 #[tokio::test]
284 async fn a_guest_falls_back_to_the_global_scope() {
285 let client = client(Some(flags()));
286
287 client.get("/checkout").await.assert_not_found();
290 client.get("/who").await.assert_ok().assert_see("global|new-checkout=false");
291 }
292
293 #[tokio::test]
294 async fn an_override_reopens_a_route_the_resolver_had_closed() {
295 let flags = flags();
297 let client = client(Some(flags.clone()));
298 let cookie = logged_in(&client, "18").await;
299
300 get_as(&client, &cookie, "/checkout").await.assert_not_found();
301
302 flags.activate_for("new-checkout", &Scope::user("18")).await.unwrap();
303
304 get_as(&client, &cookie, "/checkout").await.assert_ok().assert_see("the new checkout");
305 }
306
307 #[tokio::test]
308 async fn the_incident_switch_closes_a_route_the_resolver_had_opened() {
309 let flags = flags().force_off(["new-checkout"]);
310 let client = client(Some(flags.clone()));
311 let cookie = logged_in(&client, "17").await;
312
313 flags.activate_for("new-checkout", &Scope::user("17")).await.unwrap();
315
316 get_as(&client, &cookie, "/checkout").await.assert_not_found();
317 }
318
319 #[tokio::test]
320 async fn a_handler_can_ask_for_itself_and_sees_the_logged_in_user() {
321 let client = client(Some(flags()));
322 let cookie = logged_in(&client, "17").await;
323
324 get_as(&client, &cookie, "/who").await.assert_ok().assert_see("user:17|new-checkout=true");
325 }
326
327 #[tokio::test]
328 async fn a_missing_registry_is_a_configuration_error_and_not_a_404() {
329 let client = client(None);
333 let cookie = logged_in(&client, "17").await;
334
335 get_as(&client, &cookie, "/checkout").await.assert_status(500);
336 get_as(&client, &cookie, "/who").await.assert_status(500);
337 }
338
339 #[tokio::test]
340 async fn the_guard_hands_its_answers_to_the_handler() {
341 let calls = Arc::new(AtomicUsize::new(0));
344 let counter = Arc::clone(&calls);
345
346 let flags = Flags::new().define("counted", move |_| {
347 let counter = Arc::clone(&counter);
348 async move {
349 counter.fetch_add(1, Ordering::SeqCst);
350 true
351 }
352 });
353
354 let mut router = Router::new();
355 router.group("/page", |r| {
356 r.middleware(WhenActive::new("counted"));
357 r.get("", |request: Request| async move {
358 for _ in 0..5 {
359 assert!(request.flag("counted").await?);
360 }
361 Ok::<_, Error>("drawn")
362 });
363 });
364
365 let client =
366 TestClient::new(router).with_context(Context::builder().state(flags).build());
367
368 client.get("/page").await.assert_ok().assert_see("drawn");
369 assert_eq!(calls.load(Ordering::SeqCst), 1);
370 }
371
372 #[tokio::test]
373 async fn a_handler_holding_the_view_itself_asks_once() {
374 let calls = Arc::new(AtomicUsize::new(0));
375 let counter = Arc::clone(&calls);
376
377 let flags = Flags::new().define("counted", move |_| {
378 let counter = Arc::clone(&counter);
379 async move {
380 counter.fetch_add(1, Ordering::SeqCst);
381 true
382 }
383 });
384
385 let mut router = Router::new();
386 router.get("/page", |request: Request| async move {
387 let view = request.scoped_flags()?;
388 let values = view.values(["counted", "counted"]).await?;
389 Ok::<_, Error>(format!("{}", values["counted"]))
390 });
391
392 let client =
393 TestClient::new(router).with_context(Context::builder().state(flags).build());
394
395 client.get("/page").await.assert_ok().assert_see("true");
396 assert_eq!(calls.load(Ordering::SeqCst), 1);
397 }
398
399 #[tokio::test]
400 async fn a_resolver_that_fails_gives_500_rather_than_hiding_the_route() {
401 let flags =
402 Flags::new().define("flaky", |_| async { Result::<bool>::Err(Error::msg("nope")) });
403
404 let mut router = Router::new();
405 router.group("/page", |r| {
406 r.middleware(WhenActive::new("flaky"));
407 r.get("", |_request: Request| async { "drawn" });
408 });
409
410 let client =
411 TestClient::new(router).with_context(Context::builder().state(flags).build());
412
413 client.get("/page").await.assert_status(500);
414 }
415
416 #[tokio::test]
417 async fn flag_off_is_the_negation() {
418 let mut router = Router::new();
419 router.get("/page", |request: Request| async move {
420 let off = request.flag_off("beta-search").await?;
421 Ok::<_, Error>(format!("off={off}"))
422 });
423
424 let client =
425 TestClient::new(router).with_context(Context::builder().state(flags()).build());
426
427 client.get("/page").await.assert_ok().assert_see("off=true");
428 }
429
430 #[test]
431 fn the_guard_names_the_flag_it_checks() {
432 assert_eq!(WhenActive::new("new-checkout").flag(), "new-checkout");
433 }
434
435 #[test]
436 fn a_request_with_no_session_at_all_is_the_global_scope() {
437 assert_eq!(Request::new(Method::Get, "/").flag_scope(), Scope::none());
438 }
439}