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 dca9a677d..6e3575238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +* 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 + * feat: smart migration generator. you can now generate migration based on naming them for creating a table, adding columns, references, join tables and more. [https://github.com/loco-rs/loco/pull/1086](https://github.com/loco-rs/loco/pull/1086) * feat: `cargo loco routes` will now pretty-print routes * fix: guard jwt error behind feature flag. [https://github.com/loco-rs/loco/pull/1032](https://github.com/loco-rs/loco/pull/1032) diff --git a/Cargo.toml b/Cargo.toml index c264918cf..299e6ee91 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/lib.rs b/loco-gen/src/lib.rs index fc26b8f67..676b4c8d5 100644 --- a/loco-gen/src/lib.rs +++ b/loco-gen/src/lib.rs @@ -31,7 +31,7 @@ pub struct GenerateResults { rrgen: Vec, local_templates: Vec, } -const DEPLOYMENT_SHUTTLE_RUNTIME_VERSION: &str = "0.46.0"; +const DEPLOYMENT_SHUTTLE_RUNTIME_VERSION: &str = "0.51.0"; const DEPLOYMENT_OPTIONS: &[(&str, DeploymentKind)] = &[ ("Docker", DeploymentKind::Docker), diff --git a/loco-gen/src/templates/deployment/shuttle/config.t b/loco-gen/src/templates/deployment/shuttle/config.t index d5de71eef..0c20a9db1 100644 --- a/loco-gen/src/templates/deployment/shuttle/config.t +++ b/loco-gen/src/templates/deployment/shuttle/config.t @@ -2,4 +2,12 @@ to: "Shuttle.toml" skip_exists: true message: "Shuttle.toml file created successfully" --- -name = "{{pkg_name}}" +[deploy] +include = [ + "config/production.yaml" +] + +[build] +assets = [ + "config/production.yaml" +] diff --git a/loco-gen/src/templates/deployment/shuttle/shuttle.t b/loco-gen/src/templates/deployment/shuttle/shuttle.t index cfba68518..ffaeec485 100644 --- a/loco-gen/src/templates/deployment/shuttle/shuttle.t +++ b/loco-gen/src/templates/deployment/shuttle/shuttle.t @@ -39,7 +39,12 @@ async fn main( shuttle_runtime::Environment::Local => Environment::Development, shuttle_runtime::Environment::Deployment => Environment::Production, }; - let boot_result = create_app::(StartMode::ServerOnly, &environment) + + let config = environment + .load() + .expect("Failed to load configuration from the environment"); + + let boot_result = create_app::(StartMode::ServerOnly, &environment, config) .await .unwrap(); 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/deployment.rs b/loco-gen/tests/templates/deployment.rs index 7dea78da5..8fc9f64ca 100644 --- a/loco-gen/tests/templates/deployment.rs +++ b/loco-gen/tests/templates/deployment.rs @@ -99,3 +99,73 @@ fn can_generate_nginx() { .expect("nginx config missing") ); } + +#[test] +fn can_generate_shuttle() { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("deployment"); + let _guard = settings.bind_to_scope(); + + let component = Component::Deployment { + kind: DeploymentKind::Shuttle, + fallback_file: None, + asset_folder: None, + host: "localhost".to_string(), + port: 8080, + }; + + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add( + ".cargo/config.toml", + r#"[alias] +loco = "run --" +loco-tool = "run --" + +playground = "run --example playground" +"#, + ) + .add( + "Cargo.toml", + r" +[dependencies] + +[dev-dependencies] + +", + ) + .create() + .unwrap(); + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Generation failed"); + + assert_eq!( + collect_messages(&gen_result), + r"* Shuttle.toml file created successfully +* Shuttle deployment ready do use +" + ); + assert_snapshot!( + "generate[shuttle.rs]", + fs::read_to_string(tree_fs.root.join("src").join("bin").join("shuttle.rs")) + .expect("shuttle rs missing") + ); + assert_snapshot!( + "inject[.config_toml]", + fs::read_to_string(tree_fs.root.join(".cargo").join("config.toml")) + .expect(".cargo/config.toml not exists") + ); + assert_snapshot!( + "inject[cargo_toml]", + fs::read_to_string(tree_fs.root.join("Cargo.toml")).expect("cargo.toml not exists") + ); +} 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/loco-gen/tests/templates/snapshots/generate[shuttle.rs]@deployment.snap b/loco-gen/tests/templates/snapshots/generate[shuttle.rs]@deployment.snap new file mode 100644 index 000000000..7fc23a3ee --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[shuttle.rs]@deployment.snap @@ -0,0 +1,32 @@ +--- +source: loco-gen/tests/templates/deployment.rs +expression: "fs::read_to_string(tree_fs.root.join(\"src\").join(\"bin\").join(\"shuttle.rs\")).expect(\"shuttle rs missing\")" +--- +use loco_rs::boot::{create_app, StartMode}; +use loco_rs::environment::Environment; +use tester::app::App; +use migration::Migrator; +use shuttle_runtime::DeploymentMetadata; + +#[shuttle_runtime::main] +async fn main( + #[shuttle_shared_db::Postgres] conn_str: String, + #[shuttle_runtime::Metadata] meta: DeploymentMetadata, +) -> shuttle_axum::ShuttleAxum { + std::env::set_var("DATABASE_URL", conn_str); + let environment = match meta.env { + shuttle_runtime::Environment::Local => Environment::Development, + shuttle_runtime::Environment::Deployment => Environment::Production, + }; + + let config = environment + .load() + .expect("Failed to load configuration from the environment"); + + let boot_result = create_app::(StartMode::ServerOnly, &environment, config) + .await + .unwrap(); + + let router = boot_result.router.unwrap(); + Ok(router.into()) +} diff --git a/loco-gen/tests/templates/snapshots/inject[.config_toml]@deployment.snap b/loco-gen/tests/templates/snapshots/inject[.config_toml]@deployment.snap new file mode 100644 index 000000000..34d100122 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[.config_toml]@deployment.snap @@ -0,0 +1,9 @@ +--- +source: loco-gen/tests/templates/deployment.rs +expression: "fs::read_to_string(tree_fs.root.join(\".cargo\").join(\"config.toml\")).expect(\".cargo/config.toml not exists\")" +--- +[alias] +loco = "run --bin tester-cli --" +loco-tool = "run --" + +playground = "run --example playground" diff --git a/loco-gen/tests/templates/snapshots/inject[cargo_toml]@deployment.snap b/loco-gen/tests/templates/snapshots/inject[cargo_toml]@deployment.snap new file mode 100644 index 000000000..f974b26b4 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[cargo_toml]@deployment.snap @@ -0,0 +1,15 @@ +--- +source: loco-gen/tests/templates/deployment.rs +expression: "fs::read_to_string(tree_fs.root.join(\"Cargo.toml\")).expect(\"cargo.toml not exists\")" +--- +[dependencies] +shuttle-axum = "0.51.0" +shuttle-runtime = { version = "0.51.0", default-features = false } +shuttle-shared-db = { version = "0.51.0", features = ["postgres"] } + + +[[bin]] +name = "tester" +path = "src/bin/shuttle.rs" + +[dev-dependencies] 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 e499ca9c6..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, } } } @@ -172,6 +178,7 @@ impl IntoResponse for Json { impl IntoResponse for Error { /// Convert an `Error` into an HTTP response. + #[allow(clippy::cognitive_complexity)] fn into_response(self) -> Response { match &self { Self::WithBacktrace { @@ -221,6 +228,29 @@ impl IntoResponse for Error { StatusCode::BAD_REQUEST, ErrorDetail::new("Bad Request", &err), ), + Self::JsonRejection(err) => { + tracing::debug!(err = err.body_text(), "json rejection"); + (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 c68b90bd8..c9202c0be 100644 --- a/tests/controller/into_response.rs +++ b/tests/controller/into_response.rs @@ -1,5 +1,6 @@ use crate::infra_cfg; use loco_rs::{controller, prelude::*, tests_cfg}; +use serde::{Deserialize, Serialize}; use serial_test::serial; #[tokio::test] @@ -138,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, }, )) } @@ -162,3 +164,43 @@ async fn custom_error() { handle.abort(); } + +#[tokio::test] +#[serial] +async fn json_rejection() { + let ctx = tests_cfg::app::get_app_context().await; + + #[allow(clippy::items_after_statements)] + #[derive(Debug, Deserialize, Serialize)] + pub struct Data { + pub email: String, + } + + #[allow(clippy::items_after_statements)] + async fn action(Json(_params): Json) -> Result { + format::json(()) + } + + let handle = infra_cfg::server::start_with_route(ctx, "/", post(action)).await; + + let client = reqwest::Client::new(); + let res = client + .post(infra_cfg::server::get_base_url()) + .json(&serde_json::json!({})) + .send() + .await + .expect("Valid response"); + + assert_eq!(res.status(), 422); + + 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(); +} 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(); +}