Skip to main content

odp_agent/
client.rs

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}