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 normalize_agent_response, parse_agent_service_document, parse_collection, parse_offering,
11 parse_offering_search_response, parse_page, parse_problem_response, 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_agent_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_agent_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_agent_collection_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_agent_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_agent_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_agent_collection_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_agent_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 let kind = if matches!(
355 operation,
356 Operation::ListCollections | Operation::SearchCollections
357 ) {
358 "collection-page"
359 } else {
360 "offering-page"
361 };
362 Ok(parse_page(&normalize_agent_response(&data, kind)?)?)
363 }
364
365 async fn get_offering_page(
366 &self,
367 operation: Operation,
368 id: Option<&str>,
369 representation: Representation,
370 limit: usize,
371 ) -> Result<OfferingPage<Offering>, AgentError> {
372 let data = self
373 .get_page_bytes(operation, id, representation, limit)
374 .await?;
375 Ok(parse_agent_offering_search_response(&data)?)
376 }
377
378 async fn get_page_bytes(
379 &self,
380 operation: Operation,
381 id: Option<&str>,
382 representation: Representation,
383 limit: usize,
384 ) -> Result<Vec<u8>, AgentError> {
385 let inspection = self.require_operation(operation).await?;
386 let mut target = build_operation_url(
387 &inspection.document.http.endpoint_base,
388 operation,
389 &self.service_origin,
390 id,
391 )
392 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
393 target
394 .query_pairs_mut()
395 .append_pair("representation", representation_name(representation));
396 if limit != 0 {
397 target
398 .query_pairs_mut()
399 .append_pair("limit", &limit.to_string());
400 }
401 let fallback = if matches!(
402 operation,
403 Operation::GetCollection | Operation::ListCollections | Operation::SearchCollections
404 ) {
405 self.cache_fallbacks.collection
406 } else {
407 self.cache_fallbacks.offering
408 };
409 let validator = response_validator(operation);
410 Ok(self
411 .request_cached(
412 "GET",
413 target,
414 Vec::new(),
415 MAX_RESOURCE_BYTES,
416 fallback,
417 validator,
418 )
419 .await?
420 .body)
421 }
422
423 async fn get_resource(
424 &self,
425 operation: Operation,
426 id: &str,
427 representation: Representation,
428 ) -> Result<Vec<u8>, AgentError> {
429 self.get_page_bytes(operation, Some(id), representation, 0)
430 .await
431 }
432
433 async fn post_search(
434 &self,
435 operation: Operation,
436 body: Vec<u8>,
437 representation: Representation,
438 ) -> Result<Vec<u8>, AgentError> {
439 let inspection = self.require_operation(operation).await?;
440 let mut target = build_operation_url(
441 &inspection.document.http.endpoint_base,
442 operation,
443 &self.service_origin,
444 None,
445 )
446 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
447 target
448 .query_pairs_mut()
449 .append_pair("representation", representation_name(representation));
450 let fallback = if operation == Operation::SearchCollections {
451 self.cache_fallbacks.collection
452 } else {
453 self.cache_fallbacks.offering
454 };
455 let validator = response_validator(operation);
456 Ok(self
457 .request_cached(
458 "POST",
459 target,
460 body,
461 MAX_RESOURCE_BYTES,
462 fallback,
463 validator,
464 )
465 .await?
466 .body)
467 }
468
469 async fn require_operation(&self, operation: Operation) -> Result<Inspection, AgentError> {
470 let inspection = self.inspect().await?;
471 if inspection
472 .document
473 .operations
474 .iter()
475 .any(|descriptor| descriptor.name == operation)
476 {
477 Ok(inspection)
478 } else {
479 Err(AgentError::UnsupportedOperation(operation))
480 }
481 }
482
483 async fn request_cached(
484 &self,
485 method: &str,
486 target: Url,
487 body: Vec<u8>,
488 maximum_bytes: usize,
489 fallback: Duration,
490 validate: fn(&[u8]) -> Result<(), AgentError>,
491 ) -> Result<Response, AgentError> {
492 let key = self.cache_key(method, target.as_str(), &body);
493 let request_origin = derive_service_origin(target.as_str())
494 .map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
495 let cached = self.cache.get(&key).map_err(AgentError::Cache)?;
496 let now = SystemTime::now();
497 if let Some(record) = &cached {
498 if now < record.expires_at {
499 return Ok(Response {
500 body: record.body.clone(),
501 final_url: record.final_url.clone(),
502 freshness: Freshness::Fresh,
503 });
504 }
505 }
506 let mut conditional = BTreeMap::new();
507 let mut request_target = target;
508 if let Some(record) = &cached {
509 if let Ok(cached_target) = Url::parse(&record.final_url) {
510 if derive_service_origin(cached_target.as_str())
511 .ok()
512 .as_deref()
513 == Some(&request_origin)
514 {
515 request_target = cached_target;
516 }
517 }
518 if let Some(etag) = &record.etag {
519 conditional.insert("if-none-match".to_owned(), etag.clone());
520 }
521 if let Some(last_modified) = &record.last_modified {
522 conditional.insert("if-modified-since".to_owned(), last_modified.clone());
523 }
524 }
525 let raw = self
526 .request_raw(method, request_target, body, conditional, &request_origin)
527 .await?;
528 if raw.status == 304 {
529 let Some(mut record) = cached else {
530 return Err(AgentError::InvalidResponse(
531 "ODP response returned 304 without a cached representation".to_owned(),
532 ));
533 };
534 if no_store(&raw.headers) {
535 self.cache.delete(&key).map_err(AgentError::Cache)?;
536 return Ok(Response {
537 body: record.body,
538 final_url: record.final_url,
539 freshness: Freshness::Revalidated,
540 });
541 }
542 record.expires_at = revalidated_expiration(&raw.headers, &record, fallback, now);
543 record.stored_at = now;
544 record.final_url = raw.final_url;
545 self.cache
546 .set(key, record.clone())
547 .map_err(AgentError::Cache)?;
548 return Ok(Response {
549 body: record.body,
550 final_url: record.final_url,
551 freshness: Freshness::Revalidated,
552 });
553 }
554 let response = consume(raw, maximum_bytes)?;
555 validate(&response.body)?;
556 if !cacheable(method, &response.headers, fallback) {
557 self.cache.delete(&key).map_err(AgentError::Cache)?;
558 } else {
559 self.cache
560 .set(
561 key,
562 CacheRecord {
563 body: response.body.clone(),
564 etag: response.headers.get("etag").cloned(),
565 expires_at: expiration(&response.headers, fallback, now),
566 final_url: response.final_url.clone(),
567 last_modified: response.headers.get("last-modified").cloned(),
568 status: response.status,
569 stored_at: now,
570 },
571 )
572 .map_err(AgentError::Cache)?;
573 }
574 Ok(Response {
575 body: response.body,
576 final_url: response.final_url,
577 freshness: Freshness::Fetched,
578 })
579 }
580
581 async fn request_raw(
582 &self,
583 mut method: &str,
584 mut target: Url,
585 mut body: Vec<u8>,
586 conditional: BTreeMap<String, String>,
587 redirect_origin: &str,
588 ) -> Result<RawResponse, AgentError> {
589 for redirect in 0..=MAX_REDIRECTS {
590 let mut headers = BTreeMap::from([("accept".to_owned(), MEDIA_TYPE.to_owned())]);
591 if let Some(language) = &self.accept_language {
592 headers.insert("accept-language".to_owned(), language.clone());
593 }
594 if !body.is_empty() {
595 headers.insert("content-type".to_owned(), MEDIA_TYPE.to_owned());
596 }
597 headers.extend(conditional.clone());
598 let response = self
599 .transport
600 .send(HttpRequest {
601 body: body.clone(),
602 headers,
603 method: method.to_owned(),
604 url: target.to_string(),
605 })
606 .await?;
607 if matches!(response.status, 301 | 302 | 303 | 307 | 308) {
608 if redirect == MAX_REDIRECTS {
609 return Err(AgentError::InvalidResponse(
610 "ODP response exceeded five redirects".to_owned(),
611 ));
612 }
613 let location = response.headers.get("location").ok_or_else(|| {
614 AgentError::InvalidResponse("ODP redirect omitted Location".to_owned())
615 })?;
616 let next = target
617 .join(location)
618 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
619 if derive_service_origin(next.as_str()).ok().as_deref() != Some(redirect_origin) {
620 return Err(AgentError::InvalidResponse(
621 "ODP redirect changed Service origin".to_owned(),
622 ));
623 }
624 if response.status == 303
625 || (matches!(response.status, 301 | 302) && method == "POST")
626 {
627 method = "GET";
628 body.clear();
629 }
630 target = next;
631 continue;
632 }
633 return Ok(RawResponse {
634 body: response.body,
635 final_url: target.to_string(),
636 headers: response.headers,
637 status: response.status,
638 });
639 }
640 Err(AgentError::InvalidResponse(
641 "ODP response exceeded its redirect limit".to_owned(),
642 ))
643 }
644
645 fn cache_key(&self, method: &str, target: &str, body: &[u8]) -> String {
646 format!(
647 "{}\n{}\n{}\n{}\n{}",
648 self.cache_partition,
649 method,
650 target,
651 self.accept_language.as_deref().unwrap_or_default(),
652 sha256_hex(body)
653 )
654 }
655
656 pub(crate) fn service_origin(&self) -> &str {
657 &self.service_origin
658 }
659
660 pub(crate) async fn linked_odp(
661 &self,
662 target: Url,
663 fallback: Duration,
664 validate: fn(&[u8]) -> Result<(), AgentError>,
665 ) -> Result<Vec<u8>, AgentError> {
666 Ok(self
667 .request_cached(
668 "GET",
669 target,
670 Vec::new(),
671 MAX_RESOURCE_BYTES,
672 fallback,
673 validate,
674 )
675 .await?
676 .body)
677 }
678
679 pub(crate) async fn supporting_json(
680 &self,
681 target: &str,
682 resource_class: &str,
683 accept: &str,
684 media_types: &[&str],
685 maximum_bytes: usize,
686 ) -> Result<serde_json::Value, AgentError> {
687 let mut current =
688 Url::parse(target).map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
689 if current.scheme() != "https" || current.host_str().is_none() {
690 return Err(AgentError::InvalidRequest(
691 "ODP supporting document URL must use HTTPS".to_owned(),
692 ));
693 }
694 let key = format!("anonymous:{resource_class}\nGET\n{target}\n{accept}");
695 let cached = self.cache.get(&key).map_err(AgentError::Cache)?;
696 let now = SystemTime::now();
697 if let Some(record) = &cached {
698 if now < record.expires_at {
699 return decode_json_object(&record.body);
700 }
701 }
702 for redirects in 0..=MAX_REDIRECTS {
703 let mut headers = BTreeMap::from([("accept".to_owned(), accept.to_owned())]);
704 if let Some(record) = &cached {
705 if let Some(etag) = &record.etag {
706 headers.insert("if-none-match".to_owned(), etag.clone());
707 }
708 if let Some(last_modified) = &record.last_modified {
709 headers.insert("if-modified-since".to_owned(), last_modified.clone());
710 }
711 }
712 let response = self
713 .supporting_transport
714 .send(HttpRequest {
715 body: Vec::new(),
716 headers,
717 method: "GET".to_owned(),
718 url: current.to_string(),
719 })
720 .await?;
721 if matches!(response.status, 301 | 302 | 303 | 307 | 308) {
722 if redirects == MAX_REDIRECTS {
723 return Err(AgentError::InvalidResponse(
724 "ODP supporting document exceeded five redirects".to_owned(),
725 ));
726 }
727 let location = response.headers.get("location").ok_or_else(|| {
728 AgentError::InvalidResponse(
729 "ODP supporting document redirect omitted Location".to_owned(),
730 )
731 })?;
732 let next = current
733 .join(location)
734 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
735 if next.scheme() != "https" || next.host_str().is_none() {
736 return Err(AgentError::InvalidResponse(
737 "ODP supporting document redirect must use HTTPS".to_owned(),
738 ));
739 }
740 current = next;
741 continue;
742 }
743 if response.status == 304 {
744 let Some(mut record) = cached.clone() else {
745 return Err(AgentError::InvalidResponse(
746 "ODP supporting document returned 304 without a cached representation"
747 .to_owned(),
748 ));
749 };
750 if no_store(&response.headers) {
751 self.cache.delete(&key).map_err(AgentError::Cache)?;
752 } else {
753 record.expires_at =
754 revalidated_expiration(&response.headers, &record, Duration::ZERO, now);
755 record.stored_at = now;
756 record.final_url = current.to_string();
757 self.cache
758 .set(key.clone(), record.clone())
759 .map_err(AgentError::Cache)?;
760 }
761 return decode_json_object(&record.body);
762 }
763 if !(200..300).contains(&response.status) {
764 return Err(AgentError::Request {
765 message: format!("ODP supporting document returned HTTP {}", response.status),
766 status: response.status,
767 });
768 }
769 if response.body.len() > maximum_bytes {
770 return Err(AgentError::InvalidResponse(
771 "ODP supporting document exceeds its byte limit".to_owned(),
772 ));
773 }
774 let content_type = response
775 .headers
776 .get("content-type")
777 .map(|value| value.split(';').next().unwrap_or_default().trim())
778 .unwrap_or_default();
779 if !media_types
780 .iter()
781 .any(|value| content_type.eq_ignore_ascii_case(value))
782 {
783 return Err(AgentError::InvalidResponse(
784 "ODP supporting document has an unsupported media type".to_owned(),
785 ));
786 }
787 let document = decode_json_object(&response.body)?;
788 if !cacheable("GET", &response.headers, Duration::ZERO) {
789 self.cache.delete(&key).map_err(AgentError::Cache)?;
790 } else {
791 self.cache
792 .set(
793 key.clone(),
794 CacheRecord {
795 body: response.body,
796 etag: response.headers.get("etag").cloned(),
797 expires_at: expiration(&response.headers, Duration::ZERO, now),
798 final_url: current.to_string(),
799 last_modified: response.headers.get("last-modified").cloned(),
800 status: response.status,
801 stored_at: now,
802 },
803 )
804 .map_err(AgentError::Cache)?;
805 }
806 return Ok(document);
807 }
808 Err(AgentError::InvalidResponse(
809 "ODP supporting document exceeded its redirect limit".to_owned(),
810 ))
811 }
812}
813
814fn sha256_hex(data: &[u8]) -> String {
815 const HEX: &[u8; 16] = b"0123456789abcdef";
816 let digest = Sha256::digest(data);
817 let mut encoded = String::with_capacity(digest.len() * 2);
818 for byte in digest {
819 encoded.push(HEX[usize::from(byte >> 4)] as char);
820 encoded.push(HEX[usize::from(byte & 0x0f)] as char);
821 }
822 encoded
823}
824
825fn parse_agent_collection(data: &[u8]) -> Result<Collection, ParseError> {
826 parse_collection(&normalize_agent_response(data, "collection")?)
827}
828
829fn parse_agent_offering(data: &[u8]) -> Result<Offering, ParseError> {
830 parse_offering(&normalize_agent_response(data, "offering")?)
831}
832
833fn parse_agent_collection_page(data: &[u8]) -> Result<Page<Collection>, ParseError> {
834 parse_page(&normalize_agent_response(data, "collection-page")?)
835}
836
837fn parse_agent_offering_search_response(data: &[u8]) -> Result<OfferingPage<Offering>, ParseError> {
838 parse_offering_search_response(&normalize_agent_response(data, "offering-page")?)
839}
840
841fn validate_collections(page: Page<Collection>) -> Result<Page<Collection>, AgentError> {
842 for collection in &page.items {
843 let mut inherited = collection.clone();
844 if inherited.odp_version.is_empty() {
845 inherited.odp_version.clone_from(&page.odp_version);
846 }
847 let data = serde_json::to_vec(&inherited)
848 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
849 parse_agent_collection(&data)?;
850 }
851 Ok(page)
852}
853
854fn validate_offerings(page: OfferingPage<Offering>) -> Result<OfferingPage<Offering>, AgentError> {
855 for offering in &page.items {
856 let mut inherited = offering.clone();
857 if inherited.odp_version.is_empty() {
858 inherited.odp_version.clone_from(&page.odp_version);
859 }
860 let data = serde_json::to_vec(&inherited)
861 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
862 parse_agent_offering(&data)?;
863 }
864 Ok(page)
865}
866
867fn validate_service_document_bytes(data: &[u8]) -> Result<(), AgentError> {
868 parse_agent_service_document(data)?;
869 Ok(())
870}
871
872fn validate_collection_bytes(data: &[u8]) -> Result<(), AgentError> {
873 parse_agent_collection(data)?;
874 Ok(())
875}
876
877fn validate_offering_bytes(data: &[u8]) -> Result<(), AgentError> {
878 parse_agent_offering(data)?;
879 Ok(())
880}
881
882fn validate_collection_page_bytes(data: &[u8]) -> Result<(), AgentError> {
883 let page = parse_agent_collection_page(data)?;
884 validate_collections(page)?;
885 Ok(())
886}
887
888fn validate_offering_page_bytes(data: &[u8]) -> Result<(), AgentError> {
889 validate_offerings(parse_agent_offering_search_response(data)?)?;
890 Ok(())
891}
892
893fn response_validator(operation: Operation) -> fn(&[u8]) -> Result<(), AgentError> {
894 match operation {
895 Operation::GetCollection => validate_collection_bytes,
896 Operation::GetOffering => validate_offering_bytes,
897 Operation::ListCollections | Operation::SearchCollections => validate_collection_page_bytes,
898 Operation::ListCollectionOfferings
899 | Operation::ListOfferings
900 | Operation::SearchOfferings => validate_offering_page_bytes,
901 }
902}
903
904fn traversal_bounds(options: TraversalOptions) -> Result<(usize, usize), AgentError> {
905 let maximum_items = if options.max_items == 0 {
906 MAX_TRAVERSAL_ITEMS
907 } else {
908 options.max_items
909 };
910 let maximum_pages = if options.max_pages == 0 {
911 MAX_TRAVERSAL_PAGES
912 } else {
913 options.max_pages
914 };
915 if maximum_items > MAX_TRAVERSAL_ITEMS || maximum_pages > MAX_TRAVERSAL_PAGES {
916 return Err(AgentError::InvalidRequest(
917 "traversal exceeds 10000 items or 16 pages".to_owned(),
918 ));
919 }
920 Ok((maximum_items, maximum_pages))
921}
922
923struct Response {
924 body: Vec<u8>,
925 final_url: String,
926 freshness: Freshness,
927}
928
929struct RawResponse {
930 body: Vec<u8>,
931 final_url: String,
932 headers: BTreeMap<String, String>,
933 status: u16,
934}
935
936fn consume(response: RawResponse, maximum_bytes: usize) -> Result<RawResponse, AgentError> {
937 if response.body.len() > maximum_bytes {
938 return Err(AgentError::InvalidResponse(
939 "ODP response exceeds its byte limit".to_owned(),
940 ));
941 }
942 if !(200..300).contains(&response.status) {
943 let problem = normalize_agent_response(&response.body, "problem")
944 .unwrap_or_else(|_| response.body.clone());
945 let message = parse_problem_response(&problem, response.status)
946 .map(|problem| {
947 if problem.detail.is_empty() {
948 problem.title
949 } else {
950 problem.detail
951 }
952 })
953 .unwrap_or_else(|_| String::from_utf8_lossy(&response.body).into_owned());
954 return Err(AgentError::Request {
955 message,
956 status: response.status,
957 });
958 }
959 let content_type = response
960 .headers
961 .get("content-type")
962 .map(|value| value.split(';').next().unwrap_or_default().trim())
963 .unwrap_or_default();
964 if !content_type.eq_ignore_ascii_case(MEDIA_TYPE) {
965 return Err(AgentError::InvalidResponse(format!(
966 "ODP response must use {MEDIA_TYPE}"
967 )));
968 }
969 Ok(response)
970}
971
972fn expiration(
973 headers: &BTreeMap<String, String>,
974 fallback: Duration,
975 now: SystemTime,
976) -> SystemTime {
977 let directives = cache_directives(headers);
978 if directives.contains_key("no-cache") {
979 return now;
980 }
981 let maximum_age = directives
982 .get("max-age")
983 .and_then(|value| value.parse::<u64>().ok())
984 .map(Duration::from_secs);
985 if let Some(mut duration) = maximum_age {
986 if let Some(age) = headers
987 .get("age")
988 .and_then(|value| value.trim().parse::<u64>().ok())
989 {
990 duration = duration.saturating_sub(Duration::from_secs(age));
991 }
992 return now.checked_add(duration).unwrap_or(now);
993 }
994 if let Some(expires) = headers
995 .get("expires")
996 .and_then(|value| httpdate::parse_http_date(value).ok())
997 {
998 return expires;
999 }
1000 now.checked_add(fallback).unwrap_or(now)
1001}
1002
1003fn revalidated_expiration(
1004 headers: &BTreeMap<String, String>,
1005 record: &CacheRecord,
1006 fallback: Duration,
1007 now: SystemTime,
1008) -> SystemTime {
1009 if has_freshness(headers) {
1010 expiration(headers, fallback, now)
1011 } else {
1012 let lifetime = record
1013 .expires_at
1014 .duration_since(record.stored_at)
1015 .unwrap_or(Duration::ZERO);
1016 now.checked_add(lifetime).unwrap_or(now)
1017 }
1018}
1019
1020fn no_store(headers: &BTreeMap<String, String>) -> bool {
1021 cache_directives(headers).contains_key("no-store")
1022}
1023
1024fn cacheable(method: &str, headers: &BTreeMap<String, String>, fallback: Duration) -> bool {
1025 if !matches!(method, "GET" | "POST") || !supported_vary(headers) || no_store(headers) {
1026 return false;
1027 }
1028 let directives = cache_directives(headers);
1029 let no_cache = directives.contains_key("no-cache");
1030 (method == "GET" && (!fallback.is_zero() || no_cache)) || explicit_freshness(headers)
1031}
1032
1033fn supported_vary(headers: &BTreeMap<String, String>) -> bool {
1034 headers.get("vary").is_none_or(|value| {
1035 value.split(',').all(|name| {
1036 matches!(
1037 name.trim().to_ascii_lowercase().as_str(),
1038 "" | "accept" | "accept-language" | "content-type"
1039 )
1040 })
1041 })
1042}
1043
1044fn explicit_freshness(headers: &BTreeMap<String, String>) -> bool {
1045 cache_directives(headers).contains_key("max-age") || headers.contains_key("expires")
1046}
1047
1048fn has_freshness(headers: &BTreeMap<String, String>) -> bool {
1049 let directives = cache_directives(headers);
1050 directives.contains_key("max-age")
1051 || directives.contains_key("no-cache")
1052 || directives.contains_key("no-store")
1053 || headers.contains_key("expires")
1054}
1055
1056fn cache_directives(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
1057 headers
1058 .get("cache-control")
1059 .into_iter()
1060 .flat_map(|value| value.split(','))
1061 .map(str::trim)
1062 .filter(|value| !value.is_empty())
1063 .map(|value| {
1064 let (name, setting) = value.split_once('=').unwrap_or((value, ""));
1065 (
1066 name.to_ascii_lowercase(),
1067 setting.trim_matches('"').to_owned(),
1068 )
1069 })
1070 .collect()
1071}
1072
1073fn decode_json_object(data: &[u8]) -> Result<serde_json::Value, AgentError> {
1074 let value = serde_json::from_slice::<serde_json::Value>(data)
1075 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
1076 if !value.is_object() {
1077 return Err(AgentError::InvalidResponse(
1078 "ODP supporting document must be a JSON object".to_owned(),
1079 ));
1080 }
1081 Ok(value)
1082}
1083
1084const fn representation_name(value: Representation) -> &'static str {
1085 match value {
1086 Representation::Terse => "terse",
1087 Representation::Full => "full",
1088 }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use std::{
1094 collections::VecDeque,
1095 sync::{
1096 Mutex,
1097 atomic::{AtomicUsize, Ordering},
1098 },
1099 };
1100
1101 use async_trait::async_trait;
1102 use odp_directory::HttpResponse;
1103
1104 use super::*;
1105
1106 struct MockTransport {
1107 responses: Mutex<VecDeque<HttpResponse>>,
1108 }
1109
1110 #[async_trait]
1111 impl Transport for MockTransport {
1112 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, TransportError> {
1113 Ok(self.responses.lock().unwrap().pop_front().unwrap())
1114 }
1115 }
1116
1117 fn response(body: &'static [u8]) -> HttpResponse {
1118 HttpResponse {
1119 body: body.to_vec(),
1120 headers: BTreeMap::from([("content-type".to_owned(), MEDIA_TYPE.to_owned())]),
1121 status: 200,
1122 }
1123 }
1124
1125 struct ConditionalTransport {
1126 calls: AtomicUsize,
1127 }
1128
1129 #[async_trait]
1130 impl Transport for ConditionalTransport {
1131 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, TransportError> {
1132 let call = self.calls.fetch_add(1, Ordering::SeqCst);
1133 if call == 0 {
1134 return Ok(HttpResponse {
1135 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(),
1136 headers: BTreeMap::from([
1137 ("cache-control".to_owned(), "max-age=0".to_owned()),
1138 ("content-type".to_owned(), MEDIA_TYPE.to_owned()),
1139 ("etag".to_owned(), "document-1".to_owned()),
1140 ]),
1141 status: 200,
1142 });
1143 }
1144 assert_eq!(
1145 request.headers.get("if-none-match").map(String::as_str),
1146 Some("document-1")
1147 );
1148 Ok(HttpResponse {
1149 body: Vec::new(),
1150 headers: BTreeMap::from([("cache-control".to_owned(), "max-age=60".to_owned())]),
1151 status: 304,
1152 })
1153 }
1154 }
1155
1156 struct InvalidTransport {
1157 calls: AtomicUsize,
1158 }
1159
1160 #[async_trait]
1161 impl Transport for InvalidTransport {
1162 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, TransportError> {
1163 self.calls.fetch_add(1, Ordering::SeqCst);
1164 Ok(response(br#"{}"#))
1165 }
1166 }
1167
1168 #[tokio::test]
1169 async fn inspects_support_before_getting_an_offering() {
1170 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"}]}"#;
1171 let offering = br#"{"id":"plant-1","name":"Plant","odp_version":"1.0"}"#;
1172 let client = ServiceClient::with_transport(
1173 "https://demo.inflowpay.ai",
1174 Arc::new(MockTransport {
1175 responses: Mutex::new(VecDeque::from([response(document), response(offering)])),
1176 }),
1177 )
1178 .unwrap();
1179 assert_eq!(client.get_offering("plant-1").await.unwrap().name, "Plant");
1180 }
1181
1182 #[tokio::test]
1183 async fn inspection_preserves_tap_trust_advertisement() {
1184 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"}],"protocols":{"payments":[{"authentication":"not-required","name":"future-payment"},{"authentication":"not-required","name":"mpp"}],"trust":[{"name":"future-trust"},{"name":"tap"}]}}"#;
1185 let client = ServiceClient::with_transport(
1186 "https://demo.inflowpay.ai",
1187 Arc::new(MockTransport {
1188 responses: Mutex::new(VecDeque::from([response(document)])),
1189 }),
1190 )
1191 .unwrap();
1192
1193 let inspection = client.inspect().await.unwrap();
1194 let protocols = inspection.document.protocols.unwrap();
1195 assert_eq!(protocols.payments.len(), 1);
1196 assert_eq!(
1197 protocols.trust,
1198 [odp_core::TrustProtocol {
1199 name: odp_core::Protocol::Tap
1200 }]
1201 );
1202 }
1203
1204 #[tokio::test]
1205 async fn caches_the_service_document_with_its_resource_fallback() {
1206 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"}]}"#;
1207 let client = ServiceClient::with_transport(
1208 "https://demo.inflowpay.ai",
1209 Arc::new(MockTransport {
1210 responses: Mutex::new(VecDeque::from([response(document)])),
1211 }),
1212 )
1213 .unwrap();
1214 assert_eq!(
1215 client.inspect().await.unwrap().freshness,
1216 Freshness::Fetched
1217 );
1218 assert_eq!(client.inspect().await.unwrap().freshness, Freshness::Fresh);
1219 }
1220
1221 #[tokio::test]
1222 async fn revalidates_a_stale_cached_document() {
1223 let transport = Arc::new(ConditionalTransport {
1224 calls: AtomicUsize::new(0),
1225 });
1226 let client =
1227 ServiceClient::with_transport("https://demo.inflowpay.ai", transport.clone()).unwrap();
1228 assert_eq!(
1229 client.inspect().await.unwrap().freshness,
1230 Freshness::Fetched
1231 );
1232 assert_eq!(
1233 client.inspect().await.unwrap().freshness,
1234 Freshness::Revalidated
1235 );
1236 assert_eq!(client.inspect().await.unwrap().freshness, Freshness::Fresh);
1237 assert_eq!(transport.calls.load(Ordering::SeqCst), 2);
1238 }
1239
1240 #[tokio::test]
1241 async fn does_not_cache_an_invalid_document() {
1242 let transport = Arc::new(InvalidTransport {
1243 calls: AtomicUsize::new(0),
1244 });
1245 let client =
1246 ServiceClient::with_transport("https://demo.inflowpay.ai", transport.clone()).unwrap();
1247 assert!(client.inspect().await.is_err());
1248 assert!(client.inspect().await.is_err());
1249 assert_eq!(transport.calls.load(Ordering::SeqCst), 2);
1250 }
1251
1252 #[test]
1253 fn post_search_requires_explicit_freshness_before_caching() {
1254 let headers = BTreeMap::new();
1255 assert!(!cacheable("POST", &headers, Duration::from_secs(300)));
1256 let headers = BTreeMap::from([("cache-control".to_owned(), "max-age=30".to_owned())]);
1257 assert!(cacheable("POST", &headers, Duration::from_secs(300)));
1258 }
1259
1260 #[test]
1261 fn validates_embedded_representations_with_the_page_version() {
1262 assert!(
1263 validate_collection_page_bytes(
1264 br#"{"items":[{"id":"plants","name":"Plants"}],"odp_version":"1.0"}"#
1265 )
1266 .is_ok()
1267 );
1268 assert!(
1269 validate_offering_page_bytes(
1270 br#"{"items":[{"id":"plant","name":"Plant"}],"odp_version":"1.0"}"#
1271 )
1272 .is_ok()
1273 );
1274 assert!(
1275 validate_offering_page_bytes(
1276 br#"{"items":[{"id":"bad/id","name":"Plant"}],"odp_version":"1.0"}"#
1277 )
1278 .is_err()
1279 );
1280 }
1281}