Skip to main content

odp_agent/
capabilities.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use odp_core::{
4    Collection, FilterDefinition, Operation, SearchCapabilities, SortDefinition,
5    normalize_agent_response, parse_filter_definition_page, parse_sort_definition_page,
6};
7use url::Url;
8
9use crate::{AgentError, CacheFallbacks, ServiceClient};
10
11const MAXIMUM_CAPABILITY_PAGES: usize = 16;
12const MAXIMUM_FILTERS: usize = 1_024;
13const MAXIMUM_SORTS: usize = 128;
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum CapabilityScope {
17    Collection,
18    Service,
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum CapabilityKind {
23    Filters,
24    Sorts,
25}
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct CapabilityIssue {
29    pub kind: CapabilityKind,
30    pub message: String,
31    pub scope: CapabilityScope,
32}
33
34#[derive(Clone, Debug, PartialEq)]
35pub struct ResolvedSortDefinition {
36    pub definition: SortDefinition,
37    pub filters: Vec<FilterDefinition>,
38}
39
40#[derive(Clone, Debug, Default, PartialEq)]
41pub struct SearchCapabilityCatalog {
42    pub filters: BTreeMap<String, FilterDefinition>,
43    pub issues: Vec<CapabilityIssue>,
44    pub sorts: BTreeMap<String, ResolvedSortDefinition>,
45}
46
47impl ServiceClient {
48    pub async fn get_collection_search_capabilities(
49        &self,
50        id: &str,
51    ) -> Result<SearchCapabilityCatalog, AgentError> {
52        let collection = self.get_collection(id).await?;
53        self.resolve_search_capabilities(Some(&collection)).await
54    }
55
56    pub async fn get_offering_search_capabilities(
57        &self,
58        collection_id: Option<&str>,
59    ) -> Result<SearchCapabilityCatalog, AgentError> {
60        let collection = match collection_id {
61            Some(id) => Some(self.get_collection(id).await?),
62            None => None,
63        };
64        self.resolve_search_capabilities(collection.as_ref()).await
65    }
66
67    async fn resolve_search_capabilities(
68        &self,
69        collection: Option<&Collection>,
70    ) -> Result<SearchCapabilityCatalog, AgentError> {
71        let inspection = self.inspect().await?;
72        let mut result = SearchCapabilityCatalog::default();
73        let supports_search = inspection
74            .document
75            .operations
76            .iter()
77            .any(|value| value.name == Operation::SearchOfferings);
78        if !supports_search {
79            if collection
80                .and_then(|value| value.search_capabilities.as_ref())
81                .is_some()
82            {
83                result.issues.push(CapabilityIssue {
84                    kind: CapabilityKind::Filters,
85                    message: "Collection search capabilities require search-offerings".to_owned(),
86                    scope: CapabilityScope::Collection,
87                });
88            }
89            return Ok(result);
90        }
91        let mut sorts = BTreeMap::new();
92        let mut sort_scopes = BTreeMap::new();
93        let sources = [
94            (
95                inspection.document.search_capabilities.as_ref(),
96                CapabilityScope::Service,
97            ),
98            (
99                collection.and_then(|value| value.search_capabilities.as_ref()),
100                CapabilityScope::Collection,
101            ),
102        ];
103        for (capabilities, scope) in sources {
104            let Some(capabilities) = capabilities else {
105                continue;
106            };
107            self.add_filters(&mut result, scope, capabilities).await;
108            self.add_sorts(
109                &mut result,
110                &mut sorts,
111                &mut sort_scopes,
112                scope,
113                capabilities,
114            )
115            .await;
116        }
117        for (id, definition) in sorts {
118            let filters = definition
119                .keys
120                .iter()
121                .filter_map(|key| result.filters.get(&key.filter_id).cloned())
122                .collect::<Vec<_>>();
123            if filters.len() != definition.keys.len() {
124                result.issues.push(CapabilityIssue {
125                    kind: CapabilityKind::Sorts,
126                    message: format!("Sort {id} references an unavailable filter"),
127                    scope: sort_scopes[&id],
128                });
129                continue;
130            }
131            result.sorts.insert(
132                id,
133                ResolvedSortDefinition {
134                    definition,
135                    filters,
136                },
137            );
138        }
139        Ok(result)
140    }
141
142    async fn add_filters(
143        &self,
144        result: &mut SearchCapabilityCatalog,
145        scope: CapabilityScope,
146        capabilities: &SearchCapabilities,
147    ) {
148        let Some(source) = &capabilities.filters else {
149            return;
150        };
151        let values = if let Some(link) = &source.linked {
152            match self.load_filter_pages(&link.href).await {
153                Ok(values) => values,
154                Err(error) => {
155                    result.issues.push(CapabilityIssue {
156                        kind: CapabilityKind::Filters,
157                        message: error.to_string(),
158                        scope,
159                    });
160                    return;
161                }
162            }
163        } else {
164            source.inline.clone()
165        };
166        let duplicates = duplicate_ids(
167            values.iter().map(|value| value.id.as_str()),
168            &result.filters,
169        );
170        for duplicate in &duplicates {
171            result.filters.remove(duplicate);
172        }
173        let accepted = values
174            .iter()
175            .filter(|value| !duplicates.contains(&value.id))
176            .count();
177        if result.filters.len() + accepted > MAXIMUM_FILTERS {
178            result.issues.push(CapabilityIssue {
179                kind: CapabilityKind::Filters,
180                message: "Effective filters exceed 1024 entries".to_owned(),
181                scope,
182            });
183            return;
184        }
185        for value in values {
186            if !duplicates.contains(&value.id) {
187                result.filters.insert(value.id.clone(), value);
188            }
189        }
190        report_duplicates(
191            &duplicates,
192            CapabilityKind::Filters,
193            scope,
194            &mut result.issues,
195        );
196    }
197
198    async fn add_sorts(
199        &self,
200        result: &mut SearchCapabilityCatalog,
201        target: &mut BTreeMap<String, SortDefinition>,
202        scopes: &mut BTreeMap<String, CapabilityScope>,
203        scope: CapabilityScope,
204        capabilities: &SearchCapabilities,
205    ) {
206        let Some(source) = &capabilities.sorts else {
207            return;
208        };
209        let values = if let Some(link) = &source.linked {
210            match self.load_sort_pages(&link.href).await {
211                Ok(values) => values,
212                Err(error) => {
213                    result.issues.push(CapabilityIssue {
214                        kind: CapabilityKind::Sorts,
215                        message: error.to_string(),
216                        scope,
217                    });
218                    return;
219                }
220            }
221        } else {
222            source.inline.clone()
223        };
224        let duplicates = duplicate_ids(values.iter().map(|value| value.id.as_str()), target);
225        for duplicate in &duplicates {
226            target.remove(duplicate);
227            scopes.remove(duplicate);
228        }
229        let accepted = values
230            .iter()
231            .filter(|value| !duplicates.contains(&value.id))
232            .count();
233        if target.len() + accepted > MAXIMUM_SORTS {
234            result.issues.push(CapabilityIssue {
235                kind: CapabilityKind::Sorts,
236                message: "Effective sorts exceed 128 entries".to_owned(),
237                scope,
238            });
239            return;
240        }
241        for value in values {
242            if !duplicates.contains(&value.id) {
243                scopes.insert(value.id.clone(), scope);
244                target.insert(value.id.clone(), value);
245            }
246        }
247        report_duplicates(
248            &duplicates,
249            CapabilityKind::Sorts,
250            scope,
251            &mut result.issues,
252        );
253    }
254
255    async fn load_filter_pages(
256        &self,
257        reference: &str,
258    ) -> Result<Vec<FilterDefinition>, AgentError> {
259        let mut values = Vec::new();
260        let mut next = reference.to_owned();
261        let mut visited = BTreeSet::new();
262        for _ in 0..MAXIMUM_CAPABILITY_PAGES {
263            if next.is_empty() {
264                return Ok(values);
265            }
266            let target = resolve_reference(&next, self.service_origin())?;
267            if !visited.insert(target.to_string()) {
268                return Err(AgentError::InvalidResponse(
269                    "ODP capability pagination loop detected".to_owned(),
270                ));
271            }
272            let data = self
273                .linked_odp(
274                    target,
275                    CacheFallbacks::default().collection,
276                    validate_filter_page,
277                )
278                .await?;
279            let page =
280                parse_filter_definition_page(&normalize_agent_response(&data, "filter-page")?)?;
281            values.extend(page.items);
282            next = page.next;
283        }
284        if next.is_empty() {
285            Ok(values)
286        } else {
287            Err(AgentError::InvalidResponse(
288                "ODP capability source exceeded 16 pages".to_owned(),
289            ))
290        }
291    }
292
293    async fn load_sort_pages(&self, reference: &str) -> Result<Vec<SortDefinition>, AgentError> {
294        let mut values = Vec::new();
295        let mut next = reference.to_owned();
296        let mut visited = BTreeSet::new();
297        for _ in 0..MAXIMUM_CAPABILITY_PAGES {
298            if next.is_empty() {
299                return Ok(values);
300            }
301            let target = resolve_reference(&next, self.service_origin())?;
302            if !visited.insert(target.to_string()) {
303                return Err(AgentError::InvalidResponse(
304                    "ODP capability pagination loop detected".to_owned(),
305                ));
306            }
307            let data = self
308                .linked_odp(
309                    target,
310                    CacheFallbacks::default().collection,
311                    validate_sort_page,
312                )
313                .await?;
314            let page = parse_sort_definition_page(&normalize_agent_response(&data, "sort-page")?)?;
315            values.extend(page.items);
316            next = page.next;
317        }
318        if next.is_empty() {
319            Ok(values)
320        } else {
321            Err(AgentError::InvalidResponse(
322                "ODP capability source exceeded 16 pages".to_owned(),
323            ))
324        }
325    }
326}
327
328fn duplicate_ids<'a, T>(
329    values: impl Iterator<Item = &'a str>,
330    existing: &BTreeMap<String, T>,
331) -> BTreeSet<String> {
332    let mut seen = BTreeSet::new();
333    let mut duplicates = BTreeSet::new();
334    for value in values {
335        if !seen.insert(value) || existing.contains_key(value) {
336            duplicates.insert(value.to_owned());
337        }
338    }
339    duplicates
340}
341
342fn report_duplicates(
343    duplicates: &BTreeSet<String>,
344    kind: CapabilityKind,
345    scope: CapabilityScope,
346    issues: &mut Vec<CapabilityIssue>,
347) {
348    if !duplicates.is_empty() {
349        issues.push(CapabilityIssue {
350            kind,
351            message: format!(
352                "Duplicate {}: {}",
353                match kind {
354                    CapabilityKind::Filters => "filters",
355                    CapabilityKind::Sorts => "sorts",
356                },
357                duplicates.iter().cloned().collect::<Vec<_>>().join(", ")
358            ),
359            scope,
360        });
361    }
362}
363
364fn resolve_reference(reference: &str, origin: &str) -> Result<Url, AgentError> {
365    let base = Url::parse(origin).map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
366    let target = base
367        .join(reference)
368        .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
369    if !matches!(target.scheme(), "http" | "https") || target.host_str().is_none() {
370        return Err(AgentError::InvalidResponse(
371            "ODP capability reference must use HTTP or HTTPS".to_owned(),
372        ));
373    }
374    Ok(target)
375}
376
377fn validate_filter_page(data: &[u8]) -> Result<(), AgentError> {
378    parse_filter_definition_page(&normalize_agent_response(data, "filter-page")?)?;
379    Ok(())
380}
381
382fn validate_sort_page(data: &[u8]) -> Result<(), AgentError> {
383    parse_sort_definition_page(&normalize_agent_response(data, "sort-page")?)?;
384    Ok(())
385}
386
387#[cfg(test)]
388mod tests {
389    use std::{collections::BTreeMap, sync::Arc};
390
391    use async_trait::async_trait;
392    use odp_directory::{HttpRequest, HttpResponse, Transport, TransportError};
393
394    use super::*;
395
396    struct DocumentTransport;
397
398    #[async_trait]
399    impl Transport for DocumentTransport {
400        async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, TransportError> {
401            Ok(HttpResponse {
402                body: br#"{"description":"Plants","http":{"endpoint_base":"/odp"},"language":"en","localizations":["en"],"name":"Plants","odp_version":"1.0","operations":[{"authentication":"not-required","name":"get-offering"},{"authentication":"not-required","name":"list-offerings"},{"authentication":"not-required","name":"search-offerings"}],"search_capabilities":{"filters":{"inline":[{"description":"Exact price","id":"price","operators":["eq"],"title":"Price","type":"number"}]},"sorts":{"inline":[{"description":"Lowest price first","id":"price-lowest","keys":[{"direction":"ascending","filter_id":"price","missing":"last"}],"title":"Lowest price"}]}}}"#.to_vec(),
403                headers: BTreeMap::from([(
404                    "content-type".to_owned(),
405                    "application/odp+json".to_owned(),
406                )]),
407                status: 200,
408            })
409        }
410    }
411
412    #[tokio::test]
413    async fn resolves_inline_sorts_to_their_filters() {
414        let client =
415            ServiceClient::with_transport("https://plants.example", Arc::new(DocumentTransport))
416                .unwrap();
417        let catalog = client.get_offering_search_capabilities(None).await.unwrap();
418        assert!(catalog.issues.is_empty());
419        assert_eq!(catalog.filters["price"].title, "Price");
420        assert_eq!(catalog.sorts["price-lowest"].filters[0].id, "price");
421    }
422}