Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

return validation json response #1174

Merged
merged 4 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
}
Loading