Skip to content

Commit

Permalink
return validation json response (#1174)
Browse files Browse the repository at this point in the history
* allow return validation json response

* allow return validation json response

* docs
  • Loading branch information
kaplanelad authored Jan 13, 2025
1 parent a602367 commit b4ca273
Show file tree
Hide file tree
Showing 9 changed files with 266 additions and 2 deletions.
64 changes: 64 additions & 0 deletions docs-site/content/docs/the-app/controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,70 @@ impl Hooks for App {
}
```

# Request Validation
`JsonValidate` extractor simplifies input [validation](https://github.com/Keats/validator) by integrating with the validator crate. Here's an example of how to validate incoming request data:

### Define Your Validation Rules
```rust
use axum::debug_handler;
use loco_rs::prelude::*;
use serde::Deserialize;
use validator::Validate;

#[derive(Debug, Deserialize, Validate)]
pub struct DataParams {
#[validate(length(min = 5, message = "custom message"))]
pub name: String,
#[validate(email)]
pub email: String,
}
```
### Create a Handler with Validation
```rust
use axum::debug_handler;
use loco_rs::prelude::*;

#[debug_handler]
pub async fn index(
State(_ctx): State<AppContext>,
JsonValidate(params): JsonValidate<DataParams>,
) -> Result<Response> {
format::empty()
}
```
Using the `JsonValidate` extractor, Loco automatically performs validation on the DataParams struct:
* If validation passes, the handler continues execution with params.
* If validation fails, a 400 Bad Request response is returned.

### Returning Validation Errors as JSON
If you'd like to return validation errors in a structured JSON format, use `JsonValidateWithMessage` instead of `JsonValidate`. The response format will look like this:

```json
{
"errors": {
"email": [
{
"code": "email",
"message": null,
"params": {
"value": "ad"
}
}
],
"name": [
{
"code": "length",
"message": "custom message",
"params": {
"min": 5,
"value": "d"
}
}
]
}
}
```

# Pagination

In many scenarios, when querying data and returning responses to users, pagination is crucial. In `Loco`, we provide a straightforward method to paginate your data and maintain a consistent pagination response schema for your API responses.
Expand Down
1 change: 1 addition & 0 deletions src/controller/extractor/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod validate;
78 changes: 78 additions & 0 deletions src/controller/extractor/validate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
use crate::Error;
use axum::extract::{Form, FromRequest, Json, Request};
use serde::de::DeserializeOwned;
use validator::Validate;

#[derive(Debug, Clone, Copy, Default)]
pub struct JsonValidateWithMessage<T>(pub T);

impl<T, S> FromRequest<S> for JsonValidateWithMessage<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = Error;

async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Json(value) = Json::<T>::from_request(req, state).await?;
value.validate()?;
Ok(Self(value))
}
}

#[derive(Debug, Clone, Copy, Default)]
pub struct FormValidateWithMessage<T>(pub T);

impl<T, S> FromRequest<S> for FormValidateWithMessage<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = Error;

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(Self(value))
}
}

#[derive(Debug, Clone, Copy, Default)]
pub struct JsonValidate<T>(pub T);

impl<T, S> FromRequest<S> for JsonValidate<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = Error;

async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Json(value) = Json::<T>::from_request(req, state).await?;
value.validate().map_err(|err| {
tracing::debug!(err = ?err, "request validation error occurred");
Error::BadRequest(String::new())
})?;
Ok(Self(value))
}
}

#[derive(Debug, Clone, Copy, Default)]
pub struct FormValidate<T>(pub T);

impl<T, S> FromRequest<S> for FormValidate<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = Error;

async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Form(value) = Form::<T>::from_request(req, state).await?;
value.validate().map_err(|err| {
tracing::debug!(err = ?err, "request validation error occurred");
Error::BadRequest(String::new())
})?;
Ok(Self(value))
}
}
28 changes: 26 additions & 2 deletions src/controller/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ use crate::{errors::Error, Result};
mod app_routes;
mod backtrace;
mod describe;
pub mod extractor;
pub mod format;
#[cfg(feature = "with-db")]
mod health;
Expand Down Expand Up @@ -138,15 +139,19 @@ pub struct ErrorDetail {
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errors: Option<serde_json::Value>,
}

impl ErrorDetail {
/// Create a new `ErrorDetail` with the specified error and description.
#[must_use]
pub fn new<T: Into<String>>(error: T, description: T) -> Self {
pub fn new<T: Into<String> + AsRef<str>>(error: T, description: T) -> Self {
let description = (!description.as_ref().is_empty()).then(|| description.into());
Self {
error: Some(error.into()),
description: Some(description.into()),
description,
errors: None,
}
}

Expand All @@ -156,6 +161,7 @@ impl ErrorDetail {
Self {
error: Some(error.into()),
description: None,
errors: None,
}
}
}
Expand Down Expand Up @@ -227,6 +233,24 @@ impl IntoResponse for Error {
(err.status(), ErrorDetail::with_reason("Bad Request"))
}

Self::ValidationError(ref errors) => serde_json::to_value(errors).map_or_else(
|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorDetail::new("internal_server_error", "Internal Server Error"),
)
},
|errors| {
(
StatusCode::BAD_REQUEST,
ErrorDetail {
error: None,
description: None,
errors: Some(errors),
},
)
},
),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
ErrorDetail::new("internal_server_error", "Internal Server Error"),
Expand Down
6 changes: 6 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ pub enum Error {

#[error(transparent)]
Any(#[from] Box<dyn std::error::Error + Send + Sync>),

#[error(transparent)]
ValidationError(#[from] validator::ValidationErrors),

#[error(transparent)]
AxumFormRejection(#[from] axum::extract::rejection::FormRejection),
}

impl Error {
Expand Down
1 change: 1 addition & 0 deletions src/prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub use sea_orm::{
// sugar for controller views to use `data!({"item": ..})` instead of `json!`
pub use serde_json::json as data;

pub use crate::controller::extractor::validate::{JsonValidate, JsonValidateWithMessage};
#[cfg(all(feature = "auth_jwt", feature = "with-db"))]
pub use crate::controller::middleware::auth;
#[cfg(feature = "with-db")]
Expand Down
1 change: 1 addition & 0 deletions tests/controller/into_response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ async fn custom_error() {
controller::ErrorDetail {
error: Some("Payload Too Large".to_string()),
description: Some("413 Payload Too Large".to_string()),
errors: None,
},
))
}
Expand Down
1 change: 1 addition & 0 deletions tests/controller/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
mod into_response;
mod middlewares;
mod validation_extractor;
88 changes: 88 additions & 0 deletions tests/controller/validation_extractor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use crate::infra_cfg;
use loco_rs::{prelude::*, tests_cfg};
use serde::{Deserialize, Serialize};
use serial_test::serial;
use validator::Validate;

#[derive(Debug, Deserialize, Serialize, Validate)]
pub struct Data {
#[validate(length(min = 5, message = "message_str"))]
pub name: String,
#[validate(email)]
pub email: String,
}

async fn validation_with_response(
JsonValidateWithMessage(_params): JsonValidateWithMessage<Data>,
) -> Result<Response> {
format::json(())
}

async fn simple_validation(JsonValidate(_params): JsonValidate<Data>) -> Result<Response> {
format::json(())
}

#[tokio::test]
#[serial]
async fn can_validation_with_response() {
let ctx = tests_cfg::app::get_app_context().await;

let handle =
infra_cfg::server::start_with_route(ctx, "/", post(validation_with_response)).await;

let client = reqwest::Client::new();
let res = client
.post(infra_cfg::server::get_base_url())
.json(&serde_json::json!({"name": "test", "email": "invalid"}))
.send()
.await
.expect("Valid response");

assert_eq!(res.status(), 400);

let res_text = res.text().await.expect("response text");
let res_json: serde_json::Value = serde_json::from_str(&res_text).expect("Valid JSON response");

let expected_json = serde_json::json!(
{
"errors":{
"email":[{"code":"email","message":null,"params":{"value":"invalid"}}],
"name":[{"code":"length","message":"message_str","params":{"min":5,"value":"test"}}]
}
});

assert_eq!(res_json, expected_json);

handle.abort();
}

#[tokio::test]
#[serial]
async fn can_validation_without_response() {
let ctx = tests_cfg::app::get_app_context().await;

let handle = infra_cfg::server::start_with_route(ctx, "/", post(simple_validation)).await;

let client = reqwest::Client::new();
let res = client
.post(infra_cfg::server::get_base_url())
.json(&serde_json::json!({"name": "test", "email": "invalid"}))
.send()
.await
.expect("Valid response");

assert_eq!(res.status(), 400);

let res_text = res.text().await.expect("response text");
let res_json: serde_json::Value = serde_json::from_str(&res_text).expect("Valid JSON response");

let expected_json = serde_json::json!(
{
"error": "Bad Request"
}
);

assert_eq!(res_json, expected_json);

handle.abort();
}

0 comments on commit b4ca273

Please sign in to comment.