use crate::flags::{Flags, ScopedFlags, missing_registry};
use crate::scope::Scope;
use rustlavel_auth::guard::AuthExt;
use rustlavel_core::{Error, Result};
use rustlavel_http::handler::BoxFuture;
use rustlavel_http::middleware::{Middleware, Next};
use rustlavel_http::response::IntoResponse;
use rustlavel_http::{Request, Response};
#[derive(Debug, Clone)]
pub struct WhenActive {
flag: String,
}
impl WhenActive {
pub fn new(flag: impl Into<String>) -> Self {
WhenActive { flag: flag.into() }
}
pub fn flag(&self) -> &str {
&self.flag
}
}
impl Middleware for WhenActive {
fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
let flag = self.flag.clone();
Box::pin(async move {
let mut request = request;
let scoped = match request.scoped_flags() {
Ok(scoped) => scoped,
Err(error) => return error.into_response(),
};
match scoped.active(&flag).await {
Ok(true) => {
request.extend(scoped);
next.run(request).await
}
Ok(false) => Response::not_found(),
Err(error) => Error::msg(format!(
"checking the `{flag}` feature flag failed: {error}"
))
.into_response(),
}
})
}
}
pub trait FlagsExt {
fn flags(&self) -> Result<Flags>;
fn flag_scope(&self) -> Scope;
fn scoped_flags(&self) -> Result<ScopedFlags>;
fn flag(&self, flag: &str) -> impl Future<Output = Result<bool>> + Send;
fn flag_off(&self, flag: &str) -> impl Future<Output = Result<bool>> + Send;
}
impl FlagsExt for Request {
fn flags(&self) -> Result<Flags> {
self.state::<Flags>().cloned().ok_or_else(missing_registry)
}
fn flag_scope(&self) -> Scope {
if let Some(identity) = self.identity() {
return Scope::user(identity.id());
}
match self.try_auth().and_then(|guard| guard.user_id()) {
Some(id) => Scope::user(id),
None => Scope::none(),
}
}
fn scoped_flags(&self) -> Result<ScopedFlags> {
if let Some(scoped) = self.extension::<ScopedFlags>() {
return Ok(scoped.clone());
}
Ok(self.flags()?.for_scope(self.flag_scope()))
}
async fn flag(&self, flag: &str) -> Result<bool> {
self.scoped_flags()?.active(flag).await
}
async fn flag_off(&self, flag: &str) -> Result<bool> {
Ok(!self.flag(flag).await?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::MemoryStore;
use rustlavel_auth::key::AppKey;
use rustlavel_auth::store::MemoryStore as SessionStore;
use rustlavel_auth::SessionManager;
use rustlavel_core::Context;
use rustlavel_http::{Method, Router, TestClient, TestResponse};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
fn flags() -> Flags {
Flags::new()
.store(MemoryStore::new())
.define("new-checkout", |scope: Scope| async move { scope.id().ends_with('7') })
.define("beta-search", |_| async { false })
}
fn client(flags: Option<Flags>) -> TestClient {
let mut router = Router::new();
router.middleware(SessionManager::new(&AppKey::from_bytes([9u8; 32]), SessionStore::new()));
router.get("/login-as/{id}", |request: Request| async move {
let id = request.param("id").unwrap_or_default().to_string();
request.auth().login_using_id(id);
"logged in"
});
router.group("/checkout", |r| {
r.middleware(WhenActive::new("new-checkout"));
r.get("", |_request: Request| async { "the new checkout" });
});
router.get("/who", |request: Request| async move {
let scope = request.flag_scope();
let on = request.flag("new-checkout").await?;
Ok::<_, Error>(format!("{scope}|new-checkout={on}"))
});
let mut builder = Context::builder();
if let Some(flags) = flags {
builder = builder.state(flags);
}
TestClient::new(router).with_context(builder.build())
}
fn cookie_of(response: &TestResponse) -> String {
response
.header("set-cookie")
.expect("the session middleware sets a cookie")
.split(';')
.next()
.unwrap()
.to_string()
}
async fn logged_in(client: &TestClient, id: &str) -> String {
cookie_of(&client.get(&format!("/login-as/{id}")).await.assert_ok())
}
async fn get_as(client: &TestClient, cookie: &str, path: &str) -> TestResponse {
client.send(Request::new(Method::Get, path).with_header("cookie", cookie)).await
}
#[tokio::test]
async fn a_route_behind_a_flag_that_is_on_is_reachable() {
let client = client(Some(flags()));
let cookie = logged_in(&client, "17").await;
get_as(&client, &cookie, "/checkout").await.assert_ok().assert_see("the new checkout");
}
#[tokio::test]
async fn a_route_behind_a_flag_that_is_off_answers_404() {
let client = client(Some(flags()));
let cookie = logged_in(&client, "18").await;
get_as(&client, &cookie, "/checkout").await.assert_not_found();
}
#[tokio::test]
async fn the_404_is_indistinguishable_from_a_route_that_never_existed() {
let client = client(Some(flags()));
let cookie = logged_in(&client, "18").await;
let flagged = get_as(&client, &cookie, "/checkout").await;
let imaginary = get_as(&client, &cookie, "/no-such-route").await;
assert_eq!(flagged.status(), imaginary.status());
assert_eq!(flagged.body(), imaginary.body());
assert_eq!(flagged.header("content-type"), imaginary.header("content-type"));
}
#[tokio::test]
async fn a_guest_falls_back_to_the_global_scope() {
let client = client(Some(flags()));
client.get("/checkout").await.assert_not_found();
client.get("/who").await.assert_ok().assert_see("global|new-checkout=false");
}
#[tokio::test]
async fn an_override_reopens_a_route_the_resolver_had_closed() {
let flags = flags();
let client = client(Some(flags.clone()));
let cookie = logged_in(&client, "18").await;
get_as(&client, &cookie, "/checkout").await.assert_not_found();
flags.activate_for("new-checkout", &Scope::user("18")).await.unwrap();
get_as(&client, &cookie, "/checkout").await.assert_ok().assert_see("the new checkout");
}
#[tokio::test]
async fn the_incident_switch_closes_a_route_the_resolver_had_opened() {
let flags = flags().force_off(["new-checkout"]);
let client = client(Some(flags.clone()));
let cookie = logged_in(&client, "17").await;
flags.activate_for("new-checkout", &Scope::user("17")).await.unwrap();
get_as(&client, &cookie, "/checkout").await.assert_not_found();
}
#[tokio::test]
async fn a_handler_can_ask_for_itself_and_sees_the_logged_in_user() {
let client = client(Some(flags()));
let cookie = logged_in(&client, "17").await;
get_as(&client, &cookie, "/who").await.assert_ok().assert_see("user:17|new-checkout=true");
}
#[tokio::test]
async fn a_missing_registry_is_a_configuration_error_and_not_a_404() {
let client = client(None);
let cookie = logged_in(&client, "17").await;
get_as(&client, &cookie, "/checkout").await.assert_status(500);
get_as(&client, &cookie, "/who").await.assert_status(500);
}
#[tokio::test]
async fn the_guard_hands_its_answers_to_the_handler() {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let flags = Flags::new().define("counted", move |_| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
true
}
});
let mut router = Router::new();
router.group("/page", |r| {
r.middleware(WhenActive::new("counted"));
r.get("", |request: Request| async move {
for _ in 0..5 {
assert!(request.flag("counted").await?);
}
Ok::<_, Error>("drawn")
});
});
let client =
TestClient::new(router).with_context(Context::builder().state(flags).build());
client.get("/page").await.assert_ok().assert_see("drawn");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_handler_holding_the_view_itself_asks_once() {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let flags = Flags::new().define("counted", move |_| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
true
}
});
let mut router = Router::new();
router.get("/page", |request: Request| async move {
let view = request.scoped_flags()?;
let values = view.values(["counted", "counted"]).await?;
Ok::<_, Error>(format!("{}", values["counted"]))
});
let client =
TestClient::new(router).with_context(Context::builder().state(flags).build());
client.get("/page").await.assert_ok().assert_see("true");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_resolver_that_fails_gives_500_rather_than_hiding_the_route() {
let flags =
Flags::new().define("flaky", |_| async { Result::<bool>::Err(Error::msg("nope")) });
let mut router = Router::new();
router.group("/page", |r| {
r.middleware(WhenActive::new("flaky"));
r.get("", |_request: Request| async { "drawn" });
});
let client =
TestClient::new(router).with_context(Context::builder().state(flags).build());
client.get("/page").await.assert_status(500);
}
#[tokio::test]
async fn flag_off_is_the_negation() {
let mut router = Router::new();
router.get("/page", |request: Request| async move {
let off = request.flag_off("beta-search").await?;
Ok::<_, Error>(format!("off={off}"))
});
let client =
TestClient::new(router).with_context(Context::builder().state(flags()).build());
client.get("/page").await.assert_ok().assert_see("off=true");
}
#[test]
fn the_guard_names_the_flag_it_checks() {
assert_eq!(WhenActive::new("new-checkout").flag(), "new-checkout");
}
#[test]
fn a_request_with_no_session_at_all_is_the_global_scope() {
assert_eq!(Request::new(Method::Get, "/").flag_scope(), Scope::none());
}
}