diff --git a/.github/workflows/loco-gen-ci.yml b/.github/workflows/loco-gen-ci.yml index 3e8d01387..b170dedbb 100644 --- a/.github/workflows/loco-gen-ci.yml +++ b/.github/workflows/loco-gen-ci.yml @@ -4,7 +4,11 @@ on: push: branches: - master + paths: + - "loco-gen/**" pull_request: + paths: + - "loco-gen/**" env: RUST_TOOLCHAIN: stable diff --git a/.github/workflows/loco-gen-e2e.yml b/.github/workflows/loco-gen-e2e.yml index 0f25f13e3..223b784ab 100644 --- a/.github/workflows/loco-gen-e2e.yml +++ b/.github/workflows/loco-gen-e2e.yml @@ -4,7 +4,11 @@ on: push: branches: - master + paths: + - "loco-gen/**" pull_request: + paths: + - "loco-gen/**" env: RUST_TOOLCHAIN: stable diff --git a/.github/workflows/loco-rs-ci-sanity.yml b/.github/workflows/loco-rs-ci-sanity.yml new file mode 100644 index 000000000..80c3f8a58 --- /dev/null +++ b/.github/workflows/loco-rs-ci-sanity.yml @@ -0,0 +1,68 @@ +# To optimize CI runtime: +# A simpler "sanity check" workflow is introduced. +# This workflow only runs if changes in the PR do NOT include +# the `loco-gen` or `loco-new` paths. +# (When changes are made to `loco-gen` or `loco-new`, +# we run comprehensive tests to validate every generator command +# and template option.) + +# Purpose of the sanity check: +# It performs basic validation by comparing the local changes +# against the templates. +# If any breaking changes are detected in the templates, +# the sanity check will fail, signaling an issue. + +name: "[loco_rs:sanity]" + +on: + push: + branches: + - master + paths-ignore: + - "loco-gen/**" + - "loco-new/**" + pull_request: + paths-ignore: + - "loco-gen/**" + - "loco-new/**" + +env: + RUST_TOOLCHAIN: stable + TOOLCHAIN_PROFILE: minimal + +jobs: + sanity: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Install seaorm cli + run: cargo install sea-orm-cli + + - run: cargo install --path loco-new + + - run: | + loco new -n myappdb --db sqlite --bg async --assets serverside -a + cd myappdb + cargo check + env: + LOCO_DEV_MODE_PATH: ${{ github.workspace }} + + - run: | + loco new -n myappnodb --db none --bg none --assets none -a + cd myappdb + cargo check + env: + LOCO_DEV_MODE_PATH: ${{ github.workspace }} + + \ No newline at end of file diff --git a/.github/workflows/loco-rs-ci.yml b/.github/workflows/loco-rs-ci.yml index c055fe22b..770289934 100644 --- a/.github/workflows/loco-rs-ci.yml +++ b/.github/workflows/loco-rs-ci.yml @@ -37,10 +37,30 @@ jobs: command: clippy args: --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms - test: + check: needs: [style] runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-hack + - run: cargo hack check --each-feature + + test: + needs: [check, style] + runs-on: ubuntu-latest + permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc2612b3..6e3575238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ * fix: bump shuttle to 0.51.0. [https://github.com/loco-rs/loco/pull/1169](https://github.com/loco-rs/loco/pull/1169) * Return 422 status code for JSON rejection errors. [https://github.com/loco-rs/loco/pull/1173](https://github.com/loco-rs/loco/pull/1173) * Address clippy warnings for Rust stable 1.84. [https://github.com/loco-rs/loco/pull/1168](https://github.com/loco-rs/loco/pull/1168) +* Bump shuttle to 0.51.0. [https://github.com/loco-rs/loco/pull/1169](https://github.com/loco-rs/loco/pull/1169) +* return 422 status code for JSON rejection errors. [https://github.com/loco-rs/loco/pull/1173](https://github.com/loco-rs/loco/pull/1173) +* return json validation details response. [https://github.com/loco-rs/loco/pull/1174](https://github.com/loco-rs/loco/pull/1174) +* fix example command after generating schedule. [https://github.com/loco-rs/loco/pull/1176](https://github.com/loco-rs/loco/pull/1176) +* fixed independent features. [https://github.com/loco-rs/loco/pull/1177](https://github.com/loco-rs/loco/pull/1177) ## v0.14 diff --git a/Cargo.toml b/Cargo.toml index c512aaf98..34d257891 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,7 +102,7 @@ lettre = { version = "0.11.4", default-features = false, features = [ include_dir = "0.7.3" thiserror = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } +tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] } tracing-appender = "0.2.3" duct = { version = "0.13.6" } @@ -146,6 +146,7 @@ english-to-cron = { version = "0.1.2" } # bg_sqlt: sqlite workers # bg_pg: postgres workers sqlx = { version = "0.8.2", default-features = false, features = [ + "json", "postgres", "chrono", "sqlite", diff --git a/docs-site/content/docs/the-app/controller.md b/docs-site/content/docs/the-app/controller.md index ada9591d7..7c424d7be 100644 --- a/docs-site/content/docs/the-app/controller.md +++ b/docs-site/content/docs/the-app/controller.md @@ -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, + JsonValidate(params): JsonValidate, +) -> Result { + 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. diff --git a/loco-gen/src/templates/scheduler/scheduler.t b/loco-gen/src/templates/scheduler/scheduler.t index b83be8c2c..d1f548e83 100644 --- a/loco-gen/src/templates/scheduler/scheduler.t +++ b/loco-gen/src/templates/scheduler/scheduler.t @@ -1,6 +1,6 @@ to: "config/scheduler.yaml" skip_exists: true -message: "A Scheduler job configuration was added successfully. Run with `cargo loco run scheduler --list`." +message: "A Scheduler job configuration was added successfully. Run with `cargo loco scheduler --list`." --- output: stdout diff --git a/loco-gen/tests/templates/scheduler.rs b/loco-gen/tests/templates/scheduler.rs index 46acdb364..cc6448c11 100644 --- a/loco-gen/tests/templates/scheduler.rs +++ b/loco-gen/tests/templates/scheduler.rs @@ -31,7 +31,7 @@ fn can_generate() { assert_eq!( collect_messages(&gen_result), - r"* A Scheduler job configuration was added successfully. Run with `cargo loco run scheduler --list`. + r"* A Scheduler job configuration was added successfully. Run with `cargo loco scheduler --list`. " ); diff --git a/src/controller/extractor/mod.rs b/src/controller/extractor/mod.rs new file mode 100644 index 000000000..ae1452419 --- /dev/null +++ b/src/controller/extractor/mod.rs @@ -0,0 +1 @@ +pub mod validate; diff --git a/src/controller/extractor/validate.rs b/src/controller/extractor/validate.rs new file mode 100644 index 000000000..1376f0474 --- /dev/null +++ b/src/controller/extractor/validate.rs @@ -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(pub T); + +impl FromRequest for JsonValidateWithMessage +where + T: DeserializeOwned + Validate, + S: Send + Sync, +{ + type Rejection = Error; + + async fn from_request(req: Request, state: &S) -> Result { + let Json(value) = Json::::from_request(req, state).await?; + value.validate()?; + Ok(Self(value)) + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct FormValidateWithMessage(pub T); + +impl FromRequest for FormValidateWithMessage +where + T: DeserializeOwned + Validate, + S: Send + Sync, +{ + type Rejection = Error; + + async fn from_request(req: Request, state: &S) -> Result { + let Form(value) = Form::::from_request(req, state).await?; + value.validate()?; + Ok(Self(value)) + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct JsonValidate(pub T); + +impl FromRequest for JsonValidate +where + T: DeserializeOwned + Validate, + S: Send + Sync, +{ + type Rejection = Error; + + async fn from_request(req: Request, state: &S) -> Result { + let Json(value) = Json::::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(pub T); + +impl FromRequest for FormValidate +where + T: DeserializeOwned + Validate, + S: Send + Sync, +{ + type Rejection = Error; + + async fn from_request(req: Request, state: &S) -> Result { + let Form(value) = Form::::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)) + } +} diff --git a/src/controller/mod.rs b/src/controller/mod.rs index 51d44a606..51cba3f69 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -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; @@ -138,15 +139,19 @@ pub struct ErrorDetail { pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub errors: Option, } impl ErrorDetail { /// Create a new `ErrorDetail` with the specified error and description. #[must_use] - pub fn new>(error: T, description: T) -> Self { + pub fn new + AsRef>(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, } } @@ -156,6 +161,7 @@ impl ErrorDetail { Self { error: Some(error.into()), description: None, + errors: None, } } } @@ -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"), diff --git a/src/errors.rs b/src/errors.rs index d5aef1c94..ef91b4364 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -156,6 +156,12 @@ pub enum Error { #[error(transparent)] Any(#[from] Box), + + #[error(transparent)] + ValidationError(#[from] validator::ValidationErrors), + + #[error(transparent)] + AxumFormRejection(#[from] axum::extract::rejection::FormRejection), } impl Error { diff --git a/src/prelude.rs b/src/prelude.rs index 47353fd17..46c341919 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -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")] diff --git a/tests/controller/into_response.rs b/tests/controller/into_response.rs index 329f052a2..c9202c0be 100644 --- a/tests/controller/into_response.rs +++ b/tests/controller/into_response.rs @@ -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, }, )) } diff --git a/tests/controller/mod.rs b/tests/controller/mod.rs index ab2362484..c1b6a7505 100644 --- a/tests/controller/mod.rs +++ b/tests/controller/mod.rs @@ -1,2 +1,3 @@ mod into_response; mod middlewares; +mod validation_extractor; diff --git a/tests/controller/validation_extractor.rs b/tests/controller/validation_extractor.rs new file mode 100644 index 000000000..8467ae3bc --- /dev/null +++ b/tests/controller/validation_extractor.rs @@ -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, +) -> Result { + format::json(()) +} + +async fn simple_validation(JsonValidate(_params): JsonValidate) -> Result { + 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(); +}