diff --git a/docs/src/cache-modes.md b/docs/src/cache-modes.md index 714fa2b..c743e81 100644 --- a/docs/src/cache-modes.md +++ b/docs/src/cache-modes.md @@ -384,3 +384,136 @@ let cache = HttpCache { 5. **Global Settings**: Options like `max_ttl` and `cache_status_headers` provide global configuration All of these functions are called on a per-request basis, giving you complete control over caching behavior for each individual request. + +## Response Metadata + +The cache allows storing custom metadata alongside cached responses using the `metadata_provider` callback. This is useful for storing computed information that should be associated with cached responses, avoiding recomputation on cache hits. + +### Basic Usage + +```rust +use http_cache::{HttpCacheOptions, CACacheManager, HttpCache, CacheMode}; +use std::sync::Arc; + +let manager = CACacheManager::new("./cache".into(), true); + +let options = HttpCacheOptions { + metadata_provider: Some(Arc::new(|request_parts, response_parts| { + // Generate metadata based on request and response + let content_type = response_parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + + // Serialize metadata as bytes (users handle serialization) + Some(format!( + "path={};content-type={};status={}", + request_parts.uri.path(), + content_type, + response_parts.status.as_u16() + ).into_bytes()) + })), + ..Default::default() +}; + +let cache = HttpCache { + mode: CacheMode::Default, + manager, + options, +}; +``` + +### Use Cases + +The `metadata_provider` is particularly useful for: + +1. **Computed Information**: Store computed data based on request/response pairs that would be expensive to recompute +2. **Logging Context**: Store information for logging that should be associated with cached responses +3. **Custom Headers**: Store additional headers or information that should be returned with cached responses +4. **Analytics Data**: Store request timing, transformation information, or other analytics data + +### Accessing Metadata + +When retrieving cached responses through the `HttpCacheInterface`, the `HttpResponse` struct contains the metadata field: + +```rust +// When looking up cached responses +if let Some((cached_response, policy)) = cache.lookup_cached_response(&cache_key).await? { + // Access the metadata + if let Some(metadata) = &cached_response.metadata { + // Deserialize and use the metadata + let metadata_str = String::from_utf8_lossy(metadata); + println!("Cached with metadata: {}", metadata_str); + } + + // Use the cached response body + let body = &cached_response.body; +} +``` + +### Conditional Metadata Generation + +The metadata provider can return `None` to skip metadata generation for certain responses: + +```rust +let options = HttpCacheOptions { + metadata_provider: Some(Arc::new(|request_parts, response_parts| { + // Only generate metadata for API responses + if request_parts.uri.path().starts_with("/api/") { + let computed_info = format!( + "api_version={};response_time={}", + response_parts.headers + .get("x-api-version") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + ); + Some(computed_info.into_bytes()) + } else { + None // No metadata for non-API responses + } + })), + ..Default::default() +}; +``` + +### Integration with Middleware + +For middleware implementations (like reqwest-middleware), the types `HttpCacheMetadata` and `MetadataProvider` are re-exported: + +```rust +use http_cache_reqwest::{ + HttpCacheMetadata, MetadataProvider, HttpCacheOptions, + CacheMode, CACacheManager, HttpCache, Cache +}; +use reqwest::Client; +use reqwest_middleware::ClientBuilder; +use std::sync::Arc; + +let options = HttpCacheOptions { + metadata_provider: Some(Arc::new(|req, res| { + // Store request path and response status as metadata + Some(format!("{}:{}", req.uri.path(), res.status.as_u16()).into_bytes()) + })), + ..Default::default() +}; + +let client = ClientBuilder::new(Client::new()) + .with(Cache(HttpCache { + mode: CacheMode::Default, + manager: CACacheManager::new("./cache".into(), true), + options, + })) + .build(); +``` + +### Notes + +- Users are responsible for serialization/deserialization of metadata +- Metadata is stored as `Vec` bytes +- When both explicit metadata is passed to `process_response` and a `metadata_provider` is configured, the explicit metadata takes precedence +- Metadata persists with the cached response and is available on cache hits diff --git a/http-cache-quickcache/src/test.rs b/http-cache-quickcache/src/test.rs index f93057b..372bc6c 100644 --- a/http-cache-quickcache/src/test.rs +++ b/http-cache-quickcache/src/test.rs @@ -48,6 +48,7 @@ async fn quickcache() -> Result<()> { status: 200, url: url.clone(), version: HttpVersion::Http11, + metadata: None, }; let req = http::Request::get("http://example.com").body(())?; let res = http::Response::builder().status(200).body(TEST_BODY.to_vec())?; @@ -126,6 +127,7 @@ async fn default_mode_with_options() -> Result<()> { cache_bust: None, cache_status_headers: true, max_ttl: None, + metadata_provider: None, }, })) .build(); diff --git a/http-cache-reqwest/src/lib.rs b/http-cache-reqwest/src/lib.rs index 82408a9..fb389a7 100644 --- a/http-cache-reqwest/src/lib.rs +++ b/http-cache-reqwest/src/lib.rs @@ -305,8 +305,8 @@ fn to_middleware_error( use url::Url; pub use http_cache::{ - CacheManager, CacheMode, CacheOptions, HttpCache, HttpCacheOptions, - HttpResponse, ResponseCacheModeFn, + CacheManager, CacheMode, CacheOptions, HttpCache, HttpCacheMetadata, + HttpCacheOptions, HttpResponse, MetadataProvider, ResponseCacheModeFn, }; #[cfg(feature = "streaming")] @@ -460,12 +460,14 @@ impl Middleware for ReqwestMiddleware<'_> { status, url, version: version.try_into()?, + metadata: None, }) } } // Converts an [`HttpResponse`] to a reqwest [`Response`] fn convert_response(response: HttpResponse) -> Result { + let metadata = response.metadata.clone(); let mut ret_res = http::Response::builder() .status(response.status) .url(response.url) @@ -477,6 +479,10 @@ fn convert_response(response: HttpResponse) -> Result { HeaderValue::from_str(&header.1)?, ); } + // Insert metadata into response extensions if present + if let Some(metadata) = metadata { + ret_res.extensions_mut().insert(HttpCacheMetadata::from(metadata)); + } Ok(Response::from(ret_res)) } @@ -789,7 +795,7 @@ where })?; let cached_response = self .cache - .process_response(analysis, http_response) + .process_response(analysis, http_response, None) .await .map_err(|e| { to_middleware_error(HttpCacheError::Cache( @@ -843,7 +849,7 @@ where // Process and potentially cache the response let cached_response = self .cache - .process_response(analysis, http_response) + .process_response(analysis, http_response, None) .await .map_err(|e| { to_middleware_error(HttpCacheError::Cache(e.to_string())) diff --git a/http-cache-reqwest/src/test.rs b/http-cache-reqwest/src/test.rs index 1e9220d..ed80c8d 100644 --- a/http-cache-reqwest/src/test.rs +++ b/http-cache-reqwest/src/test.rs @@ -1142,6 +1142,7 @@ async fn options_request_not_cached() -> Result<()> { Ok(()) } +#[cfg(feature = "streaming")] #[tokio::test] async fn test_multipart_form_cloning_issue() -> Result<()> { // This test reproduces the exact issue reported by the user @@ -1274,7 +1275,7 @@ mod streaming_tests { // Process and cache the response let cached_response = - cache.process_response(analysis.clone(), response).await?; + cache.process_response(analysis.clone(), response, None).await?; assert_eq!(cached_response.status(), 200); // Verify the response body @@ -1323,7 +1324,7 @@ mod streaming_tests { // Process the large response let cached_response = - cache.process_response(analysis.clone(), response).await?; + cache.process_response(analysis.clone(), response, None).await?; assert_eq!(cached_response.status(), 200); // Verify the large response body @@ -1365,7 +1366,7 @@ mod streaming_tests { // Process the empty response let cached_response = - cache.process_response(analysis.clone(), response).await?; + cache.process_response(analysis.clone(), response, None).await?; assert_eq!(cached_response.status(), 204); // Verify empty body @@ -1734,7 +1735,7 @@ mod streaming_tests { // Process the response let cached_response = - cache.process_response(analysis.clone(), response).await?; + cache.process_response(analysis.clone(), response, None).await?; assert_eq!(cached_response.status(), 200); // Verify the body @@ -2239,3 +2240,72 @@ mod rate_limiting_tests { Ok(()) } } + +#[tokio::test] +async fn test_metadata_retrieval_through_extensions() -> Result<()> { + let mock_server = MockServer::start().await; + + Mock::given(method(GET)) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", CACHEABLE_PUBLIC) + .set_body_bytes(TEST_BODY), + ) + .expect(1) // Only called once, second request is cached + .mount(&mock_server) + .await; + + let url = format!("{}/metadata-test", mock_server.uri()); + + // Create cache options with a metadata provider + let options = HttpCacheOptions { + metadata_provider: Some(Arc::new(|_request_parts, _response_parts| { + Some(b"test-metadata-value".to_vec()) + })), + ..Default::default() + }; + + let client = ClientBuilder::new(Client::new()) + .with(Cache(HttpCache { + mode: CacheMode::Default, + manager: create_cache_manager(), + options, + })) + .build(); + + // First request - stores the response with metadata + let response1 = client.get(&url).send().await?; + assert_eq!(response1.status(), 200); + + // First response should have metadata (generated by metadata provider when caching) + let metadata1 = response1.extensions().get::(); + assert!( + metadata1.is_some(), + "Metadata should be present when response is cached" + ); + assert_eq!( + metadata1.unwrap().as_slice(), + b"test-metadata-value", + "Metadata value should match what was stored" + ); + + // Second request - should retrieve from cache with metadata in extensions + let response2 = client.get(&url).send().await?; + assert_eq!(response2.status(), 200); + + // Check that metadata is also present on cache hit + let metadata2 = response2.extensions().get::(); + assert!( + metadata2.is_some(), + "Metadata should be present in response extensions on cache hit" + ); + + let metadata_value = metadata2.unwrap(); + assert_eq!( + metadata_value.as_slice(), + b"test-metadata-value", + "Metadata value should match what was stored" + ); + + Ok(()) +} diff --git a/http-cache-surf/src/lib.rs b/http-cache-surf/src/lib.rs index 77c73b9..4a2e6e7 100644 --- a/http-cache-surf/src/lib.rs +++ b/http-cache-surf/src/lib.rs @@ -274,6 +274,7 @@ impl Middleware for SurfMiddleware<'_> { status, url, version: version.try_into()?, + metadata: None, }) } } diff --git a/http-cache-tower/src/lib.rs b/http-cache-tower/src/lib.rs index adf9bbc..ccf5207 100644 --- a/http-cache-tower/src/lib.rs +++ b/http-cache-tower/src/lib.rs @@ -693,6 +693,7 @@ where .process_response( analysis, Response::from_parts(res_parts, body_bytes.clone()), + None, ) .await .cache_err()?; @@ -723,6 +724,13 @@ where ); } + // Insert metadata into response extensions if present + if let Some(metadata) = cached_response.metadata { + response.extensions_mut().insert( + http_cache::HttpCacheMetadata::from(metadata), + ); + } + return Ok(response); } BeforeRequest::Stale { @@ -766,6 +774,15 @@ where ); } + // Insert metadata into response extensions if present + if let Some(metadata) = updated_response.metadata { + response.extensions_mut().insert( + http_cache::HttpCacheMetadata::from( + metadata, + ), + ); + } + return Ok(response); } else { // Process fresh response @@ -787,6 +804,7 @@ where parts, body_bytes.clone(), ), + None, ) .await .cache_err()?; @@ -827,6 +845,7 @@ where .process_response( analysis, Response::from_parts(res_parts, body_bytes.clone()), + None, ) .await .cache_err()?; @@ -993,7 +1012,7 @@ where })?; let cached_response = cache - .process_response(analysis, response) + .process_response(analysis, response, None) .await .cache_err()?; @@ -1091,6 +1110,7 @@ where .process_response( analysis, conditional_response, + None, ) .await .cache_err()?; @@ -1128,8 +1148,10 @@ where })?; // Process using streaming interface - let cached_response = - cache.process_response(analysis, response).await.cache_err()?; + let cached_response = cache + .process_response(analysis, response, None) + .await + .cache_err()?; let mut final_response = cached_response; diff --git a/http-cache-tower/src/test.rs b/http-cache-tower/src/test.rs index f8ac798..61b0930 100644 --- a/http-cache-tower/src/test.rs +++ b/http-cache-tower/src/test.rs @@ -2130,4 +2130,82 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_metadata_retrieval_through_extensions() -> Result<()> { + use http_cache::HttpCacheMetadata; + use std::sync::Arc; + + let cache_dir = tempfile::tempdir()?; + let manager = + CACacheManager::new(cache_dir.path().to_path_buf(), false); + + // Create cache options with a metadata provider + let options = HttpCacheOptions { + metadata_provider: Some(Arc::new( + |_request_parts, _response_parts| { + // Return some test metadata + Some(b"test-metadata-value".to_vec()) + }, + )), + ..Default::default() + }; + + let cache_layer = + HttpCacheLayer::with_options(manager.clone(), options); + + let test_service = TestService::new( + StatusCode::OK, + vec![("cache-control", CACHEABLE_PUBLIC)], + TEST_BODY, + ); + + let mut cached_service = cache_layer.layer(test_service); + + // First request - stores the response with metadata + let request = Request::builder() + .method("GET") + .uri("http://example.com/metadata-test") + .body(Full::new(Bytes::new())) + .map_err(|e| { + Box::new(e) as Box + })?; + + let response1 = cached_service.ready().await?.call(request).await?; + + // First response (cache miss) does NOT have metadata in extensions + // (metadata is generated and stored but not returned on the first request) + let metadata1 = response1.extensions().get::(); + assert!( + metadata1.is_none(), + "Metadata should NOT be present on cache miss" + ); + + // Second request - should retrieve from cache with metadata in extensions + let request = Request::builder() + .method("GET") + .uri("http://example.com/metadata-test") + .body(Full::new(Bytes::new())) + .map_err(|e| { + Box::new(e) as Box + })?; + + let response2 = cached_service.ready().await?.call(request).await?; + + // Check that metadata is in the response extensions + let metadata = response2.extensions().get::(); + assert!( + metadata.is_some(), + "Metadata should be present in response extensions" + ); + + let metadata_value = metadata.unwrap(); + assert_eq!( + metadata_value.as_slice(), + b"test-metadata-value", + "Metadata value should match what was stored" + ); + + Ok(()) + } } diff --git a/http-cache-ureq/src/lib.rs b/http-cache-ureq/src/lib.rs index d59f5da..dc0d4c6 100644 --- a/http-cache-ureq/src/lib.rs +++ b/http-cache-ureq/src/lib.rs @@ -701,6 +701,7 @@ fn convert_ureq_response_to_http_response( status: status.as_u16(), url: parsed_url, version: http_cache::HttpVersion::Http11, + metadata: None, }) } diff --git a/http-cache/src/lib.rs b/http-cache/src/lib.rs index f1b15e1..0a6864c 100644 --- a/http-cache/src/lib.rs +++ b/http-cache/src/lib.rs @@ -437,6 +437,8 @@ pub struct HttpResponse { pub url: Url, /// HTTP response version pub version: HttpVersion, + /// Metadata + pub metadata: Option>, } impl HttpResponse { @@ -572,6 +574,7 @@ pub trait StreamingCacheManager: Send + Sync + 'static { response: Response, policy: CachePolicy, request_url: Url, + metadata: Option>, ) -> Result> where B: http_body::Body + Send + 'static, @@ -672,6 +675,7 @@ pub trait HttpCacheInterface>: Send + Sync { &self, analysis: CacheAnalysis, response: Response, + metadata: Option>, ) -> Result>; /// Update request headers for conditional requests (e.g., If-None-Match) @@ -722,6 +726,7 @@ pub trait HttpCacheStreamInterface: Send + Sync { &self, analysis: CacheAnalysis, response: Response, + metadata: Option>, ) -> Result> where B: http_body::Body + Send + 'static, @@ -959,6 +964,18 @@ pub type CacheBust = Arc< + Sync, >; +/// Type alias for metadata stored alongside cached responses. +/// Users are responsible for serialization/deserialization of this data. +pub type HttpCacheMetadata = Vec; + +/// A closure that takes [`http::request::Parts`] and [`http::response::Parts`] and returns optional metadata to store with the cached response. +/// This allows middleware to compute and store additional information alongside cached responses. +pub type MetadataProvider = Arc< + dyn Fn(&request::Parts, &response::Parts) -> Option + + Send + + Sync, +>; + /// Configuration options for customizing HTTP cache behavior on a per-request basis. /// /// This struct allows you to override default caching behavior for individual requests @@ -1065,6 +1082,28 @@ pub type CacheBust = Arc< /// ..Default::default() /// }; /// ``` +/// +/// ## Storing Metadata with Cached Responses +/// ```rust +/// use http_cache::{HttpCacheOptions, MetadataProvider}; +/// use http::{request, response}; +/// use std::sync::Arc; +/// +/// let options = HttpCacheOptions { +/// metadata_provider: Some(Arc::new(|request_parts: &request::Parts, response_parts: &response::Parts| { +/// // Store computed information with the cached response +/// let content_type = response_parts +/// .headers +/// .get("content-type") +/// .and_then(|v| v.to_str().ok()) +/// .unwrap_or("unknown"); +/// +/// // Return serialized metadata (users handle serialization) +/// Some(format!("path={};content-type={}", request_parts.uri.path(), content_type).into_bytes()) +/// })), +/// ..Default::default() +/// }; +/// ``` #[derive(Clone)] pub struct HttpCacheOptions { /// Override the default cache options. @@ -1093,6 +1132,11 @@ pub struct HttpCacheOptions { /// This provides the optimal behavior for web scrapers and similar applications. #[cfg(feature = "rate-limiting")] pub rate_limiter: Option>, + /// Optional callback to provide metadata to store alongside cached responses. + /// The callback receives request and response parts and can return metadata bytes. + /// This is useful for storing computed information that should be associated with + /// cached responses without recomputation on cache hits. + pub metadata_provider: Option, } impl Default for HttpCacheOptions { @@ -1107,6 +1151,7 @@ impl Default for HttpCacheOptions { max_ttl: None, #[cfg(feature = "rate-limiting")] rate_limiter: None, + metadata_provider: None, } } } @@ -1127,6 +1172,10 @@ impl Debug for HttpCacheOptions { .field("cache_status_headers", &self.cache_status_headers) .field("max_ttl", &self.max_ttl) .field("rate_limiter", &"Option") + .field( + "metadata_provider", + &"Fn(&request::Parts, &response::Parts) -> Option>", + ) .finish() } @@ -1143,6 +1192,10 @@ impl Debug for HttpCacheOptions { .field("cache_bust", &"Fn(&request::Parts) -> Vec") .field("cache_status_headers", &self.cache_status_headers) .field("max_ttl", &self.max_ttl) + .field( + "metadata_provider", + &"Fn(&request::Parts, &response::Parts) -> Option>", + ) .finish() } } @@ -1211,6 +1264,7 @@ impl HttpCacheOptions { &self, parts: &response::Parts, request_parts: &request::Parts, + metadata: Option>, ) -> Result { Ok(HttpResponse { body: vec![], // We don't need the full body for cache mode decision @@ -1218,6 +1272,7 @@ impl HttpCacheOptions { status: parts.status.as_u16(), url: extract_url_from_request_parts(request_parts)?, version: parts.version.try_into()?, + metadata, }) } @@ -1238,6 +1293,17 @@ impl HttpCacheOptions { original_mode } + /// Generates metadata for a response using the metadata_provider callback if configured + pub fn generate_metadata( + &self, + request_parts: &request::Parts, + response_parts: &response::Parts, + ) -> Option { + self.metadata_provider + .as_ref() + .and_then(|provider| provider(request_parts, response_parts)) + } + /// Creates a cache policy for the given request and response fn create_cache_policy( &self, @@ -1540,6 +1606,7 @@ impl HttpCache { status: 504, url: middleware.url()?, version: HttpVersion::Http11, + metadata: None, }; if self.options.cache_status_headers { res.cache_status(HitOrMiss::MISS); @@ -1600,6 +1667,11 @@ impl HttpCache { ); if is_cacheable { + // Generate metadata using the provider callback if configured + let response_parts = res.parts()?; + res.metadata = + self.options.generate_metadata(&parts, &response_parts); + Ok(self .manager .put(self.options.create_cache_key(&parts, None), res, policy) @@ -1695,6 +1767,11 @@ impl HttpCache { cond_res.cache_status(HitOrMiss::MISS); cond_res.cache_lookup_status(HitOrMiss::HIT); } + // Generate metadata using the provider callback if configured + let response_parts = cond_res.parts()?; + cond_res.metadata = + self.options.generate_metadata(&parts, &response_parts); + let res = self .manager .put( @@ -1778,6 +1855,7 @@ where &self, analysis: CacheAnalysis, response: Response, + metadata: Option>, ) -> Result> where B: http_body::Body + Send + 'static, @@ -1812,9 +1890,15 @@ where // Convert response to HttpResponse format for response-based cache mode evaluation let (parts, body) = response.into_parts(); - let http_response = self - .options - .parts_to_http_response(&parts, &analysis.request_parts)?; + // Use provided metadata or generate from provider + let effective_metadata = metadata.or_else(|| { + self.options.generate_metadata(&analysis.request_parts, &parts) + }); + let http_response = self.options.parts_to_http_response( + &parts, + &analysis.request_parts, + effective_metadata.clone(), + )?; // Check for response-based cache mode override let effective_cache_mode = self.options.evaluate_response_cache_mode( @@ -1867,7 +1951,13 @@ where // Cache the response using the streaming manager let mut cached_response = self .manager - .put(analysis.cache_key, response, policy, request_url) + .put( + analysis.cache_key, + response, + policy, + request_url, + effective_metadata, + ) .await?; // Add cache miss headers (response is being stored for first time) @@ -1948,6 +2038,7 @@ impl HttpCacheInterface for HttpCache { &self, analysis: CacheAnalysis, response: Response>, + metadata: Option>, ) -> Result>> { if !analysis.should_cache { return Ok(response); @@ -1960,9 +2051,15 @@ impl HttpCacheInterface for HttpCache { // Convert response to HttpResponse format let (parts, body) = response.into_parts(); - let mut http_response = self - .options - .parts_to_http_response(&parts, &analysis.request_parts)?; + // Use provided metadata or generate from provider + let effective_metadata = metadata.or_else(|| { + self.options.generate_metadata(&analysis.request_parts, &parts) + }); + let mut http_response = self.options.parts_to_http_response( + &parts, + &analysis.request_parts, + effective_metadata, + )?; http_response.body = body.clone(); // Include the body for buffered cache managers // Check for response-based cache mode override diff --git a/http-cache/src/managers/streaming_cache.rs b/http-cache/src/managers/streaming_cache.rs index 252c9b3..70925ed 100644 --- a/http-cache/src/managers/streaming_cache.rs +++ b/http-cache/src/managers/streaming_cache.rs @@ -352,6 +352,9 @@ pub struct CacheMetadata { pub content_digest: String, pub policy: CachePolicy, pub created_at: u64, + /// User-provided metadata stored with the cached response + #[serde(default)] + pub user_metadata: Option>, } /// File-based streaming cache manager @@ -1020,9 +1023,16 @@ impl StreamingCacheManager for StreamingManager { // Create streaming body from file let body = StreamingBody::from_file(file); - let response = + let mut response = response_builder.body(body).map_err(StreamingError::new)?; + // Insert user metadata into response extensions if present + if let Some(user_metadata) = metadata.user_metadata { + response + .extensions_mut() + .insert(crate::HttpCacheMetadata::from(user_metadata)); + } + Ok(Some((response, metadata.policy))) } @@ -1032,6 +1042,7 @@ impl StreamingCacheManager for StreamingManager { response: Response, policy: CachePolicy, _request_url: Url, + metadata: Option>, ) -> Result> where B: http_body::Body + Send + 'static, @@ -1076,7 +1087,7 @@ impl StreamingCacheManager for StreamingManager { self.enforce_cache_limits().await?; // Create metadata - let metadata = CacheMetadata { + let cache_metadata = CacheMetadata { status: parts.status.as_u16(), version: match parts.version { Version::HTTP_09 => 9, @@ -1095,11 +1106,12 @@ impl StreamingCacheManager for StreamingManager { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(), + user_metadata: metadata, }; // Write metadata atomically let metadata_path = self.metadata_path(&cache_key); - let metadata_json = serde_json::to_vec(&metadata) + let metadata_json = serde_json::to_vec(&cache_metadata) .map_err(StreamingError::serialization)?; // If metadata write fails, we need to rollback to prevent resource leaks @@ -1295,7 +1307,13 @@ mod tests { // Put response into cache let cached_response = cache - .put("test-key".to_string(), response, policy.clone(), request_url) + .put( + "test-key".to_string(), + response, + policy.clone(), + request_url, + None, + ) .await .unwrap(); @@ -1318,6 +1336,66 @@ mod tests { assert_eq!(cached_policy.time_to_live(now), policy.time_to_live(now)); } + #[tokio::test] + async fn test_streaming_cache_metadata() { + use crate::HttpCacheMetadata; + + let temp_dir = TempDir::new().unwrap(); + let cache = StreamingManager::new(temp_dir.path().to_path_buf()); + + let original_body = Full::new(Bytes::from("test response body")); + let response = Response::builder() + .status(200) + .header("content-type", "text/plain") + .body(original_body) + .unwrap(); + + let policy = CachePolicy::new( + &http::request::Request::builder() + .method("GET") + .uri("/test") + .body(()) + .unwrap() + .into_parts() + .0, + &response.clone().map(|_| ()), + ); + + let request_url = Url::parse("http://example.com/test").unwrap(); + let test_metadata = b"test-metadata-value".to_vec(); + + // Put response into cache with metadata + let cached_response = cache + .put( + "metadata-key".to_string(), + response, + policy.clone(), + request_url, + Some(test_metadata.clone()), + ) + .await + .unwrap(); + + // Response should be returned immediately + assert_eq!(cached_response.status(), 200); + + // Get response from cache + let retrieved = cache.get("metadata-key").await.unwrap(); + assert!(retrieved.is_some()); + + let (cached_response, _cached_policy) = retrieved.unwrap(); + assert_eq!(cached_response.status(), 200); + + // Verify metadata is in response extensions + let metadata = cached_response.extensions().get::(); + assert!(metadata.is_some(), "Metadata should be present in extensions"); + assert_eq!( + metadata.unwrap().as_slice(), + test_metadata.as_slice(), + "Metadata value should match what was stored" + ); + } + #[tokio::test] async fn test_streaming_cache_delete() { let temp_dir = TempDir::new().unwrap(); @@ -1346,7 +1424,7 @@ mod tests { // Put response into cache cache - .put(cache_key.to_string(), response, policy, request_url) + .put(cache_key.to_string(), response, policy, request_url, None) .await .unwrap(); @@ -1409,11 +1487,17 @@ mod tests { // Cache both responses cache - .put("key1".to_string(), response1, policy1, request_url.clone()) + .put( + "key1".to_string(), + response1, + policy1, + request_url.clone(), + None, + ) .await .unwrap(); cache - .put("key2".to_string(), response2, policy2, request_url) + .put("key2".to_string(), response2, policy2, request_url, None) .await .unwrap(); @@ -1521,6 +1605,7 @@ mod tests { response1, policy1, request_url.clone(), + None, ) .await .unwrap(); @@ -1530,6 +1615,7 @@ mod tests { response2, policy2, request_url.clone(), + None, ) .await .unwrap(); @@ -1619,7 +1705,13 @@ mod tests { ); cache - .put(format!("concurrent-key-{}", i), response, policy, url) + .put( + format!("concurrent-key-{}", i), + response, + policy, + url, + None, + ) .await .unwrap(); }); @@ -1714,7 +1806,7 @@ mod tests { // Store large response let cached_response = cache - .put("large-key".to_string(), response, policy, request_url) + .put("large-key".to_string(), response, policy, request_url, None) .await .unwrap(); @@ -1782,7 +1874,7 @@ mod tests { let request_url = Url::parse("http://example.com/test").unwrap(); let result = cache - .put("valid-key".to_string(), response, policy, request_url) + .put("valid-key".to_string(), response, policy, request_url, None) .await; assert!(result.is_ok(), "Should handle corrupted metadata gracefully"); } @@ -1816,7 +1908,13 @@ mod tests { // Store original content cache - .put("integrity-key".to_string(), response, policy, request_url) + .put( + "integrity-key".to_string(), + response, + policy, + request_url, + None, + ) .await .unwrap(); @@ -1883,7 +1981,7 @@ mod tests { // Store response cache - .put(cache_key.clone(), response, policy, request_url) + .put(cache_key.clone(), response, policy, request_url, None) .await .unwrap(); @@ -1949,6 +2047,7 @@ mod tests { response.clone(), policy, request_url, + None, ) .await .unwrap(); @@ -2032,6 +2131,7 @@ mod tests { test_response, policy.clone(), request_url.clone(), + None, ) .await; @@ -2100,7 +2200,13 @@ mod tests { ); cache - .put(key.to_string(), response, policy, request_url.clone()) + .put( + key.to_string(), + response, + policy, + request_url.clone(), + None, + ) .await .unwrap(); @@ -2130,7 +2236,7 @@ mod tests { ); cache - .put("key4".to_string(), response, policy, request_url) + .put("key4".to_string(), response, policy, request_url, None) .await .unwrap(); @@ -2187,7 +2293,7 @@ mod tests { ); cache - .put("cleanup-key".to_string(), response, policy, request_url) + .put("cleanup-key".to_string(), response, policy, request_url, None) .await .unwrap(); @@ -2241,7 +2347,7 @@ mod tests { // This put operation should fail due to metadata filename being too long let result = cache - .put(very_long_key.clone(), response, policy, request_url) + .put(very_long_key.clone(), response, policy, request_url, None) .await; // The operation should fail @@ -2317,7 +2423,7 @@ mod tests { // Put, get, and delete in rapid succession let put_result = cache - .put(key.clone(), response, policy, url.clone()) + .put(key.clone(), response, policy, url.clone(), None) .await; assert!( put_result.is_ok(), @@ -2413,7 +2519,7 @@ mod tests { // Should handle extreme config gracefully let _result = cache - .put("config-key".to_string(), response, policy, request_url) + .put("config-key".to_string(), response, policy, request_url, None) .await; // With zero cache size/entries, the put might succeed but get might fail diff --git a/http-cache/src/test.rs b/http-cache/src/test.rs index 72a3d94..1bcda0c 100644 --- a/http-cache/src/test.rs +++ b/http-cache/src/test.rs @@ -55,8 +55,9 @@ fn response_methods_work() -> Result<()> { status: 200, url: url.clone(), version: HttpVersion::Http11, + metadata: Some(b"Metadata".to_vec()), }; - assert_eq!(format!("{:?}", res.clone()), "HttpResponse { body: [116, 101, 115, 116], headers: {}, status: 200, url: Url { scheme: \"http\", cannot_be_a_base: false, username: \"\", password: None, host: Some(Domain(\"example.com\")), port: None, path: \"/\", query: None, fragment: None }, version: Http11 }"); + assert_eq!(format!("{:?}", res.clone()), "HttpResponse { body: [116, 101, 115, 116], headers: {}, status: 200, url: Url { scheme: \"http\", cannot_be_a_base: false, username: \"\", password: None, host: Some(Domain(\"example.com\")), port: None, path: \"/\", query: None, fragment: None }, version: Http11, metadata: Some([77, 101, 116, 97, 100, 97, 116, 97]) }"); res.add_warning(&url, 112, "Test Warning"); let code = res.warning_code(); assert!(code.is_some()); @@ -179,6 +180,7 @@ mod with_cacache { status: 200, url: url.clone(), version: HttpVersion::Http11, + metadata: Some(b"Metadata".to_vec()), }; let req = http::Request::get("http://example.com").body(())?; let res = @@ -188,6 +190,7 @@ mod with_cacache { let (cached_res, _policy) = manager.get("test").await?.ok_or("Missing cache record")?; assert_eq!(cached_res.body, TEST_BODY); + assert_eq!(cached_res.metadata, Some(b"Metadata".to_vec())); Ok(()) } @@ -203,6 +206,7 @@ mod with_cacache { status: 200, url: url.clone(), version: HttpVersion::Http11, + metadata: Some(b"Metadata".to_vec()), }; let req = http::Request::get("http://example.com").body(())?; let res = @@ -213,7 +217,9 @@ mod with_cacache { .await?; let data = manager.get(&format!("{}:{}", GET, &url)).await?; assert!(data.is_some()); - assert_eq!(data.unwrap().0.body, TEST_BODY); + let test_data = data.unwrap(); + assert_eq!(test_data.0.body, TEST_BODY); + assert_eq!(test_data.0.metadata, Some(b"Metadata".to_vec())); let clone = manager.clone(); let clonedata = clone.get(&format!("{}:{}", GET, &url)).await?; assert!(clonedata.is_some()); @@ -254,6 +260,7 @@ mod with_moka { status: 200, url: url.clone(), version: HttpVersion::Http11, + metadata: Some(b"Metadata".to_vec()), }; let req = http::Request::get("http://example.com").body(())?; let res = @@ -264,11 +271,15 @@ mod with_moka { .await?; let data = manager.get(&format!("{}:{}", GET, &url)).await?; assert!(data.is_some()); - assert_eq!(data.unwrap().0.body, TEST_BODY); + let response = data.unwrap(); + assert_eq!(response.0.body, TEST_BODY); + assert_eq!(response.0.metadata, Some(b"Metadata".to_vec())); let clone = manager.clone(); let clonedata = clone.get(&format!("{}:{}", GET, &url)).await?; assert!(clonedata.is_some()); - assert_eq!(clonedata.unwrap().0.body, TEST_BODY); + let response = clonedata.unwrap(); + assert_eq!(response.0.body, TEST_BODY); + assert_eq!(response.0.metadata, Some(b"Metadata".to_vec())); manager.delete(&format!("{}:{}", GET, &url)).await?; let data = manager.get(&format!("{}:{}", GET, &url)).await?; assert!(data.is_none()); @@ -409,8 +420,14 @@ mod interface_tests { let analysis = cache.analyze_request(&parts, None).unwrap(); // Process the response (should cache it) - let processed = - cache.process_response(analysis.clone(), response).await.unwrap(); + let processed = cache + .process_response( + analysis.clone(), + response, + Some(b"Metadata".to_vec()), + ) + .await + .unwrap(); assert_eq!(processed.status(), StatusCode::OK); // Try to look up the cached response @@ -421,6 +438,7 @@ mod interface_tests { let (cached_response, _policy) = cached.unwrap(); assert_eq!(cached_response.status, StatusCode::OK); assert_eq!(cached_response.body, b"Hello, world!"); + assert_eq!(cached_response.metadata, Some(b"Metadata".to_vec())); // Temporary directory will be automatically cleaned up when dropped } @@ -459,8 +477,14 @@ mod interface_tests { let analysis = cache.analyze_request(&parts, None).unwrap(); // Process the response (should cache it) - let processed = - cache.process_response(analysis.clone(), response).await.unwrap(); + let processed = cache + .process_response( + analysis.clone(), + response, + Some(b"Metadata".to_vec()), + ) + .await + .unwrap(); assert_eq!(processed.status(), StatusCode::OK); // Try to look up the cached response @@ -471,6 +495,7 @@ mod interface_tests { let (cached_response, _policy) = cached.unwrap(); assert_eq!(cached_response.status, StatusCode::OK); assert_eq!(cached_response.body, b"Hello, world!"); + assert_eq!(cached_response.metadata, Some(b"Metadata".to_vec())); // Temporary directory will be automatically cleaned up when dropped } @@ -503,8 +528,14 @@ mod interface_tests { let analysis = cache.analyze_request(&parts, None).unwrap(); // Cache the response - let _processed = - cache.process_response(analysis.clone(), response).await.unwrap(); + let _processed = cache + .process_response( + analysis.clone(), + response, + Some(b"Metadata".to_vec()), + ) + .await + .unwrap(); // Look up the cached response let cached = @@ -556,8 +587,14 @@ mod interface_tests { let analysis = cache.analyze_request(&parts, None).unwrap(); // Cache the response - let _processed = - cache.process_response(analysis.clone(), response).await.unwrap(); + let _processed = cache + .process_response( + analysis.clone(), + response, + Some(b"Metadata".to_vec()), + ) + .await + .unwrap(); // Look up the cached response let cached = @@ -599,6 +636,7 @@ mod interface_tests { status: 200, url: Url::parse("https://example.com/test").unwrap(), version: crate::HttpVersion::Http11, + metadata: Some(b"Metadata".to_vec()), }; // Create fresh response parts (simulating 304 Not Modified) @@ -618,6 +656,7 @@ mod interface_tests { let updated_response = result.unwrap(); assert_eq!(updated_response.body, b"Cached content"); assert_eq!(updated_response.status, 200); + assert_eq!(updated_response.metadata, Some(b"Metadata".to_vec())); // Temporary directory will be automatically cleaned up when dropped } @@ -640,6 +679,7 @@ mod interface_tests { status: 200, url: Url::parse("https://example.com/test").unwrap(), version: crate::HttpVersion::Http11, + metadata: Some(b"Metadata".to_vec()), }; // Create fresh response parts (simulating 304 Not Modified) @@ -659,6 +699,7 @@ mod interface_tests { let updated_response = result.unwrap(); assert_eq!(updated_response.body, b"Cached content"); assert_eq!(updated_response.status, 200); + assert_eq!(updated_response.metadata, Some(b"Metadata".to_vec())); // Temporary directory will be automatically cleaned up when dropped } @@ -883,6 +924,7 @@ mod interface_tests { status: 200, url: Url::parse("https://example.com/test").unwrap(), version: crate::HttpVersion::Http11, + metadata: Some(b"Metadata".to_vec()), }; // Create fresh response parts @@ -924,6 +966,7 @@ mod interface_tests { status: 200, url: Url::parse("https://example.com/test").unwrap(), version: crate::HttpVersion::Http11, + metadata: Some(b"Metadata".to_vec()), }; // Create fresh response parts @@ -974,8 +1017,14 @@ mod interface_tests { .unwrap(); // Process the response (should NOT cache it) - let processed = - cache.process_response(analysis.clone(), response).await.unwrap(); + let processed = cache + .process_response( + analysis.clone(), + response, + Some(b"Metadata".to_vec()), + ) + .await + .unwrap(); assert_eq!(processed.status(), StatusCode::OK); assert_eq!(processed.body(), b"Hello, world!"); @@ -1014,8 +1063,10 @@ mod interface_tests { .unwrap(); // Process the response (should NOT cache it) - let processed = - cache.process_response(analysis.clone(), response).await.unwrap(); + let processed = cache + .process_response(analysis.clone(), response, None) + .await + .unwrap(); assert_eq!(processed.status(), StatusCode::OK); assert_eq!(processed.body(), b"Hello, world!"); @@ -1104,6 +1155,554 @@ mod interface_tests { } } +#[cfg(feature = "manager-cacache")] +mod metadata_provider_tests { + use crate::{ + CACacheManager, CacheMode, HttpCache, HttpCacheInterface, + HttpCacheOptions, + }; + use http::{Request, Response, StatusCode}; + use std::sync::Arc; + + #[cfg(feature = "cacache-tokio")] + use tokio::test as async_test; + + #[cfg(feature = "cacache-smol")] + use macro_rules_attribute::apply; + #[cfg(feature = "cacache-smol")] + use smol_macros::test; + + #[cfg(feature = "cacache-smol")] + #[apply(test!)] + async fn test_metadata_provider_generates_metadata() { + let cache_dir = tempfile::tempdir().unwrap(); + let manager = CACacheManager::new(cache_dir.path().to_path_buf(), true); + + // Configure cache with a metadata provider + let options = HttpCacheOptions { + metadata_provider: Some(Arc::new( + |request_parts, response_parts| { + // Generate metadata based on request path and response status + let metadata = format!( + "path={};status={}", + request_parts.uri.path(), + response_parts.status.as_u16() + ); + Some(metadata.into_bytes()) + }, + )), + ..Default::default() + }; + + let cache = HttpCache { mode: CacheMode::Default, manager, options }; + + // Create a GET request + let request = Request::builder() + .method("GET") + .uri("https://example.com/api/data") + .body(()) + .unwrap(); + let (request_parts, _) = request.into_parts(); + + let analysis = cache.analyze_request(&request_parts, None).unwrap(); + + // Create a cacheable response + let response = Response::builder() + .status(StatusCode::OK) + .header("cache-control", "max-age=3600") + .body(b"response body".to_vec()) + .unwrap(); + + // Process the response (should generate and store metadata) + let _ = cache + .process_response(analysis.clone(), response, None) + .await + .unwrap(); + + // Look up the cached response + let cached = + cache.lookup_cached_response(&analysis.cache_key).await.unwrap(); + assert!(cached.is_some()); + + let (cached_response, _policy) = cached.unwrap(); + + // Verify metadata was generated and stored + assert!(cached_response.metadata.is_some()); + let metadata = cached_response.metadata.unwrap(); + let metadata_str = String::from_utf8(metadata).unwrap(); + assert_eq!(metadata_str, "path=/api/data;status=200"); + } + + #[cfg(feature = "cacache-tokio")] + #[async_test] + async fn test_metadata_provider_generates_metadata() { + let cache_dir = tempfile::tempdir().unwrap(); + let manager = CACacheManager::new(cache_dir.path().to_path_buf(), true); + + // Configure cache with a metadata provider + let options = HttpCacheOptions { + metadata_provider: Some(Arc::new( + |request_parts, response_parts| { + // Generate metadata based on request path and response status + let metadata = format!( + "path={};status={}", + request_parts.uri.path(), + response_parts.status.as_u16() + ); + Some(metadata.into_bytes()) + }, + )), + ..Default::default() + }; + + let cache = HttpCache { mode: CacheMode::Default, manager, options }; + + // Create a GET request + let request = Request::builder() + .method("GET") + .uri("https://example.com/api/data") + .body(()) + .unwrap(); + let (request_parts, _) = request.into_parts(); + + let analysis = cache.analyze_request(&request_parts, None).unwrap(); + + // Create a cacheable response + let response = Response::builder() + .status(StatusCode::OK) + .header("cache-control", "max-age=3600") + .body(b"response body".to_vec()) + .unwrap(); + + // Process the response (should generate and store metadata) + let _ = cache + .process_response(analysis.clone(), response, None) + .await + .unwrap(); + + // Look up the cached response + let cached = + cache.lookup_cached_response(&analysis.cache_key).await.unwrap(); + assert!(cached.is_some()); + + let (cached_response, _policy) = cached.unwrap(); + + // Verify metadata was generated and stored + assert!(cached_response.metadata.is_some()); + let metadata = cached_response.metadata.unwrap(); + let metadata_str = String::from_utf8(metadata).unwrap(); + assert_eq!(metadata_str, "path=/api/data;status=200"); + } + + #[cfg(feature = "cacache-smol")] + #[apply(test!)] + async fn test_metadata_provider_returns_none() { + let cache_dir = tempfile::tempdir().unwrap(); + let manager = CACacheManager::new(cache_dir.path().to_path_buf(), true); + + // Configure cache with a metadata provider that returns None + let options = HttpCacheOptions { + metadata_provider: Some(Arc::new( + |_request_parts, _response_parts| { + None // Don't generate metadata for this response + }, + )), + ..Default::default() + }; + + let cache = HttpCache { mode: CacheMode::Default, manager, options }; + + // Create a GET request + let request = Request::builder() + .method("GET") + .uri("https://example.com/test") + .body(()) + .unwrap(); + let (request_parts, _) = request.into_parts(); + + let analysis = cache.analyze_request(&request_parts, None).unwrap(); + + // Create a cacheable response + let response = Response::builder() + .status(StatusCode::OK) + .header("cache-control", "max-age=3600") + .body(b"response body".to_vec()) + .unwrap(); + + // Process the response + let _ = cache + .process_response(analysis.clone(), response, None) + .await + .unwrap(); + + // Look up the cached response + let cached = + cache.lookup_cached_response(&analysis.cache_key).await.unwrap(); + assert!(cached.is_some()); + + let (cached_response, _policy) = cached.unwrap(); + + // Verify metadata is None + assert!(cached_response.metadata.is_none()); + } + + #[cfg(feature = "cacache-tokio")] + #[async_test] + async fn test_metadata_provider_returns_none() { + let cache_dir = tempfile::tempdir().unwrap(); + let manager = CACacheManager::new(cache_dir.path().to_path_buf(), true); + + // Configure cache with a metadata provider that returns None + let options = HttpCacheOptions { + metadata_provider: Some(Arc::new( + |_request_parts, _response_parts| { + None // Don't generate metadata for this response + }, + )), + ..Default::default() + }; + + let cache = HttpCache { mode: CacheMode::Default, manager, options }; + + // Create a GET request + let request = Request::builder() + .method("GET") + .uri("https://example.com/test") + .body(()) + .unwrap(); + let (request_parts, _) = request.into_parts(); + + let analysis = cache.analyze_request(&request_parts, None).unwrap(); + + // Create a cacheable response + let response = Response::builder() + .status(StatusCode::OK) + .header("cache-control", "max-age=3600") + .body(b"response body".to_vec()) + .unwrap(); + + // Process the response + let _ = cache + .process_response(analysis.clone(), response, None) + .await + .unwrap(); + + // Look up the cached response + let cached = + cache.lookup_cached_response(&analysis.cache_key).await.unwrap(); + assert!(cached.is_some()); + + let (cached_response, _policy) = cached.unwrap(); + + // Verify metadata is None + assert!(cached_response.metadata.is_none()); + } + + #[cfg(feature = "cacache-smol")] + #[apply(test!)] + async fn test_explicit_metadata_overrides_provider() { + let cache_dir = tempfile::tempdir().unwrap(); + let manager = CACacheManager::new(cache_dir.path().to_path_buf(), true); + + // Configure cache with a metadata provider + let options = HttpCacheOptions { + metadata_provider: Some(Arc::new( + |_request_parts, _response_parts| { + Some(b"from-provider".to_vec()) + }, + )), + ..Default::default() + }; + + let cache = HttpCache { mode: CacheMode::Default, manager, options }; + + // Create a GET request + let request = Request::builder() + .method("GET") + .uri("https://example.com/test") + .body(()) + .unwrap(); + let (request_parts, _) = request.into_parts(); + + let analysis = cache.analyze_request(&request_parts, None).unwrap(); + + // Create a cacheable response + let response = Response::builder() + .status(StatusCode::OK) + .header("cache-control", "max-age=3600") + .body(b"response body".to_vec()) + .unwrap(); + + // Process the response with explicit metadata (should override provider) + let _ = cache + .process_response( + analysis.clone(), + response, + Some(b"explicit-metadata".to_vec()), + ) + .await + .unwrap(); + + // Look up the cached response + let cached = + cache.lookup_cached_response(&analysis.cache_key).await.unwrap(); + assert!(cached.is_some()); + + let (cached_response, _policy) = cached.unwrap(); + + // Verify explicit metadata takes precedence over provider + assert!(cached_response.metadata.is_some()); + let metadata = cached_response.metadata.unwrap(); + assert_eq!(metadata, b"explicit-metadata"); + } + + #[cfg(feature = "cacache-tokio")] + #[async_test] + async fn test_explicit_metadata_overrides_provider() { + let cache_dir = tempfile::tempdir().unwrap(); + let manager = CACacheManager::new(cache_dir.path().to_path_buf(), true); + + // Configure cache with a metadata provider + let options = HttpCacheOptions { + metadata_provider: Some(Arc::new( + |_request_parts, _response_parts| { + Some(b"from-provider".to_vec()) + }, + )), + ..Default::default() + }; + + let cache = HttpCache { mode: CacheMode::Default, manager, options }; + + // Create a GET request + let request = Request::builder() + .method("GET") + .uri("https://example.com/test") + .body(()) + .unwrap(); + let (request_parts, _) = request.into_parts(); + + let analysis = cache.analyze_request(&request_parts, None).unwrap(); + + // Create a cacheable response + let response = Response::builder() + .status(StatusCode::OK) + .header("cache-control", "max-age=3600") + .body(b"response body".to_vec()) + .unwrap(); + + // Process the response with explicit metadata (should override provider) + let _ = cache + .process_response( + analysis.clone(), + response, + Some(b"explicit-metadata".to_vec()), + ) + .await + .unwrap(); + + // Look up the cached response + let cached = + cache.lookup_cached_response(&analysis.cache_key).await.unwrap(); + assert!(cached.is_some()); + + let (cached_response, _policy) = cached.unwrap(); + + // Verify explicit metadata takes precedence over provider + assert!(cached_response.metadata.is_some()); + let metadata = cached_response.metadata.unwrap(); + assert_eq!(metadata, b"explicit-metadata"); + } + + #[cfg(feature = "cacache-smol")] + #[apply(test!)] + async fn test_metadata_provider_with_conditional_logic() { + let cache_dir = tempfile::tempdir().unwrap(); + let manager = CACacheManager::new(cache_dir.path().to_path_buf(), true); + + // Configure cache with a metadata provider that only generates metadata for certain paths + let options = HttpCacheOptions { + metadata_provider: Some(Arc::new( + |request_parts, response_parts| { + // Only generate metadata for API paths + if request_parts.uri.path().starts_with("/api/") { + let content_type = response_parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + Some( + format!("content-type={}", content_type) + .into_bytes(), + ) + } else { + None + } + }, + )), + ..Default::default() + }; + + let cache = HttpCache { mode: CacheMode::Default, manager, options }; + + // Test 1: API path should generate metadata + let api_request = Request::builder() + .method("GET") + .uri("https://example.com/api/users") + .body(()) + .unwrap(); + let (api_parts, _) = api_request.into_parts(); + + let api_analysis = cache.analyze_request(&api_parts, None).unwrap(); + + let api_response = Response::builder() + .status(StatusCode::OK) + .header("cache-control", "max-age=3600") + .header("content-type", "application/json") + .body(b"[]".to_vec()) + .unwrap(); + + let _ = cache + .process_response(api_analysis.clone(), api_response, None) + .await + .unwrap(); + + let cached = cache + .lookup_cached_response(&api_analysis.cache_key) + .await + .unwrap(); + let (cached_response, _) = cached.unwrap(); + assert!(cached_response.metadata.is_some()); + let metadata_str = + String::from_utf8(cached_response.metadata.unwrap()).unwrap(); + assert_eq!(metadata_str, "content-type=application/json"); + + // Test 2: Non-API path should not generate metadata + let static_request = Request::builder() + .method("GET") + .uri("https://example.com/static/style.css") + .body(()) + .unwrap(); + let (static_parts, _) = static_request.into_parts(); + + let static_analysis = + cache.analyze_request(&static_parts, None).unwrap(); + + let static_response = Response::builder() + .status(StatusCode::OK) + .header("cache-control", "max-age=3600") + .header("content-type", "text/css") + .body(b"body {}".to_vec()) + .unwrap(); + + let _ = cache + .process_response(static_analysis.clone(), static_response, None) + .await + .unwrap(); + + let cached = cache + .lookup_cached_response(&static_analysis.cache_key) + .await + .unwrap(); + let (cached_response, _) = cached.unwrap(); + assert!(cached_response.metadata.is_none()); + } + + #[cfg(feature = "cacache-tokio")] + #[async_test] + async fn test_metadata_provider_with_conditional_logic() { + let cache_dir = tempfile::tempdir().unwrap(); + let manager = CACacheManager::new(cache_dir.path().to_path_buf(), true); + + // Configure cache with a metadata provider that only generates metadata for certain paths + let options = HttpCacheOptions { + metadata_provider: Some(Arc::new( + |request_parts, response_parts| { + // Only generate metadata for API paths + if request_parts.uri.path().starts_with("/api/") { + let content_type = response_parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + Some( + format!("content-type={}", content_type) + .into_bytes(), + ) + } else { + None + } + }, + )), + ..Default::default() + }; + + let cache = HttpCache { mode: CacheMode::Default, manager, options }; + + // Test 1: API path should generate metadata + let api_request = Request::builder() + .method("GET") + .uri("https://example.com/api/users") + .body(()) + .unwrap(); + let (api_parts, _) = api_request.into_parts(); + + let api_analysis = cache.analyze_request(&api_parts, None).unwrap(); + + let api_response = Response::builder() + .status(StatusCode::OK) + .header("cache-control", "max-age=3600") + .header("content-type", "application/json") + .body(b"[]".to_vec()) + .unwrap(); + + let _ = cache + .process_response(api_analysis.clone(), api_response, None) + .await + .unwrap(); + + let cached = cache + .lookup_cached_response(&api_analysis.cache_key) + .await + .unwrap(); + let (cached_response, _) = cached.unwrap(); + assert!(cached_response.metadata.is_some()); + let metadata_str = + String::from_utf8(cached_response.metadata.unwrap()).unwrap(); + assert_eq!(metadata_str, "content-type=application/json"); + + // Test 2: Non-API path should not generate metadata + let static_request = Request::builder() + .method("GET") + .uri("https://example.com/static/style.css") + .body(()) + .unwrap(); + let (static_parts, _) = static_request.into_parts(); + + let static_analysis = + cache.analyze_request(&static_parts, None).unwrap(); + + let static_response = Response::builder() + .status(StatusCode::OK) + .header("cache-control", "max-age=3600") + .header("content-type", "text/css") + .body(b"body {}".to_vec()) + .unwrap(); + + let _ = cache + .process_response(static_analysis.clone(), static_response, None) + .await + .unwrap(); + + let cached = cache + .lookup_cached_response(&static_analysis.cache_key) + .await + .unwrap(); + let (cached_response, _) = cached.unwrap(); + assert!(cached_response.metadata.is_none()); + } +} + #[cfg(feature = "manager-cacache")] mod response_cache_mode_tests { #[cfg(feature = "cacache-smol")] @@ -1163,8 +1762,10 @@ mod response_cache_mode_tests { .unwrap(); // Process the response - should be cached despite no-cache headers - let result = - cache.process_response(analysis.clone(), response).await.unwrap(); + let result = cache + .process_response(analysis.clone(), response, None) + .await + .unwrap(); assert_eq!(result.status(), StatusCode::OK); assert_eq!(result.body(), b"important data"); @@ -1218,8 +1819,10 @@ mod response_cache_mode_tests { .body(b"Rate limit exceeded".to_vec()) .unwrap(); - let result = - cache.process_response(analysis.clone(), response).await.unwrap(); + let result = cache + .process_response(analysis.clone(), response, None) + .await + .unwrap(); assert_eq!(result.status(), StatusCode::TOO_MANY_REQUESTS); assert_eq!(result.body(), b"Rate limit exceeded"); @@ -1279,7 +1882,7 @@ mod response_cache_mode_tests { .unwrap(); let _ = cache - .process_response(auth_analysis.clone(), auth_response) + .process_response(auth_analysis.clone(), auth_response, None) .await .unwrap(); @@ -1308,7 +1911,7 @@ mod response_cache_mode_tests { .unwrap(); let _ = cache - .process_response(public_analysis.clone(), public_response) + .process_response(public_analysis.clone(), public_response, None) .await .unwrap(); @@ -1383,8 +1986,10 @@ mod response_cache_mode_tests { .body(b"body { margin: 0; }".to_vec()) .unwrap(); - let _ = - cache.process_response(analysis.clone(), response).await.unwrap(); + let _ = cache + .process_response(analysis.clone(), response, None) + .await + .unwrap(); // Should be cached despite no-cache header let cached = @@ -1409,8 +2014,10 @@ mod response_cache_mode_tests { .body(b"Error occurred".to_vec()) .unwrap(); - let _ = - cache.process_response(analysis2.clone(), response2).await.unwrap(); + let _ = cache + .process_response(analysis2.clone(), response2, None) + .await + .unwrap(); // Should not be cached due to error marker let cached = @@ -1458,8 +2065,10 @@ mod response_cache_mode_tests { .body(b"normal data".to_vec()) .unwrap(); - let _ = - cache.process_response(analysis.clone(), response).await.unwrap(); + let _ = cache + .process_response(analysis.clone(), response, None) + .await + .unwrap(); // Should be cached using normal HTTP cache semantics let cached =