diff --git a/docs/content/guide/extractors.md b/docs/content/guide/extractors.md index b9bab74..3df67ae 100644 --- a/docs/content/guide/extractors.md +++ b/docs/content/guide/extractors.md @@ -17,6 +17,7 @@ Extractors automatically parse request data and inject it into your handlers. If | `Headers` | Request headers | | `State` | Application state | | `Context` | Request context (trace_id) | +| `Cookie` | Typed cookie access | | `CurrentUser` | Authenticated user (JWT) | | `Validated` | Validated extractor | @@ -124,6 +125,24 @@ async fn info(config: State) -> String { } ``` +## Cookies + +Deserialize cookies into typed structs: + +```rust +#[derive(Deserialize)] +struct Session { + session_id: String, +} + +#[get("/dashboard")] +async fn dashboard(session: Cookie) -> String { + format!("Session: {}", session.into_inner().session_id) +} +``` + +Returns 400 Bad Request if required cookies are missing or malformed. + ## Request Context Access the request context with trace ID: diff --git a/rapina/src/extract.rs b/rapina/src/extract.rs index e4be6ff..98572c1 100644 --- a/rapina/src/extract.rs +++ b/rapina/src/extract.rs @@ -132,6 +132,32 @@ pub struct Form(pub T); #[derive(Debug)] pub struct Headers(pub http::HeaderMap); +/// Extracts and deserializes cookies from the request. +/// +/// Parses the `Cookie` header into a typed struct. Each field in the struct +/// corresponds to a cookie name. Returns 400 Bad Request if parsing fails. +/// +/// Use `Option>` for optional cookie access. +/// +/// # Examples +/// +/// ```ignore +/// use rapina::prelude::*; +/// +/// #[derive(Deserialize)] +/// struct Session { +/// session_id: String, +/// } +/// +/// #[get("/dashboard")] +/// async fn dashboard(session: Cookie) -> Result> { +/// let data = session.into_inner(); +/// // Use data.session_id... +/// } +/// ``` +#[derive(Debug)] +pub struct Cookie(pub T); + /// Extracts application state. /// /// Provides access to shared application state that was registered @@ -270,6 +296,13 @@ impl Headers { } } +impl Cookie { + /// Consumes the extractor and returns the inner value. + pub fn into_inner(self) -> T { + self.0 + } +} + impl State { /// Consumes the extractor and returns the inner value. pub fn into_inner(self) -> T { @@ -463,6 +496,44 @@ impl FromRequestParts for Headers { } } +impl FromRequestParts for Cookie { + async fn from_request_parts( + parts: &http::request::Parts, + _params: &PathParams, + _state: &Arc, + ) -> Result { + let cookie_header = parts + .headers + .get(http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + // Parse cookies into key=value pairs + let cookies: HashMap = cookie_header + .split(';') + .filter_map(|pair| { + let mut parts = pair.trim().splitn(2, '='); + let key = parts.next()?.to_string(); + let value = parts.next()?.to_string(); + if key.is_empty() { + None + } else { + Some((key, value)) + } + }) + .collect(); + + // Serialize to JSON then deserialize to target type + let json = serde_json::to_string(&cookies) + .map_err(|e| Error::bad_request(format!("Failed to process cookies: {}", e)))?; + + let value: T = serde_json::from_str(&json) + .map_err(|e| Error::bad_request(format!("Invalid or missing cookies: {}", e)))?; + + Ok(Cookie(value)) + } +} + impl FromRequestParts for Path where T::Err: std::fmt::Display, @@ -840,4 +911,109 @@ mod tests { } ); } + + // Cookie extractor tests + #[tokio::test] + async fn test_cookie_extractor_success() { + #[derive(serde::Deserialize, Debug, PartialEq)] + struct Session { + session_id: String, + } + + let (parts, _) = TestRequest::get("/dashboard") + .header("cookie", "session_id=abc123") + .into_parts(); + + let result = + Cookie::::from_request_parts(&parts, &empty_params(), &empty_state()).await; + + assert!(result.is_ok()); + let cookie = result.unwrap(); + assert_eq!(cookie.0.session_id, "abc123"); + } + + #[tokio::test] + async fn test_cookie_extractor_multiple_cookies() { + #[derive(serde::Deserialize, Debug)] + struct Cookies { + session_id: String, + user_id: String, + } + + let (parts, _) = TestRequest::get("/") + .header("cookie", "session_id=abc123; user_id=user456") + .into_parts(); + + let result = + Cookie::::from_request_parts(&parts, &empty_params(), &empty_state()).await; + + assert!(result.is_ok()); + let cookies = result.unwrap(); + assert_eq!(cookies.0.session_id, "abc123"); + assert_eq!(cookies.0.user_id, "user456"); + } + + #[tokio::test] + async fn test_cookie_extractor_optional_field() { + #[derive(serde::Deserialize, Debug)] + struct Cookies { + session_id: String, + tracking: Option, + } + + let (parts, _) = TestRequest::get("/") + .header("cookie", "session_id=abc123") + .into_parts(); + + let result = + Cookie::::from_request_parts(&parts, &empty_params(), &empty_state()).await; + + assert!(result.is_ok()); + let cookies = result.unwrap(); + assert_eq!(cookies.0.session_id, "abc123"); + assert!(cookies.0.tracking.is_none()); + } + + #[tokio::test] + async fn test_cookie_extractor_missing_required() { + // Struct never successfully deserializes in this test (testing error case) + #[allow(dead_code)] + #[derive(serde::Deserialize, Debug)] + struct Session { + session_id: String, + } + + let (parts, _) = TestRequest::get("/").into_parts(); + + let result = + Cookie::::from_request_parts(&parts, &empty_params(), &empty_state()).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.status, 400); + assert!(err.message.contains("session_id")); + } + + #[tokio::test] + async fn test_cookie_extractor_empty_header() { + #[allow(dead_code)] + #[derive(serde::Deserialize, Debug)] + struct Session { + session_id: Option, + } + + let (parts, _) = TestRequest::get("/").header("cookie", "").into_parts(); + + let result = + Cookie::::from_request_parts(&parts, &empty_params(), &empty_state()).await; + + assert!(result.is_ok()); + assert!(result.unwrap().0.session_id.is_none()); + } + + #[test] + fn test_cookie_into_inner() { + let cookie = Cookie("session".to_string()); + assert_eq!(cookie.into_inner(), "session"); + } } diff --git a/rapina/src/lib.rs b/rapina/src/lib.rs index 81b09e6..ebaac18 100644 --- a/rapina/src/lib.rs +++ b/rapina/src/lib.rs @@ -52,6 +52,7 @@ //! - [`Query`](extract::Query) - Parse query string parameters //! - [`Form`](extract::Form) - Parse URL-encoded form data //! - [`Headers`](extract::Headers) - Access request headers +//! - [`Cookie`](extract::Cookie) - Extract and deserialize cookies //! - [`State`](extract::State) - Access application state //! - [`Context`](extract::Context) - Access request context with trace_id //! - [`Validated`](extract::Validated) - Validate extracted data @@ -113,7 +114,7 @@ pub mod prelude { }; pub use crate::context::RequestContext; pub use crate::error::{DocumentedError, Error, ErrorVariant, IntoApiError, Result}; - pub use crate::extract::{Context, Form, Headers, Json, Path, Query, State, Validated}; + pub use crate::extract::{Context, Cookie, Form, Headers, Json, Path, Query, State, Validated}; pub use crate::introspection::RouteInfo; pub use crate::middleware::{ CompressionConfig, KeyExtractor, Middleware, Next, RateLimitConfig, diff --git a/rapina/tests/extractors.rs b/rapina/tests/extractors.rs index deec81f..f83efb4 100644 --- a/rapina/tests/extractors.rs +++ b/rapina/tests/extractors.rs @@ -558,3 +558,109 @@ async fn test_validated_extraction_empty_name() { assert_eq!(response.status(), 422); // Validation error } + +// Cookie Extractor Tests + +#[tokio::test] +async fn test_cookie_extraction() { + let app = Rapina::new() + .with_introspection(false) + .router( + Router::new().route(http::Method::GET, "/dashboard", |req, _, _| async move { + let cookie_header = req + .headers() + .get("cookie") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + // Parse session_id from cookie + let session_id = cookie_header + .split(';') + .find_map(|pair| { + let (key, value) = pair.trim().split_once('=')?; + if key == "session_id" { + Some(value.to_string()) + } else { + None + } + }) + .unwrap_or_default(); + + format!("Session: {}", session_id) + }), + ); + + let client = TestClient::new(app).await; + let response = client + .get("/dashboard") + .header("cookie", "session_id=abc123") + .send() + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.text(), "Session: abc123"); +} + +#[tokio::test] +async fn test_cookie_extraction_multiple_cookies() { + let app = Rapina::new() + .with_introspection(false) + .router( + Router::new().route(http::Method::GET, "/user", |req, _, _| async move { + let cookie_header = req + .headers() + .get("cookie") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let cookies: std::collections::HashMap = cookie_header + .split(';') + .filter_map(|pair| { + let (key, value) = pair.trim().split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect(); + + let session = cookies.get("session_id").cloned().unwrap_or_default(); + let user = cookies.get("user_id").cloned().unwrap_or_default(); + + format!("Session: {}, User: {}", session, user) + }), + ); + + let client = TestClient::new(app).await; + let response = client + .get("/user") + .header("cookie", "session_id=abc123; user_id=user456") + .send() + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.text(), "Session: abc123, User: user456"); +} + +#[tokio::test] +async fn test_cookie_extraction_missing() { + let app = Rapina::new() + .with_introspection(false) + .router( + Router::new().route(http::Method::GET, "/dashboard", |req, _, _| async move { + let cookie_header = req + .headers() + .get("cookie") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if cookie_header.is_empty() { + return Error::bad_request("missing cookies").into_response(); + } + + "ok".into_response() + }), + ); + + let client = TestClient::new(app).await; + let response = client.get("/dashboard").send().await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +}