From 3334bddeb2d700b04dad90f3d7588bff7987e060 Mon Sep 17 00:00:00 2001 From: kulapoo <116390979+kulapoo@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:23:01 +0800 Subject: [PATCH] feat: add form_data method into request (#651) --- crates/tuono_lib/src/request.rs | 104 +++++++++++++++++++- crates/tuono_lib/tests/server_test.rs | 29 ++++++ crates/tuono_lib/tests/utils/form_data.rs | 13 +++ crates/tuono_lib/tests/utils/mock_server.rs | 2 + crates/tuono_lib/tests/utils/mod.rs | 1 + 5 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 crates/tuono_lib/tests/utils/form_data.rs diff --git a/crates/tuono_lib/src/request.rs b/crates/tuono_lib/src/request.rs index 9247add0..762652cf 100644 --- a/crates/tuono_lib/src/request.rs +++ b/crates/tuono_lib/src/request.rs @@ -1,8 +1,8 @@ +use axum::http::{HeaderMap, Uri}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use axum::http::{HeaderMap, Uri}; - /// Location must match client side interface #[derive(Serialize, Debug)] pub struct Location { @@ -70,12 +70,40 @@ impl Request { "Failed to read body", ))) } + + pub fn form_data(&self) -> Result + where + T: DeserializeOwned, + { + let content_type = self + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if !content_type.contains("application/x-www-form-urlencoded") { + return Err(BodyParseError::ContentType( + "Invalid content type, expected application/x-www-form-urlencoded".to_string(), + )); + } + + let body = self.body.as_ref().ok_or_else(|| { + BodyParseError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Missing request body", + )) + })?; + + serde_urlencoded::from_bytes::(body).map_err(BodyParseError::UrlEncoded) + } } #[derive(Debug)] pub enum BodyParseError { Io(std::io::Error), Serde(serde_json::Error), + UrlEncoded(serde_urlencoded::de::Error), + ContentType(String), } impl From for BodyParseError { @@ -95,6 +123,12 @@ mod tests { field2: String, } + #[derive(Debug, Deserialize)] + struct FormData { + name: String, + email: Option, + } + #[test] fn it_correctly_parse_the_body() { let request = Request::new( @@ -123,4 +157,70 @@ mod tests { assert!(body.is_err()); } + + #[test] + fn it_correctly_parses_form_data() { + let mut request = Request::new( + Uri::from_static("http://localhost:3000"), + HeaderMap::new(), + HashMap::new(), + None, + ); + + request.headers.insert( + "content-type", + "application/x-www-form-urlencoded".parse().unwrap(), + ); + + request.body = Some("name=John+Doe&email=john%40example.com".as_bytes().to_vec()); + + let form_data: Result = request.form_data(); + + assert!(form_data.is_ok()); + let data = form_data.unwrap(); + assert_eq!(data.name, "John Doe"); + assert_eq!(data.email, Some("john@example.com".to_string())); + } + + #[test] + fn it_rejects_wrong_form_content_type() { + let mut request = Request::new( + Uri::from_static("http://localhost:3000"), + HeaderMap::new(), + HashMap::new(), + None, + ); + + request + .headers + .insert("content-type", "application/json".parse().unwrap()); + + request.headers.insert( + "body", + "name=John+Doe&email=john%40example.com".parse().unwrap(), + ); + + let form_data: Result = request.form_data(); + + assert!(form_data.is_err()); + } + + #[test] + fn it_handles_missing_form_body() { + let mut request = Request::new( + Uri::from_static("http://localhost:3000"), + HeaderMap::new(), + HashMap::new(), + None, + ); + + request.headers.insert( + "content-type", + "application/x-www-form-urlencoded".parse().unwrap(), + ); + + let form_data: Result = request.form_data(); + + assert!(form_data.is_err()); + } } diff --git a/crates/tuono_lib/tests/server_test.rs b/crates/tuono_lib/tests/server_test.rs index d1319973..81c43634 100644 --- a/crates/tuono_lib/tests/server_test.rs +++ b/crates/tuono_lib/tests/server_test.rs @@ -1,4 +1,6 @@ mod utils; +use std::collections::HashMap; + use crate::utils::mock_server::MockTuonoServer; use serial_test::serial; @@ -194,3 +196,30 @@ async fn it_parses_the_http_body() { assert!(response.status().is_success()); assert_eq!(response.text().await.unwrap(), "payload"); } + +#[tokio::test] +#[serial] +async fn it_parses_the_form_encoded_url() { + let app = MockTuonoServer::spawn().await; + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let server_url = format!("http://{}:{}", &app.address, &app.port); + + let mut form_params = HashMap::new(); + form_params.insert("data", "payload"); + + let response = client + .post(format!("{server_url}/api/form_data")) + .header("content-type", "application/x-www-form-urlencoded") + .form(&form_params) + .send() + .await + .expect("Failed to execute request."); + + assert!(response.status().is_success()); + assert_eq!(response.text().await.unwrap(), "payload"); +} diff --git a/crates/tuono_lib/tests/utils/form_data.rs b/crates/tuono_lib/tests/utils/form_data.rs new file mode 100644 index 00000000..5485ba2b --- /dev/null +++ b/crates/tuono_lib/tests/utils/form_data.rs @@ -0,0 +1,13 @@ +use serde::Deserialize; +use tuono_lib::Request; + +#[derive(Deserialize)] +struct Payload { + data: String, +} + +#[tuono_lib::api(POST)] +async fn form_data(req: Request) -> String { + let form = req.form_data::().unwrap(); + form.data +} diff --git a/crates/tuono_lib/tests/utils/mock_server.rs b/crates/tuono_lib/tests/utils/mock_server.rs index 674f1e27..15e16db7 100644 --- a/crates/tuono_lib/tests/utils/mock_server.rs +++ b/crates/tuono_lib/tests/utils/mock_server.rs @@ -10,6 +10,7 @@ use tuono_lib::{Mode, Server, axum::Router, tuono_internal_init_v8_platform}; use crate::utils::catch_all::get_tuono_internal_api as catch_all; use crate::utils::dynamic_parameter::get_tuono_internal_api as dynamic_parameter; use crate::utils::env::get_tuono_internal_api as test_env; +use crate::utils::form_data::post_tuono_internal_api as form_data_api; use crate::utils::health_check::get_tuono_internal_api as health_check; use crate::utils::post_api::post_tuono_internal_api as post_api; use crate::utils::route as html_route; @@ -86,6 +87,7 @@ impl MockTuonoServer { .route("/catch_all/{*catch_all}", get(catch_all)) .route("/dynamic/{parameter}", get(dynamic_parameter)) .route("/api/post", post(post_api)) + .route("/api/form_data", post(form_data_api)) .route("/env", get(test_env)); let server = Server::init(router, Mode::Prod).await; diff --git a/crates/tuono_lib/tests/utils/mod.rs b/crates/tuono_lib/tests/utils/mod.rs index a12fd7e0..855cfbd4 100644 --- a/crates/tuono_lib/tests/utils/mod.rs +++ b/crates/tuono_lib/tests/utils/mod.rs @@ -1,6 +1,7 @@ pub mod catch_all; pub mod dynamic_parameter; pub mod env; +pub mod form_data; pub mod health_check; pub mod mock_server; pub mod post_api;