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}