Commit Diff


commit - e8fe7f5532aee563a949b78098208da9e48445f2
commit + d2e15101190f6ade30fd37b466d94cef85a2189f
blob - 9b0bebcc211a37fb3c319e992a6b8b8919de1f93
blob + e9d1ba5ff092fc6401f81e38f4cb452e7869f2fe
--- web/README.md
+++ web/README.md
@@ -12,4 +12,5 @@
 * [x] Messages (like flask)
 * [x] Sessions
 * [x] CSRF
+* [x] Validation
 * [ ] 404
blob - d266dac95ef710f4e07d61d0565c34bc52ca0566
blob + b7d3f32466ba2542cdc0e80e42a5e7062a020d9d
--- web/template/Cargo.toml
+++ web/template/Cargo.toml
@@ -17,9 +17,11 @@ metrics = { version = "=0.24.2", default-features = fa
 metrics-exporter-prometheus = { version = "=0.17.2", default-features = false }
 minijinja = "=2.12.0"
 serde = { version = "=1.0.228", features = ["derive"] }
+thiserror = "2.0.17"
 time = "=0.3.44"
 tokio = { version = "=1.48.0", features = ["macros", "rt-multi-thread", "signal"] }
 tower-http = { version = "=0.6.6", features = ["timeout", "trace", "fs", "request-id"] }
 tower-sessions = "=0.14.0"
 tracing = "=0.1.41"
 tracing-subscriber = { version = "=0.3.20", features = ["env-filter"] }
+validator = { version = "=0.20.0", features = ["derive"] }
blob - d1f56d93282c97bddded2b3c6399390911d2364c
blob + 1693167744b435b06ce0ff681c4a17473b2e6c40
--- web/template/src/main.rs
+++ web/template/src/main.rs
@@ -45,6 +45,10 @@ async fn start_main_server() -> anyhow::Result<()> {
     env.add_template("content", include_str!("../templates/content.jinja"))?;
     env.add_template("about", include_str!("../templates/about.jinja"))?;
     env.add_template("csrf", include_str!("../templates/csrf.jinja"))?;
+    env.add_template(
+        "validation",
+        include_str!("../templates/validation.jinja"),
+    )?;
 
     let app_state = Arc::new(state::AppState { env });
 
blob - ca2f2a800fa47ac104c61617c1a9d127a84700b4
blob + d500121b1749957b78c80b02e8cb69c0029480cd
--- web/template/src/router.rs
+++ web/template/src/router.rs
@@ -13,21 +13,24 @@
 // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 //
+
 use std::sync::Arc;
 
 use axum::{
-    Form, Router,
-    extract::State,
-    http::{HeaderName, Request, StatusCode},
+    Router,
+    extract::{Form, FromRequest, Request, State, rejection::FormRejection},
+    http::{self, HeaderName, StatusCode},
     middleware,
-    response::{Html, IntoResponse, Redirect},
+    response::{Html, IntoResponse, Redirect, Response},
     routing::get,
 };
 use axum_client_ip::{ClientIp, ClientIpSource};
 use axum_csrf::{CsrfConfig, CsrfLayer, CsrfToken, Key};
 use axum_messages::{Messages, MessagesManagerLayer};
 use minijinja::context;
+use serde::de::DeserializeOwned;
 use serde::{Deserialize, Serialize};
+use thiserror::Error;
 use time::Duration;
 use tower_http::{
     request_id::{
@@ -39,6 +42,7 @@ use tower_http::{
 };
 use tower_sessions::{Expiry, MemoryStore, Session, SessionManagerLayer};
 use tracing::{error, info_span};
+use validator::Validate;
 
 use crate::metric::track_metrics;
 use crate::state::AppState;
@@ -75,13 +79,17 @@ pub(crate) fn route(app_state: Arc<AppState>) -> Route
         .route("/read-messages", get(read_messages_handler))
         .route("/csrf", get(csrf_root).post(csrf_check_key))
         .route("/ip", get(ip_handler))
+        .route(
+            "/validation",
+            get(get_validation_handler).post(post_validation_handler),
+        )
         .layer(MessagesManagerLayer)
         // TODO(msi): from config folder asssets
         .nest_service("/assets", ServeDir::new("assets"))
         .layer((
             SetRequestIdLayer::new(x_request_id.clone(), MakeRequestUuid),
             TraceLayer::new_for_http().make_span_with(
-                |request: &Request<_>| {
+                |request: &http::Request<_>| {
                     // Log the request id as generated.
                     let request_id = request.headers().get(REQUEST_ID_HEADER);
 
@@ -112,6 +120,74 @@ pub(crate) fn route(app_state: Arc<AppState>) -> Route
         .with_state(app_state)
 }
 
+#[derive(Debug, Deserialize, Validate)]
+pub struct NameInput {
+    #[validate(length(min = 2, message = "Can not be empty"))]
+    pub name: String,
+}
+
+async fn get_validation_handler(
+    State(state): State<Arc<AppState>>,
+) -> Result<Html<String>, ServerError> {
+    let template = state.env.get_template("validation").unwrap();
+
+    let rendered = template.render(context! {}).unwrap();
+
+    Ok(Html(rendered))
+}
+
+async fn post_validation_handler(
+    ValidatedForm(input): ValidatedForm<NameInput>,
+) -> Html<String> {
+    Html(format!("<h1>Hello, {}!</h1>", input.name))
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+pub struct ValidatedForm<T>(pub T);
+
+impl<T, S> FromRequest<S> for ValidatedForm<T>
+where
+    T: DeserializeOwned + Validate,
+    S: Send + Sync,
+    Form<T>: FromRequest<S, Rejection = FormRejection>,
+{
+    type Rejection = ServerError;
+
+    async fn from_request(
+        req: Request,
+        state: &S,
+    ) -> Result<Self, Self::Rejection> {
+        let Form(value) = Form::<T>::from_request(req, state).await?;
+        value.validate()?;
+        Ok(ValidatedForm(value))
+    }
+}
+
+#[derive(Debug, Error)]
+pub enum ServerError {
+    #[error(transparent)]
+    ValidationError(#[from] validator::ValidationErrors),
+
+    #[error(transparent)]
+    AxumFormRejection(#[from] FormRejection),
+}
+
+impl IntoResponse for ServerError {
+    fn into_response(self) -> Response {
+        match self {
+            ServerError::ValidationError(_) => {
+                let message = format!("Input validation error: [{self}]")
+                    .replace('\n', ", ");
+                (StatusCode::BAD_REQUEST, message)
+            }
+            ServerError::AxumFormRejection(_) => {
+                (StatusCode::BAD_REQUEST, self.to_string())
+            }
+        }
+        .into_response()
+    }
+}
+
 async fn ip_handler(ClientIp(ip): ClientIp) -> String {
     ip.to_string()
 }
blob - 6abc2fda11620fbecb3d72b3b37cc2b3dac02d2d
blob + ee0649398dbcf65170b3ec8c1a23ab209f7d4cc0
--- web/template/templates/layout.jinja
+++ web/template/templates/layout.jinja
@@ -13,6 +13,7 @@
             <li><a href="/read-messages">Read Messages</a></li>
             <li><a href="/csrf">Csrf</a></li>
             <li><a href="/ip">Ip</a></li>
+            <li><a href="/validation">Validation</a></li>
         </ul>
     </nav>
     <h1><h1>Hello, World web =]</h1>
blob - /dev/null
blob + e121f990b9365500592811c80c9fb26acd4dbbe4 (mode 644)
--- /dev/null
+++ web/template/templates/validation.jinja
@@ -0,0 +1,10 @@
+{% extends "layout" %}
+{% block title %}{{ super() }} | {{ title }} {% endblock %}
+{% block body %}
+<h1>{{ title }}</h1>
+<p>{{ about_text }}</p>
+ <form method="post" action="/validation">
+            <input type="text" name="name" value=""/>
+            <input id="button" type="submit" value="Submit" tabindex="4" />
+        </form>
+{% endblock %}