diff options
| -rw-r--r-- | web/README.md | 1 | ||||
| -rw-r--r-- | web/template/Cargo.toml | 2 | ||||
| -rw-r--r-- | web/template/src/main.rs | 4 | ||||
| -rw-r--r-- | web/template/src/router.rs | 86 | ||||
| -rw-r--r-- | web/template/templates/layout.jinja | 1 | ||||
| -rw-r--r-- | web/template/templates/validation.jinja | 10 |
6 files changed, 99 insertions, 5 deletions
diff --git a/web/README.md b/web/README.md index 9b0bebc..e9d1ba5 100644 --- a/web/README.md +++ b/web/README.md @@ -12,4 +12,5 @@ * [x] Messages (like flask) * [x] Sessions * [x] CSRF +* [x] Validation * [ ] 404 diff --git a/web/template/Cargo.toml b/web/template/Cargo.toml index d266dac..b7d3f32 100644 --- a/web/template/Cargo.toml +++ b/web/template/Cargo.toml @@ -17,9 +17,11 @@ metrics = { version = "=0.24.2", default-features = false } 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"] } diff --git a/web/template/src/main.rs b/web/template/src/main.rs index d1f56d9..1693167 100644 --- a/web/template/src/main.rs +++ b/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 }); diff --git a/web/template/src/router.rs b/web/template/src/router.rs index ca2f2a8..d500121 100644 --- a/web/template/src/router.rs +++ b/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>) -> Router { .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>) -> Router { .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() } diff --git a/web/template/templates/layout.jinja b/web/template/templates/layout.jinja index 6abc2fd..ee06493 100644 --- a/web/template/templates/layout.jinja +++ b/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> diff --git a/web/template/templates/validation.jinja b/web/template/templates/validation.jinja new file mode 100644 index 0000000..e121f99 --- /dev/null +++ b/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 %} |