diff --git a/docs/.agent/docs_inventory.md b/docs/.agent/docs_inventory.md index b5ade76..c336ebb 100644 --- a/docs/.agent/docs_inventory.md +++ b/docs/.agent/docs_inventory.md @@ -7,6 +7,7 @@ | `docs/cookbook/src/SUMMARY.md` | Cookbook navigation structure | Docs | OK | | `docs/cookbook/src/learning/curriculum.md` | Structured learning path | Docs | Updated (Mini Projects) | | `docs/cookbook/src/recipes/file_uploads.md` | Recipe for File Uploads | Docs | Updated (Buffered) | +| `docs/cookbook/src/recipes/validation.md` | Advanced Validation Patterns | Docs | OK | | `docs/cookbook/src/recipes/websockets.md` | Recipe for Real-time Chat | Docs | Updated (Extractors) | | `docs/cookbook/src/recipes/background_jobs.md` | Recipe for Background Jobs | Docs | OK | | `docs/cookbook/src/recipes/tuning.md` | Performance Tuning | Docs | DELETED | diff --git a/docs/.agent/last_run.json b/docs/.agent/last_run.json index f1c8034..e3839e0 100644 --- a/docs/.agent/last_run.json +++ b/docs/.agent/last_run.json @@ -1,5 +1,5 @@ { "last_processed_ref": "v0.1.335", - "date": "2026-02-23", - "notes": "Fixed MockServer examples in testing recipe. Added Graceful Shutdown recipe. Enhanced Learning Path with 'The Email Worker' mini-project." + "date": "2026-03-01", + "notes": "Added Advanced Validation Patterns recipe. Enhanced Learning Path with 'The Live Chat Room' mini-project. Fixed docs for File Uploads and Testing." } diff --git a/docs/.agent/run_report_2026-03-01.md b/docs/.agent/run_report_2026-03-01.md new file mode 100644 index 0000000..ad53ed2 --- /dev/null +++ b/docs/.agent/run_report_2026-03-01.md @@ -0,0 +1,27 @@ +# Documentation Run Report: 2026-03-01 + +**Status**: Success +**Detected Version**: v0.1.335 (No change) +**Focus**: Cookbook Expansion & Learning Path Improvement + +## Changes + +### 1. Fixes +- **`docs/cookbook/src/recipes/file_uploads.md`**: Clarified usage of `.body_limit()` vs `DefaultBodyLimit` middleware. Removed confusing double-configuration in the example. +- **`docs/cookbook/src/recipes/testing.md`**: Fixed missing `RequestMatcher` import in `MockServer` example. + +### 2. New Recipes +- **`docs/cookbook/src/recipes/validation.md`**: Added "Advanced Validation Patterns" covering custom validators, cross-field validation, and error customization. + +### 3. Learning Path Improvements +- **`docs/cookbook/src/learning/curriculum.md`**: + - **Module 9 (WebSockets)**: Added "The Live Chat Room" mini-project. + - **Module 14 (High Performance)**: Added explicit instruction to enable `http3` feature. + - **Module 5 (Validation)**: Linked to the new Validation recipe. + +### 4. Index Updates +- Added "Advanced Validation" to `docs/cookbook/src/SUMMARY.md`. + +## Next Steps +- Consider a recipe for "Structured Logging with Tracing". +- Review "Module 12: Observability" for potential mini-project additions. diff --git a/docs/cookbook/src/SUMMARY.md b/docs/cookbook/src/SUMMARY.md index 998b872..8e302a9 100644 --- a/docs/cookbook/src/SUMMARY.md +++ b/docs/cookbook/src/SUMMARY.md @@ -35,6 +35,7 @@ - [OAuth2 Client](recipes/oauth2_client.md) - [CSRF Protection](recipes/csrf_protection.md) - [Database Integration](recipes/db_integration.md) + - [Advanced Validation](recipes/validation.md) - [Testing & Mocking](recipes/testing.md) - [File Uploads](recipes/file_uploads.md) - [Background Jobs](recipes/background_jobs.md) diff --git a/docs/cookbook/src/learning/curriculum.md b/docs/cookbook/src/learning/curriculum.md index f95e1d6..d2723d5 100644 --- a/docs/cookbook/src/learning/curriculum.md +++ b/docs/cookbook/src/learning/curriculum.md @@ -92,7 +92,7 @@ Create a `POST /register` endpoint that accepts a JSON body `{"username": "...", ### Module 5: Validation - **Prerequisites:** Module 4. -- **Reading:** [Validation](../crates/rustapi_validation.md). +- **Reading:** [Validation](../crates/rustapi_validation.md), [Advanced Validation Patterns](../recipes/validation.md). - **Task:** Add `#[derive(Validate)]` to your `User` struct. Use `ValidatedJson`. - **Expected Output:** Requests with invalid email or short password return `422 Unprocessable Entity`. - **Pitfalls:** Forgetting to add `#[validate]` attributes to struct fields. @@ -189,6 +189,12 @@ Create a `POST /register` endpoint that accepts a JSON body `{"username": "...", - **Expected Output:** Multiple clients connected via WS receiving messages in real-time. - **Pitfalls:** Blocking the WebSocket loop with long-running synchronous tasks. +#### 🛠️ Mini Project: "The Live Chat Room" +Create a simple chat room where multiple users can connect and send messages. +1. Use `broadcast::channel` to distribute messages. +2. Store the `broadcast::Sender` in `AppState`. +3. (Bonus) Add a "system" message when a user joins or leaves. + #### 🧠 Knowledge Check 1. How do you upgrade an HTTP request to a WebSocket connection? 2. Can you share state between HTTP handlers and WebSocket handlers? @@ -279,7 +285,7 @@ Create a system where users can request a "Report". - **Prerequisites:** Phase 3. - **Reading:** [HTTP/3 (QUIC)](../recipes/http3_quic.md), [Performance Tuning](../recipes/high_performance.md), [Compression](../recipes/compression.md). - **Task:** - 1. Enable `http3` feature and generate self-signed certs. + 1. Enable `http3` feature in `Cargo.toml` (`features = ["http3"]`) and generate self-signed certs. 2. Serve traffic over QUIC. 3. Add `CompressionLayer` to compress large responses. - **Expected Output:** Browser/Client connects via HTTP/3. Responses have `content-encoding: gzip`. diff --git a/docs/cookbook/src/recipes/file_uploads.md b/docs/cookbook/src/recipes/file_uploads.md index bb5d67c..4aeef2b 100644 --- a/docs/cookbook/src/recipes/file_uploads.md +++ b/docs/cookbook/src/recipes/file_uploads.md @@ -31,12 +31,10 @@ async fn main() -> Result<(), Box> { RustApi::new() // Increase body limit to 1GB (default is usually 1MB) - .body_limit(1024 * 1024 * 1024) - .route("/upload", post(upload_handler)) - // Increase body limit to 50MB (default is usually 2MB) // ⚠️ IMPORTANT: Since Multipart buffers the whole body, // setting this too high can exhaust server memory. - .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) + .body_limit(1024 * 1024 * 1024) + .route("/upload", post(upload_handler)) .run("127.0.0.1:8080") .await } @@ -107,7 +105,7 @@ RustAPI loads the entire `multipart/form-data` body into memory. - **Mitigation**: Set a reasonable `DefaultBodyLimit` (e.g., 10MB - 100MB) to prevent DoS attacks. ### 2. Body Limits -The default request body limit is small (2MB) to prevent attacks. You **must** explicitly increase this limit for file upload routes using `.layer(DefaultBodyLimit::max(size_in_bytes))`. +The default request body limit is small (1MB) to prevent attacks. You **must** explicitly increase this limit for file upload routes using the `.body_limit(size_in_bytes)` method on the `RustApi` builder. ### 3. Security - **Path Traversal**: Malicious users can send filenames like `../../system32/cmd.exe`. Always rename files or sanitize filenames strictly. diff --git a/docs/cookbook/src/recipes/testing.md b/docs/cookbook/src/recipes/testing.md index 72c7db5..5f98034 100644 --- a/docs/cookbook/src/recipes/testing.md +++ b/docs/cookbook/src/recipes/testing.md @@ -84,7 +84,7 @@ When your API calls external services (e.g., payment gateways, third-party APIs) `rustapi-testing` provides `MockServer` for this purpose. ```rust -use rustapi_testing::{MockServer, MockResponse}; +use rustapi_testing::{MockServer, MockResponse, RequestMatcher}; #[tokio::test] async fn test_external_integration() { @@ -93,7 +93,7 @@ async fn test_external_integration() { // 2. Define an expectation mock_server.expect( - rustapi_testing::RequestMatcher::new() + RequestMatcher::new() .method("GET") .path("/external-data") ).respond_with( diff --git a/docs/cookbook/src/recipes/validation.md b/docs/cookbook/src/recipes/validation.md new file mode 100644 index 0000000..38a0c5d --- /dev/null +++ b/docs/cookbook/src/recipes/validation.md @@ -0,0 +1,173 @@ +# Advanced Validation Patterns + +While simple validation (length, range, email) is straightforward with `#[derive(Validate)]`, real-world applications often require complex logic, such as cross-field checks, custom business rules, and asynchronous database lookups. + +## Custom Synchronous Validators + +You can define custom validation logic by writing a function and referencing it with `#[validate(custom = "...")]`. + +### Example: Password Strength + +```rust +use rustapi_macros::Validate; +use rustapi_validate::ValidationError; + +#[derive(Debug, Deserialize, Validate)] +pub struct SignupRequest { + #[validate(custom = "validate_password_strength")] + pub password: String, +} + +fn validate_password_strength(password: &String) -> Result<(), ValidationError> { + if password.len() < 8 { + return Err(ValidationError::new("password_too_short")); + } + + let has_uppercase = password.chars().any(|c| c.is_uppercase()); + let has_number = password.chars().any(|c| c.is_numeric()); + + if !has_uppercase || !has_number { + return Err(ValidationError::new("password_too_weak")); + } + + Ok(()) +} +``` + +## Cross-Field Validation + +Sometimes validation depends on multiple fields (e.g., "start date must be before end date" or "password confirmation must match"). Since the `Validate` macro works on individual fields, cross-field validation is typically done on the struct level. + +Currently, `rustapi-validate` focuses on field-level validation. For struct-level checks, you can implement a custom method and call it manually, or use a "virtual" field strategy. + +A common pattern is to validate the struct *after* extraction: + +```rust +use rustapi_rs::prelude::*; + +#[derive(Debug, Deserialize, Validate)] +pub struct DateRange { + pub start: chrono::NaiveDate, + pub end: chrono::NaiveDate, +} + +impl DateRange { + fn validate_logical(&self) -> Result<(), ApiError> { + if self.start > self.end { + return Err(ApiError::unprocessable_entity( + "start_date_after_end_date", + "Start date must be before end date" + )); + } + Ok(()) + } +} + +async fn create_event( + ValidatedJson(payload): ValidatedJson +) -> Result { + // 1. Basic field validation passes automatically + + // 2. Perform cross-field validation + payload.validate_logical()?; + + Ok(Json("Event created")) +} +``` + +## Custom Asynchronous Validators + +When you need to check an external source (like a database) during validation, use `#[validate(custom_async = "...")]`. + +### Example: Unique Email Check + +```rust +use rustapi_macros::Validate; +use rustapi_validate::v2::{ValidationContext, RuleError}; +use std::sync::Arc; + +// Define your application state +struct AppState { + db: sqlx::PgPool, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct CreateUserRequest { + #[validate(custom_async = "check_email_unique")] + pub email: String, +} + +// The async validator receives the value and the validation context +async fn check_email_unique(email: &String, ctx: &ValidationContext) -> Result<(), RuleError> { + // 1. Retrieve the database connection from the context + // The context wraps the AppState you provided to the server + let state = ctx.get::>() + .ok_or_else(|| RuleError::new("internal", "Database not available"))?; + + // 2. Perform the query + let exists = sqlx::query_scalar!("SELECT 1 FROM users WHERE email = $1", email) + .fetch_optional(&state.db) + .await + .map_err(|_| RuleError::new("db_error", "Database error"))? + .is_some(); + + if exists { + return Err(RuleError::new("email_taken", "This email is already registered")); + } + + Ok(()) +} +``` + +### Registering the Context + +For async validation to work, you must ensure your application state is available to the validator. `AsyncValidatedJson` attempts to extract `ValidationContext` from the request state. + +Typically, if you use `RustApi::new().state(...)`, the state is automatically available. + +```rust +use rustapi_rs::prelude::*; + +#[tokio::main] +async fn main() { + let state = Arc::new(AppState { /* ... */ }); + + RustApi::new() + .state(state) // Injected into ValidationContext automatically + .route("/users", post(create_user)) + .run("127.0.0.1:8080") + .await + .unwrap(); +} + +async fn create_user( + AsyncValidatedJson(payload): AsyncValidatedJson +) -> impl IntoResponse { + // payload is valid and email is unique + Json(payload) +} +``` + +## Customizing Error Messages + +You can override default error messages in the attribute: + +```rust +#[derive(Validate)] +struct Request { + #[validate(length(min = 5, message = "Username must be at least 5 characters"))] + username: String, + + #[validate(email(message = "Please provide a valid email address"))] + email: String, +} +``` + +For custom validators, the `ValidationError` or `RuleError` constructor takes a code and a message: + +```rust +ValidationError::new("custom_code").with_message("Friendly error message"); +RuleError::new("custom_code", "Friendly error message"); +``` + +This structured error format allows frontend clients to display localized or specific error messages based on the error code.