Skip to content
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
19 changes: 19 additions & 0 deletions docs/content/guide/extractors.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Extractors automatically parse request data and inject it into your handlers. If
| `Headers` | Request headers |
| `State<T>` | Application state |
| `Context` | Request context (trace_id) |
| `Cookie<T>` | Typed cookie access |
| `CurrentUser` | Authenticated user (JWT) |
| `Validated<T>` | Validated extractor |

Expand Down Expand Up @@ -124,6 +125,24 @@ async fn info(config: State<AppConfig>) -> String {
}
```

## Cookies

Deserialize cookies into typed structs:

```rust
#[derive(Deserialize)]
struct Session {
session_id: String,
}

#[get("/dashboard")]
async fn dashboard(session: Cookie<Session>) -> 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:
Expand Down
176 changes: 176 additions & 0 deletions rapina/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,32 @@ pub struct Form<T>(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<Cookie<T>>` for optional cookie access.
///
/// # Examples
///
/// ```ignore
/// use rapina::prelude::*;
///
/// #[derive(Deserialize)]
/// struct Session {
/// session_id: String,
/// }
///
/// #[get("/dashboard")]
/// async fn dashboard(session: Cookie<Session>) -> Result<Json<Dashboard>> {
/// let data = session.into_inner();
/// // Use data.session_id...
/// }
/// ```
#[derive(Debug)]
pub struct Cookie<T>(pub T);

/// Extracts application state.
///
/// Provides access to shared application state that was registered
Expand Down Expand Up @@ -270,6 +296,13 @@ impl Headers {
}
}

impl<T> Cookie<T> {
/// Consumes the extractor and returns the inner value.
pub fn into_inner(self) -> T {
self.0
}
}

impl<T> State<T> {
/// Consumes the extractor and returns the inner value.
pub fn into_inner(self) -> T {
Expand Down Expand Up @@ -463,6 +496,44 @@ impl FromRequestParts for Headers {
}
}

impl<T: DeserializeOwned + Send> FromRequestParts for Cookie<T> {
async fn from_request_parts(
parts: &http::request::Parts,
_params: &PathParams,
_state: &Arc<AppState>,
) -> Result<Self, Error> {
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<String, String> = 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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Serializing to JSON and then deserializing from JSON to T looks interesting. Is this a good approach (genuinely asking)?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair point! it's a pragmatic shortcut, cookies are always k/v pairs, so the json roundtrip works, but yeah it's not the ideal world, I think maybe a direct Hashmap<String, String> -> T would be cleaner, good candidate for a follow-up improvement.

.map_err(|e| Error::bad_request(format!("Invalid or missing cookies: {}", e)))?;

Ok(Cookie(value))
}
}

impl<T: FromStr + Send> FromRequestParts for Path<T>
where
T::Err: std::fmt::Display,
Expand Down Expand Up @@ -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::<Session>::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::<Cookies>::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<String>,
}

let (parts, _) = TestRequest::get("/")
.header("cookie", "session_id=abc123")
.into_parts();

let result =
Cookie::<Cookies>::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::<Session>::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<String>,
}

let (parts, _) = TestRequest::get("/").header("cookie", "").into_parts();

let result =
Cookie::<Session>::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");
}
}
3 changes: 2 additions & 1 deletion rapina/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
106 changes: 106 additions & 0 deletions rapina/tests/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> = 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);
}