diff --git a/Cargo.lock b/Cargo.lock index 3897e80..0e5c3d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,6 +809,14 @@ dependencies = [ "flareon", ] +[[package]] +name = "example-json" +version = "0.1.0" +dependencies = [ + "flareon", + "serde", +] + [[package]] name = "example-sessions" version = "0.1.0" @@ -876,6 +884,7 @@ dependencies = [ "sea-query", "sea-query-binder", "serde", + "serde_json", "sha2 0.11.0-pre.4", "sqlx", "subtle", diff --git a/Cargo.toml b/Cargo.toml index dd54b5a..3699fb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "examples/todo-list", "examples/sessions", "examples/admin", + "examples/json", ] resolver = "2" @@ -67,6 +68,7 @@ rustversion = "1" sea-query = { version = "0.32.0-rc.2", default-features = false } sea-query-binder = { version = "0.7.0-rc.2", default-features = false } serde = "1" +serde_json = "1" sha2 = "0.11.0-pre.4" sqlx = { version = "0.8", default-features = false } subtle = { version = "2", default-features = false } diff --git a/examples/json/Cargo.toml b/examples/json/Cargo.toml new file mode 100644 index 0000000..c025e73 --- /dev/null +++ b/examples/json/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "example-json" +version = "0.1.0" +publish = false +description = "JSON - Flareon example." +edition = "2021" + +[dependencies] +flareon = { path = "../../flareon" } +serde = "1" diff --git a/examples/json/src/main.rs b/examples/json/src/main.rs new file mode 100644 index 0000000..d547c54 --- /dev/null +++ b/examples/json/src/main.rs @@ -0,0 +1,50 @@ +use flareon::request::{Request, RequestExt}; +use flareon::response::{Response, ResponseExt}; +use flareon::router::{Route, Router}; +use flareon::{FlareonApp, FlareonProject, StatusCode}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +struct AddRequest { + a: i32, + b: i32, +} + +#[derive(Debug, Clone, Serialize)] +struct AddResponse { + result: i32, +} + +async fn add(mut request: Request) -> flareon::Result { + let add_request: AddRequest = request.json().await?; + let response = AddResponse { + result: add_request.a + add_request.b, + }; + + Response::new_json(StatusCode::OK, &response) +} + +struct AddApp; + +impl FlareonApp for AddApp { + fn name(&self) -> &'static str { + env!("CARGO_PKG_NAME") + } + + fn router(&self) -> Router { + Router::with_urls([Route::with_handler("/", add)]) + } +} + +// Test with: +// curl --header "Content-Type: application/json" --request POST --data '{"a": 123, "b": 456}' 'http://127.0.0.1:8080/' + +#[flareon::main] +async fn main() -> flareon::Result { + let flareon_project = FlareonProject::builder() + .register_app_with_views(AddApp, "") + .build() + .await?; + + Ok(flareon_project) +} diff --git a/flareon/Cargo.toml b/flareon/Cargo.toml index c8677e1..777fa70 100644 --- a/flareon/Cargo.toml +++ b/flareon/Cargo.toml @@ -35,7 +35,8 @@ password-auth = { workspace = true, features = ["std", "argon2"] } pin-project-lite.workspace = true sea-query = { workspace = true } sea-query-binder = { workspace = true, features = ["with-chrono", "runtime-tokio"] } -serde.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, optional = true } sha2.workspace = true sqlx = { workspace = true, features = ["runtime-tokio", "chrono"] } subtle = { workspace = true, features = ["std"] } @@ -67,9 +68,10 @@ ignored = [ ] [features] -default = ["sqlite", "postgres", "mysql"] +default = ["sqlite", "postgres", "mysql", "json"] fake = ["dep:fake"] db = [] sqlite = ["db", "sea-query/backend-sqlite", "sea-query-binder/sqlx-sqlite", "sqlx/sqlite"] postgres = ["db", "sea-query/backend-postgres", "sea-query-binder/sqlx-postgres", "sqlx/postgres"] mysql = ["db", "sea-query/backend-mysql", "sea-query-binder/sqlx-mysql", "sqlx/mysql"] +json = ["serde_json"] diff --git a/flareon/src/error.rs b/flareon/src/error.rs index 0cd7a12..4bfff89 100644 --- a/flareon/src/error.rs +++ b/flareon/src/error.rs @@ -72,6 +72,8 @@ impl_error_from_repr!(crate::router::path::ReverseError); impl_error_from_repr!(crate::db::DatabaseError); impl_error_from_repr!(crate::forms::FormError); impl_error_from_repr!(crate::auth::AuthError); +#[cfg(feature = "json")] +impl_error_from_repr!(serde_json::Error); #[derive(Debug, Error)] #[non_exhaustive] @@ -86,7 +88,7 @@ pub(crate) enum ErrorRepr { source: Box, }, /// The request body had an invalid `Content-Type` header. - #[error("Invalid content type; expected {expected}, found {actual}")] + #[error("Invalid content type; expected `{expected}`, found `{actual}`")] InvalidContentType { expected: &'static str, actual: String, @@ -114,6 +116,10 @@ pub(crate) enum ErrorRepr { /// An error occurred while trying to authenticate a user. #[error("Failed to authenticate user: {0}")] AuthenticationError(#[from] crate::auth::AuthError), + /// An error occurred while trying to serialize or deserialize JSON. + #[error("JSON error: {0}")] + #[cfg(feature = "json")] + JsonError(#[from] serde_json::Error), } #[cfg(test)] diff --git a/flareon/src/headers.rs b/flareon/src/headers.rs index d3d9d89..aa11f0a 100644 --- a/flareon/src/headers.rs +++ b/flareon/src/headers.rs @@ -1,2 +1,4 @@ pub(crate) const HTML_CONTENT_TYPE: &str = "text/html; charset=utf-8"; pub(crate) const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded"; +#[cfg(feature = "json")] +pub(crate) const JSON_CONTENT_TYPE: &str = "application/json"; diff --git a/flareon/src/request.rs b/flareon/src/request.rs index 6126087..98e97b1 100644 --- a/flareon/src/request.rs +++ b/flareon/src/request.rs @@ -17,6 +17,8 @@ use std::sync::Arc; use async_trait::async_trait; use bytes::Bytes; +#[cfg(feature = "json")] +use flareon::headers::JSON_CONTENT_TYPE; use indexmap::IndexMap; use tower_sessions::Session; @@ -78,11 +80,40 @@ pub trait RequestExt: private::Sealed { /// Throws an error if the request method is not GET or HEAD and the content /// type is not `application/x-www-form-urlencoded`. /// Throws an error if the request body could not be read. + async fn form_data(&mut self) -> Result; + + /// Get the request body as JSON and deserialize it into a type `T` + /// implementing `serde::de::DeserializeOwned`. /// - /// # Returns + /// The content type of the request must be `application/json`. /// - /// The request body as bytes. - async fn form_data(&mut self) -> Result; + /// # Errors + /// + /// Throws an error if the content type is not `application/json`. + /// Throws an error if the request body could not be read. + /// Throws an error if the request body could not be deserialized - either + /// because the JSON is invalid or because the deserialization to the target + /// structure failed. + /// + /// # Example + /// + /// ``` + /// use flareon::request::{Request, RequestExt}; + /// use flareon::response::{Response, ResponseExt}; + /// use serde::{Deserialize, Serialize}; + /// + /// #[derive(Serialize, Deserialize)] + /// struct MyData { + /// hello: String, + /// } + /// + /// async fn my_handler(mut request: Request) -> flareon::Result { + /// let data: MyData = request.json().await?; + /// Ok(Response::new_json(flareon::StatusCode::OK, &data)?) + /// } + /// ``` + #[cfg(feature = "json")] + async fn json(&mut self) -> Result; #[must_use] fn content_type(&self) -> Option<&http::HeaderValue>; @@ -152,6 +183,16 @@ impl RequestExt for Request { } } + #[cfg(feature = "json")] + async fn json(&mut self) -> Result { + self.expect_content_type(JSON_CONTENT_TYPE)?; + + let body = std::mem::take(self.body_mut()); + let bytes = body.into_bytes().await?; + + Ok(serde_json::from_slice(&bytes)?) + } + fn content_type(&self) -> Option<&http::HeaderValue> { self.headers().get(http::header::CONTENT_TYPE) } @@ -204,3 +245,55 @@ impl PathParams { pub(crate) fn query_pairs(bytes: &Bytes) -> impl Iterator, Cow)> { form_urlencoded::parse(bytes.as_ref()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_form_data() { + let mut request = http::Request::builder() + .method(http::Method::POST) + .header(http::header::CONTENT_TYPE, FORM_CONTENT_TYPE) + .body(Body::fixed("hello=world")) + .unwrap(); + + let bytes = request.form_data().await.unwrap(); + assert_eq!(bytes, Bytes::from_static(b"hello=world")); + } + + #[cfg(feature = "json")] + #[tokio::test] + async fn test_json() { + let mut request = http::Request::builder() + .method(http::Method::POST) + .header(http::header::CONTENT_TYPE, JSON_CONTENT_TYPE) + .body(Body::fixed(r#"{"hello":"world"}"#)) + .unwrap(); + + let data: serde_json::Value = request.json().await.unwrap(); + assert_eq!(data, serde_json::json!({"hello": "world"})); + } + + #[test] + fn test_path_params() { + let mut path_params = PathParams::new(); + path_params.insert("name".into(), "world".into()); + + assert_eq!(path_params.get("name"), Some("world")); + assert_eq!(path_params.get("missing"), None); + } + + #[test] + fn test_query_pairs() { + let bytes = Bytes::from_static(b"hello=world&foo=bar"); + let pairs: Vec<_> = query_pairs(&bytes).collect(); + assert_eq!( + pairs, + vec![ + (Cow::from("hello"), Cow::from("world")), + (Cow::from("foo"), Cow::from("bar")) + ] + ); + } +} diff --git a/flareon/src/response.rs b/flareon/src/response.rs index c7ebfc7..3373539 100644 --- a/flareon/src/response.rs +++ b/flareon/src/response.rs @@ -12,7 +12,7 @@ //! use flareon::response::ResponseExt; //! ``` -use crate::headers::HTML_CONTENT_TYPE; +use crate::headers::{HTML_CONTENT_TYPE, JSON_CONTENT_TYPE}; use crate::{Body, StatusCode}; const RESPONSE_BUILD_FAILURE: &str = "Failed to build response"; @@ -31,13 +31,45 @@ mod private { /// /// This trait is sealed since it doesn't make sense to be implemented for types /// outside the context of Flareon. -pub trait ResponseExt: private::Sealed { +pub trait ResponseExt: Sized + private::Sealed { #[must_use] fn builder() -> http::response::Builder; #[must_use] fn new_html(status: StatusCode, body: Body) -> Self; + /// Create a new JSON response. + /// + /// This function will create a new response with a content type of + /// `application/json` and a body that is the JSON-serialized version of the + /// provided instance of a type implementing `serde::Serialize`. + /// + /// # Errors + /// + /// This function will return an error if the data could not be serialized + /// to JSON. + /// + /// # Examples + /// + /// ``` + /// use flareon::response::{Response, ResponseExt}; + /// use flareon::{Body, StatusCode}; + /// use serde::Serialize; + /// + /// #[derive(Serialize)] + /// struct MyData { + /// hello: String, + /// } + /// + /// let data = MyData { + /// hello: String::from("world"), + /// }; + /// let response = Response::new_json(StatusCode::OK, &data)?; + /// # Ok::<(), flareon::Error>(()) + /// ``` + #[cfg(feature = "json")] + fn new_json(status: StatusCode, data: &T) -> crate::Result; + #[must_use] fn new_redirect>(location: T) -> Self; } @@ -59,6 +91,15 @@ impl ResponseExt for Response { .expect(RESPONSE_BUILD_FAILURE) } + #[cfg(feature = "json")] + fn new_json(status: StatusCode, data: &T) -> crate::Result { + Ok(http::Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, JSON_CONTENT_TYPE) + .body(Body::fixed(serde_json::to_string(data)?)) + .expect(RESPONSE_BUILD_FAILURE)) + } + #[must_use] fn new_redirect>(location: T) -> Self { http::Response::builder() @@ -74,6 +115,7 @@ mod tests { use super::*; use crate::headers::HTML_CONTENT_TYPE; use crate::response::{Response, ResponseExt}; + use crate::BodyInner; #[test] fn response_new_html() { @@ -86,6 +128,33 @@ mod tests { ); } + #[test] + #[cfg(feature = "json")] + fn response_new_json() { + #[derive(serde::Serialize)] + struct MyData { + hello: String, + } + + let data = MyData { + hello: String::from("world"), + }; + let response = Response::new_json(StatusCode::OK, &data).unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(http::header::CONTENT_TYPE).unwrap(), + JSON_CONTENT_TYPE + ); + match &response.body().inner { + BodyInner::Fixed(fixed) => { + assert_eq!(fixed, "{\"hello\":\"world\"}"); + } + _ => { + panic!("Expected fixed body"); + } + } + } + #[test] fn response_new_redirect() { let location = "http://example.com"; diff --git a/flareon/src/test.rs b/flareon/src/test.rs index 90bd598..db5498a 100644 --- a/flareon/src/test.rs +++ b/flareon/src/test.rs @@ -66,6 +66,8 @@ pub struct TestRequestBuilder { #[cfg(feature = "db")] database: Option>, form_data: Option>, + #[cfg(feature = "json")] + json_data: Option, } impl TestRequestBuilder { @@ -140,6 +142,12 @@ impl TestRequestBuilder { self } + #[cfg(feature = "json")] + pub fn json(&mut self, data: &T) -> &mut Self { + self.json_data = Some(serde_json::to_string(data).expect("Failed to serialize JSON")); + self + } + #[must_use] pub fn build(&mut self) -> http::Request { let mut request = http::Request::builder() @@ -178,6 +186,15 @@ impl TestRequestBuilder { ); } + #[cfg(feature = "json")] + if let Some(json_data) = &self.json_data { + *request.body_mut() = Body::fixed(json_data.clone()); + request.headers_mut().insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + } + request } }