feattle_ui/
axum_ui.rs

1use crate::api::v1;
2use crate::{AdminPanel, RenderError, RenderedPage};
3use async_trait::async_trait;
4use axum::extract::{Path, State};
5use axum::http::{HeaderMap, StatusCode};
6use axum::response::{IntoResponse, Redirect, Response};
7use axum::{routing, Form, Json, Router};
8use feattle_core::{Feattles, UpdateError};
9use serde::Deserialize;
10use std::sync::Arc;
11
12/// A trait that can be used to extract information about the user that is modifying a feattle.
13///
14/// If a `Response` is returned, the feattle will not be modified and the given response will be
15/// returned.
16///
17/// For convenience, this trait is implemented for:
18/// - strings (`String` and `&'static str`) if simply want to label all modifications with a single
19///   name.
20/// - functions that take a [`HeaderMap`] and return `Result<String, impl IntoResponse>` if async is
21///   not necessary
22///
23/// For example, to extract the username from a trusted header:
24/// ```
25/// use axum::http::{HeaderMap, StatusCode};
26///
27/// fn get_user(headers: &HeaderMap) -> Result<String, StatusCode> {
28///     headers
29///         .get("X-User")
30///         .and_then(|user| user.to_str().ok())
31///         .map(|user| user.to_string())
32///         .ok_or(StatusCode::UNAUTHORIZED)
33/// }
34/// ```
35#[async_trait]
36pub trait ExtractModifiedBy: Send + Sync + 'static {
37    async fn extract_modified_by(&self, headers: &HeaderMap) -> Result<String, Response>;
38}
39
40/// Return an [`axum`] router that serves the admin panel.
41///
42/// To use it, make sure to activate the cargo feature `"axum"` in your `Cargo.toml`.
43///
44/// The router will answer to the web UI under "/" and a JSON API under "/api/v1/" (see more at [`v1`]):
45/// - GET /api/v1/feattles
46/// - GET /api/v1/feattle/{key}
47/// - POST /api/v1/feattle/{key}
48///
49/// # Example
50/// ```no_run
51/// # #[tokio::main]
52/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
53/// use std::future::IntoFuture;
54/// use feattle_ui::{AdminPanel, axum_router};
55/// use feattle_core::{feattles, Feattles};
56/// use feattle_core::persist::NoPersistence;
57/// use std::sync::Arc;
58///
59/// use tokio::net::TcpListener;
60///
61/// feattles! {
62///     struct MyToggles { a: bool, b: i32 }
63/// }
64///
65/// // `NoPersistence` here is just a mock for the sake of the example
66/// let my_toggles = Arc::new(MyToggles::new(Arc::new(NoPersistence)));
67/// let admin_panel = Arc::new(AdminPanel::new(my_toggles, "Project Panda - DEV".to_owned()));
68///
69/// let router = axum_router(admin_panel, "admin");
70///
71/// let listener = TcpListener::bind(("127.0.0.1", 3031)).await?;
72/// tokio::spawn(axum::serve(listener, router.into_make_service()).into_future());
73///
74/// # Ok(())
75/// # }
76/// ```
77pub fn axum_router<F>(
78    admin_panel: Arc<AdminPanel<F>>,
79    extract_modified_by: impl ExtractModifiedBy,
80) -> Router<()>
81where
82    F: Feattles + Sync + Send + 'static,
83{
84    async fn list_feattles<F: Feattles + Sync>(
85        State(state): State<RouterState<F>>,
86    ) -> impl IntoResponse {
87        state.admin_panel.list_feattles().await
88    }
89
90    async fn list_feattles_api_v1<F: Feattles + Sync>(
91        State(state): State<RouterState<F>>,
92    ) -> impl IntoResponse {
93        state.admin_panel.list_feattles_api_v1().await.map(Json)
94    }
95
96    async fn show_feattle<F: Feattles + Sync>(
97        State(state): State<RouterState<F>>,
98        Path(key): Path<String>,
99    ) -> impl IntoResponse {
100        state.admin_panel.show_feattle(&key).await
101    }
102
103    async fn show_feattle_api_v1<F: Feattles + Sync>(
104        State(state): State<RouterState<F>>,
105        Path(key): Path<String>,
106    ) -> impl IntoResponse {
107        state.admin_panel.show_feattle_api_v1(&key).await.map(Json)
108    }
109
110    async fn edit_feattle<F: Feattles + Sync>(
111        State(state): State<RouterState<F>>,
112        Path(key): Path<String>,
113        headers: HeaderMap,
114        Form(form): Form<EditFeattleForm>,
115    ) -> Response {
116        let modified_by = match state
117            .extract_modified_by
118            .extract_modified_by(&headers)
119            .await
120        {
121            Ok(modified_by) => modified_by,
122            Err(response) => return response,
123        };
124
125        state
126            .admin_panel
127            .edit_feattle(&key, &form.value_json, modified_by)
128            .await
129            .map(|_| Redirect::to("/"))
130            .into_response()
131    }
132
133    async fn edit_feattle_api_v1<F: Feattles + Sync>(
134        State(state): State<RouterState<F>>,
135        Path(key): Path<String>,
136        Json(request): Json<v1::EditFeattleRequest>,
137    ) -> impl IntoResponse {
138        state
139            .admin_panel
140            .edit_feattle_api_v1(&key, request)
141            .await
142            .map(Json)
143    }
144
145    async fn render_public_file<F: Feattles + Sync>(
146        State(state): State<RouterState<F>>,
147        Path(file_name): Path<String>,
148    ) -> impl IntoResponse {
149        state.admin_panel.render_public_file(&file_name)
150    }
151
152    let state = RouterState {
153        admin_panel,
154        extract_modified_by: Arc::new(extract_modified_by),
155    };
156
157    Router::new()
158        .route("/", routing::get(list_feattles))
159        .route("/api/v1/feattles", routing::get(list_feattles_api_v1))
160        .route("/feattle/{key}", routing::get(show_feattle))
161        .route("/api/v1/feattle/{key}", routing::get(show_feattle_api_v1))
162        .route("/feattle/{key}/edit", routing::post(edit_feattle))
163        .route("/api/v1/feattle/{key}", routing::post(edit_feattle_api_v1))
164        .route("/public/{file_name}", routing::get(render_public_file))
165        .with_state(state)
166}
167
168#[derive(Debug, Deserialize)]
169struct EditFeattleForm {
170    value_json: String,
171}
172
173struct RouterState<F> {
174    admin_panel: Arc<AdminPanel<F>>,
175    extract_modified_by: Arc<dyn ExtractModifiedBy>,
176}
177
178impl IntoResponse for RenderedPage {
179    fn into_response(self) -> Response {
180        ([("Content-Type", self.content_type)], self.content).into_response()
181    }
182}
183
184impl IntoResponse for RenderError {
185    fn into_response(self) -> Response {
186        match self {
187            RenderError::NotFound | RenderError::Update(UpdateError::UnknownKey(_)) => {
188                StatusCode::NOT_FOUND.into_response()
189            }
190            RenderError::Update(UpdateError::Parsing(err)) => (
191                StatusCode::BAD_REQUEST,
192                format!("Failed to parse: {:?}", err),
193            )
194                .into_response(),
195            err => {
196                log::error!("request failed with {:?}", err);
197                (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", err)).into_response()
198            }
199        }
200    }
201}
202
203impl<F> Clone for RouterState<F> {
204    fn clone(&self) -> Self {
205        RouterState {
206            admin_panel: self.admin_panel.clone(),
207            extract_modified_by: self.extract_modified_by.clone(),
208        }
209    }
210}
211
212#[async_trait]
213impl ExtractModifiedBy for String {
214    async fn extract_modified_by(&self, _headers: &HeaderMap) -> Result<String, Response> {
215        Ok(self.clone())
216    }
217}
218
219#[async_trait]
220impl ExtractModifiedBy for &'static str {
221    async fn extract_modified_by(&self, _headers: &HeaderMap) -> Result<String, Response> {
222        Ok(self.to_string())
223    }
224}
225
226#[async_trait]
227impl<F, R> ExtractModifiedBy for F
228where
229    F: Fn(&HeaderMap) -> Result<String, R> + Send + Sync + 'static,
230    R: IntoResponse,
231{
232    async fn extract_modified_by(&self, headers: &HeaderMap) -> Result<String, Response> {
233        self(headers).map_err(|response| response.into_response())
234    }
235}