feattle_ui/
lib.rs

1//! This crate implements an administration Web Interface for visualizing and modifying the feature
2//! flags (called "feattles", for short).
3//!
4//! It provides a web-framework-agnostic implementation in [`AdminPanel`] and ready-to-use bindings
5//! for `warp` and `axum`. Please refer to the
6//! [main package - `feattle`](https://crates.io/crates/feattle) for more information.
7//!
8//! Note that authentication is **not** provided out-of-the-box and you're the one responsible for
9//! controlling and protecting the access.
10//!
11//! # Optional features
12//!
13//! - **axum**: provides [`axum_router`] for a read-to-use integration with [`axum`]
14//! - **warp**: provides [`run_warp_server`] for a read-to-use integration with [`warp`]
15
16pub mod api;
17#[cfg(feature = "axum")]
18mod axum_ui;
19mod pages;
20#[cfg(feature = "warp")]
21mod warp_ui;
22
23use crate::pages::{PageError, Pages};
24use feattle_core::{BoxError, Feattles, HistoryError, UpdateError};
25use serde_json::Value;
26use std::sync::Arc;
27
28use crate::api::v1;
29#[cfg(feature = "axum")]
30pub use axum_ui::axum_router;
31#[cfg(feature = "warp")]
32pub use warp_ui::run_warp_server;
33
34/// The administration panel, agnostic to the choice of web-framework.
35///
36/// This type is designed to be easily integrated with Rust web-frameworks, by providing one method
37/// per page and form submission, each returning bytes with their "Content-Type".
38///
39/// # Example
40/// ```
41/// # #[tokio::main]
42/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
43/// use feattle_ui::AdminPanel;
44/// use feattle_core::{feattles, Feattles};
45/// use feattle_core::persist::NoPersistence;
46/// use std::sync::Arc;
47///
48/// feattles! {
49///     struct MyToggles { a: bool, b: i32 }
50/// }
51///
52/// // `NoPersistence` here is just a mock for the sake of the example
53/// let my_toggles = Arc::new(MyToggles::new(Arc::new(NoPersistence)));
54/// let admin_panel = AdminPanel::new(my_toggles, "Project Panda - DEV".to_owned());
55///
56/// let home_content = admin_panel.list_feattles().await?;
57/// assert_eq!(home_content.content_type, "text/html; charset=utf-8");
58/// assert!(home_content.content.len() > 0);
59/// # Ok(())
60/// # }
61/// ```
62pub struct AdminPanel<F> {
63    feattles: Arc<F>,
64    pages: Pages,
65}
66
67/// Represent a rendered page
68#[derive(Debug, Clone)]
69pub struct RenderedPage {
70    /// The value for the `Content-Type` header
71    pub content_type: String,
72    /// The response body, as bytes
73    pub content: Vec<u8>,
74}
75
76/// Represent what can go wrong while handling a request
77#[derive(Debug, thiserror::Error)]
78pub enum RenderError {
79    /// The requested page does not exist
80    #[error("the requested page does not exist")]
81    NotFound,
82    /// The template failed to render
83    #[error("the template failed to render")]
84    Template(#[from] handlebars::RenderError),
85    /// Failed to serialize or deserialize JSON
86    #[error("failed to serialize or deserialize JSON")]
87    Serialization(#[from] serde_json::Error),
88    /// Failed to recover history information
89    #[error("failed to recover history information")]
90    History(#[from] HistoryError),
91    /// Failed to update value
92    #[error("failed to update value")]
93    Update(#[from] UpdateError),
94    /// Failed to reload new version
95    #[error("failed to reload new version")]
96    Reload(#[source] BoxError),
97}
98
99impl From<PageError> for RenderError {
100    fn from(error: PageError) -> Self {
101        match error {
102            PageError::NotFound => RenderError::NotFound,
103            PageError::Template(error) => RenderError::Template(error),
104            PageError::Serialization(error) => RenderError::Serialization(error),
105        }
106    }
107}
108
109impl<F: Feattles + Sync> AdminPanel<F> {
110    /// Create a new UI provider for a given feattles and a user-visible label
111    pub fn new(feattles: Arc<F>, label: String) -> Self {
112        AdminPanel {
113            feattles,
114            pages: Pages::new(label),
115        }
116    }
117
118    /// Render the page that lists the current feattles values, together with navigation links to
119    /// modify them. This page is somewhat the "home screen" of the UI.
120    ///
121    /// To ensure fresh data is displayed, [`Feattles::reload()`] is called.
122    pub async fn list_feattles(&self) -> Result<RenderedPage, RenderError> {
123        let data = self.list_feattles_api_v1().await?;
124        Ok(self
125            .pages
126            .render_feattles(&data.definitions, data.last_reload, data.reload_failed)?)
127    }
128
129    /// The JSON-API equivalent of [`AdminPanel::list_feattles()`].
130    ///
131    /// To ensure fresh data is displayed, [`Feattles::reload()`] is called.
132    pub async fn list_feattles_api_v1(&self) -> Result<v1::ListFeattlesResponse, RenderError> {
133        let reload_failed = self.feattles.reload().await.is_err();
134        Ok(v1::ListFeattlesResponse {
135            definitions: self.feattles.definitions(),
136            last_reload: self.feattles.last_reload(),
137            reload_failed,
138        })
139    }
140
141    /// Render the page that shows the current and historical values of a single feattle, together
142    /// with the form to modify it. The generated form submits to "/feattle/{{ key }}/edit" with the
143    /// POST method in url-encoded format with a single field called "value_json".
144    ///
145    /// To ensure fresh data is displayed, [`Feattles::reload()`] is called.
146    pub async fn show_feattle(&self, key: &str) -> Result<RenderedPage, RenderError> {
147        let data = self.show_feattle_api_v1(key).await?;
148        Ok(self.pages.render_feattle(
149            &data.definition,
150            &data.history,
151            data.last_reload,
152            data.reload_failed,
153        )?)
154    }
155
156    /// The JSON-API equivalent of [`AdminPanel::show_feattle()`].
157    ///
158    /// To ensure fresh data is displayed, [`Feattles::reload()`] is called.
159    pub async fn show_feattle_api_v1(
160        &self,
161        key: &str,
162    ) -> Result<v1::ShowFeattleResponse, RenderError> {
163        let reload_failed = self.feattles.reload().await.is_err();
164        let definition = self.feattles.definition(key).ok_or(RenderError::NotFound)?;
165        let history = self.feattles.history(key).await?;
166        Ok(v1::ShowFeattleResponse {
167            definition,
168            history,
169            last_reload: self.feattles.last_reload(),
170            reload_failed,
171        })
172    }
173
174    /// Process a modification of a single feattle, given its key and the JSON representation of its
175    /// future value. In case of success, the return is empty, so caller should usually redirect the
176    /// user somewhere after.
177    ///
178    /// To ensure fresh data is displayed, [`Feattles::reload()`] is called. Unlike the other pages,
179    /// if the reload fails, this operation will fail.
180    pub async fn edit_feattle(
181        &self,
182        key: &str,
183        value_json: &str,
184        modified_by: String,
185    ) -> Result<(), RenderError> {
186        let value: Value = serde_json::from_str(value_json)?;
187        self.edit_feattle_api_v1(key, v1::EditFeattleRequest { value, modified_by })
188            .await?;
189        Ok(())
190    }
191
192    /// The JSON-API equivalent of [`AdminPanel::edit_feattle()`].
193    ///
194    /// To ensure fresh data is displayed, [`Feattles::reload()`] is called. Unlike the other pages,
195    /// if the reload fails, this operation will fail.
196    pub async fn edit_feattle_api_v1(
197        &self,
198        key: &str,
199        request: v1::EditFeattleRequest,
200    ) -> Result<v1::EditFeattleResponse, RenderError> {
201        log::info!(
202            "Received edit request for key {} with value {}",
203            key,
204            request.value
205        );
206        self.feattles.reload().await.map_err(RenderError::Reload)?;
207        self.feattles
208            .update(key, request.value, request.modified_by)
209            .await?;
210        Ok(v1::EditFeattleResponse {})
211    }
212
213    /// Renders a public file with the given path. The pages include public files like
214    /// "/public/some/path.js", but this method should be called with only the "some/path.js" part.
215    pub fn render_public_file(&self, path: &str) -> Result<RenderedPage, RenderError> {
216        Ok(self.pages.render_public_file(path)?)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use feattle_core::{feattles, Feattles};
224
225    feattles! {
226        struct MyToggles { a: bool, b: i32 }
227    }
228
229    #[tokio::test]
230    async fn test() {
231        use feattle_core::persist::NoPersistence;
232
233        // `NoPersistence` here is just a mock for the sake of the example
234        let my_toggles = Arc::new(MyToggles::new(Arc::new(NoPersistence)));
235        my_toggles.reload().await.unwrap();
236        let admin_panel = Arc::new(AdminPanel::new(
237            my_toggles,
238            "Project Panda - DEV".to_owned(),
239        ));
240
241        // Just check the methods return
242        admin_panel.list_feattles().await.unwrap();
243        admin_panel.show_feattle("a").await.unwrap();
244        admin_panel.show_feattle("non-existent").await.unwrap_err();
245        admin_panel.render_public_file("script.js").unwrap();
246        admin_panel.render_public_file("non-existent").unwrap_err();
247        admin_panel
248            .edit_feattle("a", "true", "user".to_owned())
249            .await
250            .unwrap();
251        admin_panel
252            .edit_feattle("a", "17", "user".to_owned())
253            .await
254            .unwrap_err();
255    }
256}