Skip to main content

rustlavel_flags/
plugin.rs

1//! Attaching the package to an application, the way every optional package
2//! attaches itself:
3//!
4//! ```ignore
5//! App::new()
6//!     .plugin(FeatureFlags::from_config(flags, &config))
7//! ```
8//!
9//! One explicit line in `main.rs`, no auto-discovery. It puts a [`Flags`] into
10//! the application context, which is where [`WhenActive`](crate::WhenActive)
11//! and `req.flag(...)` look for it — and the only reason those two work without
12//! being handed a registry.
13
14use crate::flags::Flags;
15use rustlavel_core::Config;
16use rustlavel_http::plugin::{Plugin, Setup};
17
18/// Registers the [`Flags`] registry into application state.
19///
20/// Adds no routes. There is no first-party screen for switching flags on and
21/// off, deliberately: a page that changes what every user of the application
22/// sees is one an application has to decide who may open, and shipping one that
23/// turns itself on with the plugin would be shipping that decision already
24/// made — wrongly.
25pub struct FeatureFlags {
26    flags: Flags,
27}
28
29impl FeatureFlags {
30    /// Register a registry the application built.
31    pub fn new(flags: Flags) -> Self {
32        FeatureFlags { flags }
33    }
34
35    /// Register it, applying `flags.on` and `flags.off` from configuration
36    /// first.
37    ///
38    /// In `.env` that is `FLAGS_OFF=new-checkout,beta-search`, through whatever
39    /// mapping the application's configuration uses — which is the point of it:
40    /// a feature can be switched off by an environment variable and a restart,
41    /// with no deploy and no working database. See [`Flags`] for where the two
42    /// lists sit in the precedence chain, and why `flags.off` sits at the top.
43    pub fn from_config(flags: Flags, config: &Config) -> Self {
44        FeatureFlags { flags: flags.configured(config) }
45    }
46
47    /// The registry, for wiring it into anything else that needs one — a
48    /// console command, a seeder, a test.
49    pub fn flags(&self) -> &Flags {
50        &self.flags
51    }
52}
53
54impl Plugin for FeatureFlags {
55    fn name(&self) -> &'static str {
56        "flags"
57    }
58
59    fn register(self: Box<Self>, setup: &mut Setup<'_>) {
60        // Cloning shares the store and the definitions: every request gets a
61        // handle to the same registry, so an override written from an admin
62        // route is seen by the next request rather than by the next process.
63        setup.state(self.flags.clone());
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use crate::guard::FlagsExt;
71    use crate::scope::Scope;
72    use rustlavel_core::{Context, Error};
73    use rustlavel_http::router::Router;
74    use rustlavel_http::{Request, TestClient};
75
76    fn flags() -> Flags {
77        Flags::new().define("new-checkout", |scope: Scope| async move { scope.id().ends_with('7') })
78    }
79
80    /// Register the plugin the way an application would.
81    fn registered(plugin: FeatureFlags) -> TestClient {
82        let mut router = Router::new();
83        let config = Config::with_defaults();
84        let mut context = Some(Context::builder());
85
86        router.get("/checkout", |request: Request| async move {
87            let on = request.flag("new-checkout").await?;
88            Ok::<_, Error>(format!("new-checkout={on}"))
89        });
90
91        let mut setup = Setup { router: &mut router, config: &config, context: &mut context };
92        Box::new(plugin).register(&mut setup);
93
94        TestClient::new(router).with_context(context.expect("the builder survives setup").build())
95    }
96
97    #[tokio::test]
98    async fn the_plugin_puts_a_registry_where_handlers_can_find_it() {
99        registered(FeatureFlags::new(flags()))
100            .get("/checkout")
101            .await
102            .assert_ok()
103            .assert_see("new-checkout=false");
104    }
105
106    #[tokio::test]
107    async fn an_override_written_through_the_plugins_registry_is_seen_by_a_handler() {
108        // The clone the plugin registers shares the store, which is what makes
109        // an admin route able to change what the next request sees.
110        let plugin = FeatureFlags::new(flags());
111        plugin.flags().activate("new-checkout").await.unwrap();
112
113        registered(plugin).get("/checkout").await.assert_ok().assert_see("new-checkout=true");
114    }
115
116    #[tokio::test]
117    async fn without_the_plugin_a_handler_gets_the_configuration_error() {
118        let mut router = Router::new();
119        router.get("/checkout", |request: Request| async move {
120            Ok::<_, Error>(format!("{}", request.flag("new-checkout").await?))
121        });
122
123        TestClient::new(router).get("/checkout").await.assert_status(500);
124    }
125
126    #[tokio::test]
127    async fn configuration_reaches_the_registry_through_the_plugin() {
128        let config = Config::new();
129        config.set("flags.off", "new-checkout");
130
131        let plugin = FeatureFlags::from_config(flags(), &config);
132        // Forced off, and not even an override placed afterwards gets past it.
133        plugin.flags().activate("new-checkout").await.unwrap();
134
135        registered(plugin).get("/checkout").await.assert_ok().assert_see("new-checkout=false");
136    }
137
138    #[test]
139    fn the_plugin_is_named_for_route_list() {
140        assert_eq!(FeatureFlags::new(flags()).name(), "flags");
141    }
142}