Skip to content

Commit

Permalink
feat: add JSON request/response support (#80)
Browse files Browse the repository at this point in the history
  • Loading branch information
m4tx authored Nov 27, 2024
1 parent e1c11ed commit 479d162
Show file tree
Hide file tree
Showing 10 changed files with 270 additions and 8 deletions.
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ members = [
"examples/todo-list",
"examples/sessions",
"examples/admin",
"examples/json",
]
resolver = "2"

Expand Down Expand Up @@ -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 }
Expand Down
10 changes: 10 additions & 0 deletions examples/json/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
50 changes: 50 additions & 0 deletions examples/json/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<FlareonProject> {
let flareon_project = FlareonProject::builder()
.register_app_with_views(AddApp, "")
.build()
.await?;

Ok(flareon_project)
}
6 changes: 4 additions & 2 deletions flareon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down Expand Up @@ -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"]
10 changes: 8 additions & 2 deletions flareon/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -86,7 +88,7 @@ pub(crate) enum ErrorRepr {
source: Box<dyn std::error::Error + Send + Sync>,
},
/// 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,
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -145,7 +151,7 @@ mod tests {

assert_eq!(
display,
"Invalid content type; expected application/json, found text/html"
"Invalid content type; expected `application/json`, found `text/html`"
);
}

Expand Down
2 changes: 2 additions & 0 deletions flareon/src/headers.rs
Original file line number Diff line number Diff line change
@@ -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";
99 changes: 96 additions & 3 deletions flareon/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Bytes>;

/// 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<Bytes>;
/// # 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<Response> {
/// let data: MyData = request.json().await?;
/// Ok(Response::new_json(flareon::StatusCode::OK, &data)?)
/// }
/// ```
#[cfg(feature = "json")]
async fn json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T>;

#[must_use]
fn content_type(&self) -> Option<&http::HeaderValue>;
Expand Down Expand Up @@ -152,6 +183,16 @@ impl RequestExt for Request {
}
}

#[cfg(feature = "json")]
async fn json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T> {
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)
}
Expand Down Expand Up @@ -204,3 +245,55 @@ impl PathParams {
pub(crate) fn query_pairs(bytes: &Bytes) -> impl Iterator<Item = (Cow<str>, Cow<str>)> {
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"))
]
);
}
}
Loading

0 comments on commit 479d162

Please sign in to comment.