1use std::{
2 collections::BTreeMap,
3 sync::Arc,
4 time::{Duration, SystemTime},
5};
6
7use odp_core::{
8 Collection, CollectionSearchRequest, Offering, OfferingPage, OfferingSearchRequest, Operation,
9 Page, ParseError, Representation, ServiceDocument, build_operation_url, derive_service_origin,
10 parse_collection, parse_offering, parse_offering_search_response, parse_page,
11 parse_problem_response, parse_service_document, resolve_continuation,
12};
13use odp_directory::{HttpRequest, ReqwestTransport, Transport, TransportError};
14use sha2::{Digest, Sha256};
15use thiserror::Error;
16use url::Url;
17
18use crate::{Cache, CacheFallbacks, CacheRecord, default_cache};
19
20const MEDIA_TYPE: &str = "application/odp+json";
21const MAX_DOCUMENT_BYTES: usize = 65_536;
22const MAX_RESOURCE_BYTES: usize = 524_288;
23const MAX_REDIRECTS: usize = 5;
24const MAX_TRAVERSAL_ITEMS: usize = 10_000;
25const MAX_TRAVERSAL_PAGES: usize = 16;
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub struct TraversalOptions {
29 pub max_items: usize,
30 pub max_pages: usize,
31}
32
33impl Default for TraversalOptions {
34 fn default() -> Self {
35 Self {
36 max_items: MAX_TRAVERSAL_ITEMS,
37 max_pages: MAX_TRAVERSAL_PAGES,
38 }
39 }
40}
41
42#[derive(Clone, Debug, PartialEq)]
43pub struct Inspection {
44 pub document: ServiceDocument,
45 pub final_url: String,
46 pub freshness: Freshness,
47 pub requested_url: String,
48 pub service_origin: String,
49}
50
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum Freshness {
53 Fetched,
54 Fresh,
55 Revalidated,
56}
57
58#[derive(Debug, Error)]
59pub enum AgentError {
60 #[error(transparent)]
61 Transport(#[from] TransportError),
62 #[error(transparent)]
63 Parse(#[from] ParseError),
64 #[error("invalid Agent request: {0}")]
65 InvalidRequest(String),
66 #[error("invalid ODP response: {0}")]
67 InvalidResponse(String),
68 #[error("ODP cache failed: {0}")]
69 Cache(String),
70 #[error("ODP Directory failed: {0}")]
71 Directory(String),
72 #[error("ODP Service does not advertise {0:?}")]
73 UnsupportedOperation(Operation),
74 #[error("ODP request failed with HTTP {status}: {message}")]
75 Request { message: String, status: u16 },
76}
77
78#[derive(Clone)]
79pub struct ServiceClient {
80 accept_language: Option<String>,
81 cache: Arc<dyn Cache>,
82 cache_fallbacks: CacheFallbacks,
83 cache_partition: String,
84 service_origin: String,
85 supporting_transport: Arc<dyn Transport>,
86 transport: Arc<dyn Transport>,
87}
88
89impl ServiceClient {
90 pub fn new(service_url: &str) -> Result<Self, AgentError> {
91 Self::with_transport(service_url, Arc::new(ReqwestTransport::new()?))
92 }
93
94 pub fn with_transport(
95 service_url: &str,
96 transport: Arc<dyn Transport>,
97 ) -> Result<Self, AgentError> {
98 let supporting_transport = Arc::new(ReqwestTransport::new()?);
99 Ok(Self {
100 accept_language: None,
101 cache: default_cache(),
102 cache_fallbacks: CacheFallbacks::default(),
103 cache_partition: "anonymous".to_owned(),
104 service_origin: derive_service_origin(service_url)
105 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?,
106 supporting_transport,
107 transport,
108 })
109 }
110
111 pub fn with_accept_language(mut self, language: impl Into<String>) -> Self {
112 self.accept_language = Some(language.into());
113 self
114 }
115
116 pub fn with_cache(mut self, cache: Arc<dyn Cache>) -> Self {
117 self.cache = cache;
118 self
119 }
120
121 pub fn with_cache_fallbacks(mut self, fallbacks: CacheFallbacks) -> Self {
122 self.cache_fallbacks = fallbacks;
123 self
124 }
125
126 pub fn with_cache_partition(mut self, partition: impl Into<String>) -> Self {
127 self.cache_partition = partition.into();
128 self
129 }
130
131 pub fn with_supporting_transport(mut self, transport: Arc<dyn Transport>) -> Self {
132 self.supporting_transport = transport;
133 self
134 }
135
136 pub async fn inspect(&self) -> Result<Inspection, AgentError> {
137 let requested_url = format!("{}/.well-known/odp", self.service_origin);
138 let target = Url::parse(&requested_url)
139 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
140 let response = self
141 .request_cached(
142 "GET",
143 target,
144 Vec::new(),
145 MAX_DOCUMENT_BYTES,
146 self.cache_fallbacks.service_document,
147 validate_service_document_bytes,
148 )
149 .await?;
150 let document = parse_service_document(&response.body)?;
151 Ok(Inspection {
152 document,
153 final_url: response.final_url,
154 freshness: response.freshness,
155 requested_url,
156 service_origin: self.service_origin.clone(),
157 })
158 }
159
160 pub async fn list_collections(
161 &self,
162 representation: Representation,
163 limit: usize,
164 ) -> Result<Page<Collection>, AgentError> {
165 let page = self
166 .get_page(Operation::ListCollections, None, representation, limit)
167 .await?;
168 validate_collections(page)
169 }
170
171 pub async fn get_collection(&self, id: &str) -> Result<Collection, AgentError> {
172 let data = self
173 .get_resource(Operation::GetCollection, id, Representation::Full)
174 .await?;
175 Ok(parse_collection(&data)?)
176 }
177
178 pub async fn search_collections(
179 &self,
180 request: &CollectionSearchRequest,
181 representation: Representation,
182 ) -> Result<Page<Collection>, AgentError> {
183 let data = self
184 .post_search(
185 Operation::SearchCollections,
186 serde_json::to_vec(request)
187 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?,
188 representation,
189 )
190 .await?;
191 validate_collections(parse_page(&data)?)
192 }
193
194 pub async fn list_offerings(
195 &self,
196 representation: Representation,
197 limit: usize,
198 ) -> Result<OfferingPage<Offering>, AgentError> {
199 self.get_offering_page(Operation::ListOfferings, None, representation, limit)
200 .await
201 }
202
203 pub async fn list_collection_offerings(
204 &self,
205 collection_id: &str,
206 representation: Representation,
207 limit: usize,
208 ) -> Result<OfferingPage<Offering>, AgentError> {
209 self.get_offering_page(
210 Operation::ListCollectionOfferings,
211 Some(collection_id),
212 representation,
213 limit,
214 )
215 .await
216 }
217
218 pub async fn get_offering(&self, id: &str) -> Result<Offering, AgentError> {
219 let data = self
220 .get_resource(Operation::GetOffering, id, Representation::Full)
221 .await?;
222 Ok(parse_offering(&data)?)
223 }
224
225 pub async fn search_offerings(
226 &self,
227 request: &OfferingSearchRequest,
228 representation: Representation,
229 ) -> Result<OfferingPage<Offering>, AgentError> {
230 let data = self
231 .post_search(
232 Operation::SearchOfferings,
233 serde_json::to_vec(request)
234 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?,
235 representation,
236 )
237 .await?;
238 Ok(parse_offering_search_response(&data)?)
239 }
240
241 pub async fn continue_collections(&self, next: &str) -> Result<Page<Collection>, AgentError> {
242 let target = resolve_continuation(next, &self.service_origin)
243 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
244 let response = self
245 .request_cached(
246 "GET",
247 target,
248 Vec::new(),
249 MAX_RESOURCE_BYTES,
250 self.cache_fallbacks.collection,
251 validate_collection_page_bytes,
252 )
253 .await?;
254 validate_collections(parse_page(&response.body)?)
255 }
256
257 pub async fn continue_offerings(
258 &self,
259 next: &str,
260 ) -> Result<OfferingPage<Offering>, AgentError> {
261 let target = resolve_continuation(next, &self.service_origin)
262 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
263 let response = self
264 .request_cached(
265 "GET",
266 target,
267 Vec::new(),
268 MAX_RESOURCE_BYTES,
269 self.cache_fallbacks.offering,
270 validate_offering_page_bytes,
271 )
272 .await?;
273 Ok(parse_offering_search_response(&response.body)?)
274 }
275
276 pub async fn list_all_collections(
277 &self,
278 representation: Representation,
279 limit: usize,
280 options: TraversalOptions,
281 ) -> Result<Vec<Collection>, AgentError> {
282 let mut page = self.list_collections(representation, limit).await?;
283 self.collect_collections(&mut page, options).await
284 }
285
286 pub async fn list_all_offerings(
287 &self,
288 representation: Representation,
289 limit: usize,
290 options: TraversalOptions,
291 ) -> Result<Vec<Offering>, AgentError> {
292 let mut page = self.list_offerings(representation, limit).await?;
293 self.collect_offerings(&mut page, options).await
294 }
295
296 pub async fn search_all_offerings(
297 &self,
298 request: &OfferingSearchRequest,
299 representation: Representation,
300 options: TraversalOptions,
301 ) -> Result<Vec<Offering>, AgentError> {
302 let mut page = self.search_offerings(request, representation).await?;
303 self.collect_offerings(&mut page, options).await
304 }
305
306 async fn collect_collections(
307 &self,
308 page: &mut Page<Collection>,
309 options: TraversalOptions,
310 ) -> Result<Vec<Collection>, AgentError> {
311 let (maximum_items, maximum_pages) = traversal_bounds(options)?;
312 let mut result = Vec::new();
313 for page_number in 0..maximum_pages {
314 result.extend(page.items.drain(..).take(maximum_items - result.len()));
315 if result.len() == maximum_items || page.next.is_empty() {
316 return Ok(result);
317 }
318 if page_number + 1 < maximum_pages {
319 *page = self.continue_collections(&page.next).await?;
320 }
321 }
322 Ok(result)
323 }
324
325 async fn collect_offerings(
326 &self,
327 page: &mut OfferingPage<Offering>,
328 options: TraversalOptions,
329 ) -> Result<Vec<Offering>, AgentError> {
330 let (maximum_items, maximum_pages) = traversal_bounds(options)?;
331 let mut result = Vec::new();
332 for page_number in 0..maximum_pages {
333 result.extend(page.items.drain(..).take(maximum_items - result.len()));
334 if result.len() == maximum_items || page.next.is_empty() {
335 return Ok(result);
336 }
337 if page_number + 1 < maximum_pages {
338 *page = self.continue_offerings(&page.next).await?;
339 }
340 }
341 Ok(result)
342 }
343
344 async fn get_page<T: serde::de::DeserializeOwned>(
345 &self,
346 operation: Operation,
347 id: Option<&str>,
348 representation: Representation,
349 limit: usize,
350 ) -> Result<Page<T>, AgentError> {
351 let data = self
352 .get_page_bytes(operation, id, representation, limit)
353 .await?;
354 Ok(parse_page(&data)?)
355 }
356
357 async fn get_offering_page(
358 &self,
359 operation: Operation,
360 id: Option<&str>,
361 representation: Representation,
362 limit: usize,
363 ) -> Result<OfferingPage<Offering>, AgentError> {
364 let data = self
365 .get_page_bytes(operation, id, representation, limit)
366 .await?;
367 Ok(parse_offering_search_response(&data)?)
368 }
369
370 async fn get_page_bytes(
371 &self,
372 operation: Operation,
373 id: Option<&str>,
374 representation: Representation,
375 limit: usize,
376 ) -> Result<Vec<u8>, AgentError> {
377 let inspection = self.require_operation(operation).await?;
378 let mut target = build_operation_url(
379 &inspection.document.http.endpoint_base,
380 operation,
381 &self.service_origin,
382 id,
383 )
384 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
385 target
386 .query_pairs_mut()
387 .append_pair("representation", representation_name(representation));
388 if limit != 0 {
389 target
390 .query_pairs_mut()
391 .append_pair("limit", &limit.to_string());
392 }
393 let fallback = if matches!(
394 operation,
395 Operation::GetCollection | Operation::ListCollections | Operation::SearchCollections
396 ) {
397 self.cache_fallbacks.collection
398 } else {
399 self.cache_fallbacks.offering
400 };
401 let validator = response_validator(operation);
402 Ok(self
403 .request_cached(
404 "GET",
405 target,
406 Vec::new(),
407 MAX_RESOURCE_BYTES,
408 fallback,
409 validator,
410 )
411 .await?
412 .body)
413 }
414
415 async fn get_resource(
416 &self,
417 operation: Operation,
418 id: &str,
419 representation: Representation,
420 ) -> Result<Vec<u8>, AgentError> {
421 self.get_page_bytes(operation, Some(id), representation, 0)
422 .await
423 }
424
425 async fn post_search(
426 &self,
427 operation: Operation,
428 body: Vec<u8>,
429 representation: Representation,
430 ) -> Result<Vec<u8>, AgentError> {
431 let inspection = self.require_operation(operation).await?;
432 let mut target = build_operation_url(
433 &inspection.document.http.endpoint_base,
434 operation,
435 &self.service_origin,
436 None,
437 )
438 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
439 target
440 .query_pairs_mut()
441 .append_pair("representation", representation_name(representation));
442 let fallback = if operation == Operation::SearchCollections {
443 self.cache_fallbacks.collection
444 } else {
445 self.cache_fallbacks.offering
446 };
447 let validator = response_validator(operation);
448 Ok(self
449 .request_cached(
450 "POST",
451 target,
452 body,
453 MAX_RESOURCE_BYTES,
454 fallback,
455 validator,
456 )
457 .await?
458 .body)
459 }
460
461 async fn require_operation(&self, operation: Operation) -> Result<Inspection, AgentError> {
462 let inspection = self.inspect().await?;
463 if inspection
464 .document
465 .operations
466 .iter()
467 .any(|descriptor| descriptor.name == operation)
468 {
469 Ok(inspection)
470 } else {
471 Err(AgentError::UnsupportedOperation(operation))
472 }
473 }
474
475 async fn request_cached(
476 &self,
477 method: &str,
478 target: Url,
479 body: Vec<u8>,
480 maximum_bytes: usize,
481 fallback: Duration,
482 validate: fn(&[u8]) -> Result<(), AgentError>,
483 ) -> Result<Response, AgentError> {
484 let key = self.cache_key(method, target.as_str(), &body);
485 let request_origin = derive_service_origin(target.as_str())
486 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
487 let cached = self.cache.get(&key).map_err(AgentError::Cache)?;
488 let now = SystemTime::now();
489 if let Some(record) = &cached {
490 if now < record.expires_at {
491 return Ok(Response {
492 body: record.body.clone(),
493 final_url: record.final_url.clone(),
494 freshness: Freshness::Fresh,
495 });
496 }
497 }
498 let mut conditional = BTreeMap::new();
499 let mut request_target = target;
500 if let Some(record) = &cached {
501 if let Ok(cached_target) = Url::parse(&record.final_url) {
502 if derive_service_origin(cached_target.as_str())
503 .ok()
504 .as_deref()
505 == Some(&request_origin)
506 {
507 request_target = cached_target;
508 }
509 }
510 if let Some(etag) = &record.etag {
511 conditional.insert("if-none-match".to_owned(), etag.clone());
512 }
513 if let Some(last_modified) = &record.last_modified {
514 conditional.insert("if-modified-since".to_owned(), last_modified.clone());
515 }
516 }
517 let raw = self
518 .request_raw(method, request_target, body, conditional, &request_origin)
519 .await?;
520 if raw.status == 304 {
521 let Some(mut record) = cached else {
522 return Err(AgentError::InvalidResponse(
523 "ODP response returned 304 without a cached representation".to_owned(),
524 ));
525 };
526 if no_store(&raw.headers) {
527 self.cache.delete(&key).map_err(AgentError::Cache)?;
528 return Ok(Response {
529 body: record.body,
530 final_url: record.final_url,
531 freshness: Freshness::Revalidated,
532 });
533 }
534 record.expires_at = revalidated_expiration(&raw.headers, &record, fallback, now);
535 record.stored_at = now;
536 record.final_url = raw.final_url;
537 self.cache
538 .set(key, record.clone())
539 .map_err(AgentError::Cache)?;
540 return Ok(Response {
541 body: record.body,
542 final_url: record.final_url,
543 freshness: Freshness::Revalidated,
544 });
545 }
546 let response = consume(raw, maximum_bytes)?;
547 validate(&response.body)?;
548 if !cacheable(method, &response.headers, fallback) {
549 self.cache.delete(&key).map_err(AgentError::Cache)?;
550 } else {
551 self.cache
552 .set(
553 key,
554 CacheRecord {
555 body: response.body.clone(),
556 etag: response.headers.get("etag").cloned(),
557 expires_at: expiration(&response.headers, fallback, now),
558 final_url: response.final_url.clone(),
559 last_modified: response.headers.get("last-modified").cloned(),
560 status: response.status,
561 stored_at: now,
562 },
563 )
564 .map_err(AgentError::Cache)?;
565 }
566 Ok(Response {
567 body: response.body,
568 final_url: response.final_url,
569 freshness: Freshness::Fetched,
570 })
571 }
572
573 async fn request_raw(
574 &self,
575 mut method: &str,
576 mut target: Url,
577 mut body: Vec<u8>,
578 conditional: BTreeMap<String, String>,
579 redirect_origin: &str,
580 ) -> Result<RawResponse, AgentError> {
581 for redirect in 0..=MAX_REDIRECTS {
582 let mut headers = BTreeMap::from([("accept".to_owned(), MEDIA_TYPE.to_owned())]);
583 if let Some(language) = &self.accept_language {
584 headers.insert("accept-language".to_owned(), language.clone());
585 }
586 if !body.is_empty() {
587 headers.insert("content-type".to_owned(), MEDIA_TYPE.to_owned());
588 }
589 headers.extend(conditional.clone());
590 let response = self
591 .transport
592 .send(HttpRequest {
593 body: body.clone(),
594 headers,
595 method: method.to_owned(),
596 url: target.to_string(),
597 })
598 .await?;
599 if matches!(response.status, 301 | 302 | 303 | 307 | 308) {
600 if redirect == MAX_REDIRECTS {
601 return Err(AgentError::InvalidResponse(
602 "ODP response exceeded five redirects".to_owned(),
603 ));
604 }
605 let location = response.headers.get("location").ok_or_else(|| {
606 AgentError::InvalidResponse("ODP redirect omitted Location".to_owned())
607 })?;
608 let next = target
609 .join(location)
610 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
611 if derive_service_origin(next.as_str()).ok().as_deref() != Some(redirect_origin) {
612 return Err(AgentError::InvalidResponse(
613 "ODP redirect changed Service origin".to_owned(),
614 ));
615 }
616 if response.status == 303
617 || (matches!(response.status, 301 | 302) && method == "POST")
618 {
619 method = "GET";
620 body.clear();
621 }
622 target = next;
623 continue;
624 }
625 return Ok(RawResponse {
626 body: response.body,
627 final_url: target.to_string(),
628 headers: response.headers,
629 status: response.status,
630 });
631 }
632 Err(AgentError::InvalidResponse(
633 "ODP response exceeded its redirect limit".to_owned(),
634 ))
635 }
636
637 fn cache_key(&self, method: &str, target: &str, body: &[u8]) -> String {
638 format!(
639 "{}\n{}\n{}\n{}\n{}",
640 self.cache_partition,
641 method,
642 target,
643 self.accept_language.as_deref().unwrap_or_default(),
644 sha256_hex(body)
645 )
646 }
647
648 pub(crate) fn service_origin(&self) -> &str {
649 &self.service_origin
650 }
651
652 pub(crate) async fn linked_odp(
653 &self,
654 target: Url,
655 fallback: Duration,
656 validate: fn(&[u8]) -> Result<(), AgentError>,
657 ) -> Result<Vec<u8>, AgentError> {
658 Ok(self
659 .request_cached(
660 "GET",
661 target,
662 Vec::new(),
663 MAX_RESOURCE_BYTES,
664 fallback,
665 validate,
666 )
667 .await?
668 .body)
669 }
670
671 pub(crate) async fn supporting_json(
672 &self,
673 target: &str,
674 resource_class: &str,
675 accept: &str,
676 media_types: &[&str],
677 maximum_bytes: usize,
678 ) -> Result<serde_json::Value, AgentError> {
679 let mut current =
680 Url::parse(target).map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
681 if current.scheme() != "https" || current.host_str().is_none() {
682 return Err(AgentError::InvalidRequest(
683 "ODP supporting document URL must use HTTPS".to_owned(),
684 ));
685 }
686 let key = format!("anonymous:{resource_class}\nGET\n{target}\n{accept}");
687 let cached = self.cache.get(&key).map_err(AgentError::Cache)?;
688 let now = SystemTime::now();
689 if let Some(record) = &cached {
690 if now < record.expires_at {
691 return decode_json_object(&record.body);
692 }
693 }
694 for redirects in 0..=MAX_REDIRECTS {
695 let mut headers = BTreeMap::from([("accept".to_owned(), accept.to_owned())]);
696 if let Some(record) = &cached {
697 if let Some(etag) = &record.etag {
698 headers.insert("if-none-match".to_owned(), etag.clone());
699 }
700 if let Some(last_modified) = &record.last_modified {
701 headers.insert("if-modified-since".to_owned(), last_modified.clone());
702 }
703 }
704 let response = self
705 .supporting_transport
706 .send(HttpRequest {
707 body: Vec::new(),
708 headers,
709 method: "GET".to_owned(),
710 url: current.to_string(),
711 })
712 .await?;
713 if matches!(response.status, 301 | 302 | 303 | 307 | 308) {
714 if redirects == MAX_REDIRECTS {
715 return Err(AgentError::InvalidResponse(
716 "ODP supporting document exceeded five redirects".to_owned(),
717 ));
718 }
719 let location = response.headers.get("location").ok_or_else(|| {
720 AgentError::InvalidResponse(
721 "ODP supporting document redirect omitted Location".to_owned(),
722 )
723 })?;
724 let next = current
725 .join(location)
726 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
727 if next.scheme() != "https" || next.host_str().is_none() {
728 return Err(AgentError::InvalidResponse(
729 "ODP supporting document redirect must use HTTPS".to_owned(),
730 ));
731 }
732 current = next;
733 continue;
734 }
735 if response.status == 304 {
736 let Some(mut record) = cached.clone() else {
737 return Err(AgentError::InvalidResponse(
738 "ODP supporting document returned 304 without a cached representation"
739 .to_owned(),
740 ));
741 };
742 if no_store(&response.headers) {
743 self.cache.delete(&key).map_err(AgentError::Cache)?;
744 } else {
745 record.expires_at =
746 revalidated_expiration(&response.headers, &record, Duration::ZERO, now);
747 record.stored_at = now;
748 record.final_url = current.to_string();
749 self.cache
750 .set(key.clone(), record.clone())
751 .map_err(AgentError::Cache)?;
752 }
753 return decode_json_object(&record.body);
754 }
755 if !(200..300).contains(&response.status) {
756 return Err(AgentError::Request {
757 message: format!("ODP supporting document returned HTTP {}", response.status),
758 status: response.status,
759 });
760 }
761 if response.body.len() > maximum_bytes {
762 return Err(AgentError::InvalidResponse(
763 "ODP supporting document exceeds its byte limit".to_owned(),
764 ));
765 }
766 let content_type = response
767 .headers
768 .get("content-type")
769 .map(|value| value.split(';').next().unwrap_or_default().trim())
770 .unwrap_or_default();
771 if !media_types
772 .iter()
773 .any(|value| content_type.eq_ignore_ascii_case(value))
774 {
775 return Err(AgentError::InvalidResponse(
776 "ODP supporting document has an unsupported media type".to_owned(),
777 ));
778 }
779 let document = decode_json_object(&response.body)?;
780 if !cacheable("GET", &response.headers, Duration::ZERO) {
781 self.cache.delete(&key).map_err(AgentError::Cache)?;
782 } else {
783 self.cache
784 .set(
785 key.clone(),
786 CacheRecord {
787 body: response.body,
788 etag: response.headers.get("etag").cloned(),
789 expires_at: expiration(&response.headers, Duration::ZERO, now),
790 final_url: current.to_string(),
791 last_modified: response.headers.get("last-modified").cloned(),
792 status: response.status,
793 stored_at: now,
794 },
795 )
796 .map_err(AgentError::Cache)?;
797 }
798 return Ok(document);
799 }
800 Err(AgentError::InvalidResponse(
801 "ODP supporting document exceeded its redirect limit".to_owned(),
802 ))
803 }
804}
805
806fn sha256_hex(data: &[u8]) -> String {
807 const HEX: &[u8; 16] = b"0123456789abcdef";
808 let digest = Sha256::digest(data);
809 let mut encoded = String::with_capacity(digest.len() * 2);
810 for byte in digest {
811 encoded.push(HEX[usize::from(byte >> 4)] as char);
812 encoded.push(HEX[usize::from(byte & 0x0f)] as char);
813 }
814 encoded
815}
816
817fn validate_collections(page: Page<Collection>) -> Result<Page<Collection>, AgentError> {
818 for collection in &page.items {
819 let data = serde_json::to_vec(collection)
820 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
821 parse_collection(&data)?;
822 }
823 Ok(page)
824}
825
826fn validate_service_document_bytes(data: &[u8]) -> Result<(), AgentError> {
827 parse_service_document(data)?;
828 Ok(())
829}
830
831fn validate_collection_bytes(data: &[u8]) -> Result<(), AgentError> {
832 parse_collection(data)?;
833 Ok(())
834}
835
836fn validate_offering_bytes(data: &[u8]) -> Result<(), AgentError> {
837 parse_offering(data)?;
838 Ok(())
839}
840
841fn validate_collection_page_bytes(data: &[u8]) -> Result<(), AgentError> {
842 let page = parse_page(data)?;
843 validate_collections(page)?;
844 Ok(())
845}
846
847fn validate_offering_page_bytes(data: &[u8]) -> Result<(), AgentError> {
848 parse_offering_search_response(data)?;
849 Ok(())
850}
851
852fn response_validator(operation: Operation) -> fn(&[u8]) -> Result<(), AgentError> {
853 match operation {
854 Operation::GetCollection => validate_collection_bytes,
855 Operation::GetOffering => validate_offering_bytes,
856 Operation::ListCollections | Operation::SearchCollections => validate_collection_page_bytes,
857 Operation::ListCollectionOfferings
858 | Operation::ListOfferings
859 | Operation::SearchOfferings => validate_offering_page_bytes,
860 }
861}
862
863fn traversal_bounds(options: TraversalOptions) -> Result<(usize, usize), AgentError> {
864 let maximum_items = if options.max_items == 0 {
865 MAX_TRAVERSAL_ITEMS
866 } else {
867 options.max_items
868 };
869 let maximum_pages = if options.max_pages == 0 {
870 MAX_TRAVERSAL_PAGES
871 } else {
872 options.max_pages
873 };
874 if maximum_items > MAX_TRAVERSAL_ITEMS || maximum_pages > MAX_TRAVERSAL_PAGES {
875 return Err(AgentError::InvalidRequest(
876 "traversal exceeds 10000 items or 16 pages".to_owned(),
877 ));
878 }
879 Ok((maximum_items, maximum_pages))
880}
881
882struct Response {
883 body: Vec<u8>,
884 final_url: String,
885 freshness: Freshness,
886}
887
888struct RawResponse {
889 body: Vec<u8>,
890 final_url: String,
891 headers: BTreeMap<String, String>,
892 status: u16,
893}
894
895fn consume(response: RawResponse, maximum_bytes: usize) -> Result<RawResponse, AgentError> {
896 if response.body.len() > maximum_bytes {
897 return Err(AgentError::InvalidResponse(
898 "ODP response exceeds its byte limit".to_owned(),
899 ));
900 }
901 if !(200..300).contains(&response.status) {
902 let message = parse_problem_response(&response.body, response.status)
903 .map(|problem| {
904 if problem.detail.is_empty() {
905 problem.title
906 } else {
907 problem.detail
908 }
909 })
910 .unwrap_or_else(|_| String::from_utf8_lossy(&response.body).into_owned());
911 return Err(AgentError::Request {
912 message,
913 status: response.status,
914 });
915 }
916 let content_type = response
917 .headers
918 .get("content-type")
919 .map(|value| value.split(';').next().unwrap_or_default().trim())
920 .unwrap_or_default();
921 if !content_type.eq_ignore_ascii_case(MEDIA_TYPE) {
922 return Err(AgentError::InvalidResponse(format!(
923 "ODP response must use {MEDIA_TYPE}"
924 )));
925 }
926 Ok(response)
927}
928
929fn expiration(
930 headers: &BTreeMap<String, String>,
931 fallback: Duration,
932 now: SystemTime,
933) -> SystemTime {
934 let directives = cache_directives(headers);
935 if directives.contains_key("no-cache") {
936 return now;
937 }
938 let maximum_age = directives
939 .get("max-age")
940 .and_then(|value| value.parse::<u64>().ok())
941 .map(Duration::from_secs);
942 if let Some(mut duration) = maximum_age {
943 if let Some(age) = headers
944 .get("age")
945 .and_then(|value| value.trim().parse::<u64>().ok())
946 {
947 duration = duration.saturating_sub(Duration::from_secs(age));
948 }
949 return now.checked_add(duration).unwrap_or(now);
950 }
951 if let Some(expires) = headers
952 .get("expires")
953 .and_then(|value| httpdate::parse_http_date(value).ok())
954 {
955 return expires;
956 }
957 now.checked_add(fallback).unwrap_or(now)
958}
959
960fn revalidated_expiration(
961 headers: &BTreeMap<String, String>,
962 record: &CacheRecord,
963 fallback: Duration,
964 now: SystemTime,
965) -> SystemTime {
966 if has_freshness(headers) {
967 expiration(headers, fallback, now)
968 } else {
969 let lifetime = record
970 .expires_at
971 .duration_since(record.stored_at)
972 .unwrap_or(Duration::ZERO);
973 now.checked_add(lifetime).unwrap_or(now)
974 }
975}
976
977fn no_store(headers: &BTreeMap<String, String>) -> bool {
978 cache_directives(headers).contains_key("no-store")
979}
980
981fn cacheable(method: &str, headers: &BTreeMap<String, String>, fallback: Duration) -> bool {
982 if !matches!(method, "GET" | "POST") || !supported_vary(headers) || no_store(headers) {
983 return false;
984 }
985 let directives = cache_directives(headers);
986 let no_cache = directives.contains_key("no-cache");
987 (method == "GET" && (!fallback.is_zero() || no_cache)) || explicit_freshness(headers)
988}
989
990fn supported_vary(headers: &BTreeMap<String, String>) -> bool {
991 headers.get("vary").is_none_or(|value| {
992 value.split(',').all(|name| {
993 matches!(
994 name.trim().to_ascii_lowercase().as_str(),
995 "" | "accept" | "accept-language" | "content-type"
996 )
997 })
998 })
999}
1000
1001fn explicit_freshness(headers: &BTreeMap<String, String>) -> bool {
1002 cache_directives(headers).contains_key("max-age") || headers.contains_key("expires")
1003}
1004
1005fn has_freshness(headers: &BTreeMap<String, String>) -> bool {
1006 let directives = cache_directives(headers);
1007 directives.contains_key("max-age")
1008 || directives.contains_key("no-cache")
1009 || directives.contains_key("no-store")
1010 || headers.contains_key("expires")
1011}
1012
1013fn cache_directives(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
1014 headers
1015 .get("cache-control")
1016 .into_iter()
1017 .flat_map(|value| value.split(','))
1018 .map(str::trim)
1019 .filter(|value| !value.is_empty())
1020 .map(|value| {
1021 let (name, setting) = value.split_once('=').unwrap_or((value, ""));
1022 (
1023 name.to_ascii_lowercase(),
1024 setting.trim_matches('"').to_owned(),
1025 )
1026 })
1027 .collect()
1028}
1029
1030fn decode_json_object(data: &[u8]) -> Result<serde_json::Value, AgentError> {
1031 let value = serde_json::from_slice::<serde_json::Value>(data)
1032 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
1033 if !value.is_object() {
1034 return Err(AgentError::InvalidResponse(
1035 "ODP supporting document must be a JSON object".to_owned(),
1036 ));
1037 }
1038 Ok(value)
1039}
1040
1041const fn representation_name(value: Representation) -> &'static str {
1042 match value {
1043 Representation::Terse => "terse",
1044 Representation::Full => "full",
1045 }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use std::{
1051 collections::VecDeque,
1052 sync::{
1053 Mutex,
1054 atomic::{AtomicUsize, Ordering},
1055 },
1056 };
1057
1058 use async_trait::async_trait;
1059 use odp_directory::HttpResponse;
1060
1061 use super::*;
1062
1063 struct MockTransport {
1064 responses: Mutex<VecDeque<HttpResponse>>,
1065 }
1066
1067 #[async_trait]
1068 impl Transport for MockTransport {
1069 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, TransportError> {
1070 Ok(self.responses.lock().unwrap().pop_front().unwrap())
1071 }
1072 }
1073
1074 fn response(body: &'static [u8]) -> HttpResponse {
1075 HttpResponse {
1076 body: body.to_vec(),
1077 headers: BTreeMap::from([("content-type".to_owned(), MEDIA_TYPE.to_owned())]),
1078 status: 200,
1079 }
1080 }
1081
1082 struct ConditionalTransport {
1083 calls: AtomicUsize,
1084 }
1085
1086 #[async_trait]
1087 impl Transport for ConditionalTransport {
1088 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, TransportError> {
1089 let call = self.calls.fetch_add(1, Ordering::SeqCst);
1090 if call == 0 {
1091 return Ok(HttpResponse {
1092 body: br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Indica Flowers","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"}]}"#.to_vec(),
1093 headers: BTreeMap::from([
1094 ("cache-control".to_owned(), "max-age=0".to_owned()),
1095 ("content-type".to_owned(), MEDIA_TYPE.to_owned()),
1096 ("etag".to_owned(), "document-1".to_owned()),
1097 ]),
1098 status: 200,
1099 });
1100 }
1101 assert_eq!(
1102 request.headers.get("if-none-match").map(String::as_str),
1103 Some("document-1")
1104 );
1105 Ok(HttpResponse {
1106 body: Vec::new(),
1107 headers: BTreeMap::from([("cache-control".to_owned(), "max-age=60".to_owned())]),
1108 status: 304,
1109 })
1110 }
1111 }
1112
1113 struct InvalidTransport {
1114 calls: AtomicUsize,
1115 }
1116
1117 #[async_trait]
1118 impl Transport for InvalidTransport {
1119 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, TransportError> {
1120 self.calls.fetch_add(1, Ordering::SeqCst);
1121 Ok(response(br#"{}"#))
1122 }
1123 }
1124
1125 #[tokio::test]
1126 async fn inspects_support_before_getting_an_offering() {
1127 let document = br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Indica Flowers","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"}]}"#;
1128 let offering = br#"{"id":"plant-1","name":"Plant","odp_version":"1.0"}"#;
1129 let client = ServiceClient::with_transport(
1130 "https://demo.inflowpay.ai",
1131 Arc::new(MockTransport {
1132 responses: Mutex::new(VecDeque::from([response(document), response(offering)])),
1133 }),
1134 )
1135 .unwrap();
1136 assert_eq!(client.get_offering("plant-1").await.unwrap().name, "Plant");
1137 }
1138
1139 #[tokio::test]
1140 async fn caches_the_service_document_with_its_resource_fallback() {
1141 let document = br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Indica Flowers","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"}]}"#;
1142 let client = ServiceClient::with_transport(
1143 "https://demo.inflowpay.ai",
1144 Arc::new(MockTransport {
1145 responses: Mutex::new(VecDeque::from([response(document)])),
1146 }),
1147 )
1148 .unwrap();
1149 assert_eq!(
1150 client.inspect().await.unwrap().freshness,
1151 Freshness::Fetched
1152 );
1153 assert_eq!(client.inspect().await.unwrap().freshness, Freshness::Fresh);
1154 }
1155
1156 #[tokio::test]
1157 async fn revalidates_a_stale_cached_document() {
1158 let transport = Arc::new(ConditionalTransport {
1159 calls: AtomicUsize::new(0),
1160 });
1161 let client =
1162 ServiceClient::with_transport("https://demo.inflowpay.ai", transport.clone()).unwrap();
1163 assert_eq!(
1164 client.inspect().await.unwrap().freshness,
1165 Freshness::Fetched
1166 );
1167 assert_eq!(
1168 client.inspect().await.unwrap().freshness,
1169 Freshness::Revalidated
1170 );
1171 assert_eq!(client.inspect().await.unwrap().freshness, Freshness::Fresh);
1172 assert_eq!(transport.calls.load(Ordering::SeqCst), 2);
1173 }
1174
1175 #[tokio::test]
1176 async fn does_not_cache_an_invalid_document() {
1177 let transport = Arc::new(InvalidTransport {
1178 calls: AtomicUsize::new(0),
1179 });
1180 let client =
1181 ServiceClient::with_transport("https://demo.inflowpay.ai", transport.clone()).unwrap();
1182 assert!(client.inspect().await.is_err());
1183 assert!(client.inspect().await.is_err());
1184 assert_eq!(transport.calls.load(Ordering::SeqCst), 2);
1185 }
1186
1187 #[test]
1188 fn post_search_requires_explicit_freshness_before_caching() {
1189 let headers = BTreeMap::new();
1190 assert!(!cacheable("POST", &headers, Duration::from_secs(300)));
1191 let headers = BTreeMap::from([("cache-control".to_owned(), "max-age=30".to_owned())]);
1192 assert!(cacheable("POST", &headers, Duration::from_secs(300)));
1193 }
1194}