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    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}