Skip to main content

odp_agent/
capabilities.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use odp_core::{
4    Collection, FilterDefinition, Operation, SearchCapabilities, SortDefinition,
5    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 = parse_filter_definition_page(&data)?;
280            values.extend(page.items);
281            next = page.next;
282        }
283        if next.is_empty() {
284            Ok(values)
285        } else {
286            Err(AgentError::InvalidResponse(
287                "ODP capability source exceeded 16 pages".to_owned(),
288            ))
289        }
290    }
291
292    async fn load_sort_pages(&self, reference: &str) -> Result<Vec<SortDefinition>, AgentError> {
293        let mut values = Vec::new();
294        let mut next = reference.to_owned();
295        let mut visited = BTreeSet::new();
296        for _ in 0..MAXIMUM_CAPABILITY_PAGES {
297            if next.is_empty() {
298                return Ok(values);
299            }
300            let target = resolve_reference(&next, self.service_origin())?;
301            if !visited.insert(target.to_string()) {
302                return Err(AgentError::InvalidResponse(
303                    "ODP capability pagination loop detected".to_owned(),
304                ));
305            }
306            let data = self
307                .linked_odp(
308                    target,
309                    CacheFallbacks::default().collection,
310                    validate_sort_page,
311                )
312                .await?;
313            let page = parse_sort_definition_page(&data)?;
314            values.extend(page.items);
315            next = page.next;
316        }
317        if next.is_empty() {
318            Ok(values)
319        } else {
320            Err(AgentError::InvalidResponse(
321                "ODP capability source exceeded 16 pages".to_owned(),
322            ))
323        }
324    }
325}
326
327fn duplicate_ids<'a, T>(
328    values: impl Iterator<Item = &'a str>,
329    existing: &BTreeMap<String, T>,
330) -> BTreeSet<String> {
331    let mut seen = BTreeSet::new();
332    let mut duplicates = BTreeSet::new();
333    for value in values {
334        if !seen.insert(value) || existing.contains_key(value) {
335            duplicates.insert(value.to_owned());
336        }
337    }
338    duplicates
339}
340
341fn report_duplicates(
342    duplicates: &BTreeSet<String>,
343    kind: CapabilityKind,
344    scope: CapabilityScope,
345    issues: &mut Vec<CapabilityIssue>,
346) {
347    if !duplicates.is_empty() {
348        issues.push(CapabilityIssue {
349            kind,
350            message: format!(
351                "Duplicate {}: {}",
352                match kind {
353                    CapabilityKind::Filters => "filters",
354                    CapabilityKind::Sorts => "sorts",
355                },
356                duplicates.iter().cloned().collect::<Vec<_>>().join(", ")
357            ),
358            scope,
359        });
360    }
361}
362
363fn resolve_reference(reference: &str, origin: &str) -> Result<Url, AgentError> {
364    let base = Url::parse(origin).map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
365    let target = base
366        .join(reference)
367        .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
368    if !matches!(target.scheme(), "http" | "https") || target.host_str().is_none() {
369        return Err(AgentError::InvalidResponse(
370            "ODP capability reference must use HTTP or HTTPS".to_owned(),
371        ));
372    }
373    Ok(target)
374}
375
376fn validate_filter_page(data: &[u8]) -> Result<(), AgentError> {
377    parse_filter_definition_page(data)?;
378    Ok(())
379}
380
381fn validate_sort_page(data: &[u8]) -> Result<(), AgentError> {
382    parse_sort_definition_page(data)?;
383    Ok(())
384}
385
386#[cfg(test)]
387mod tests {
388    use std::{collections::BTreeMap, sync::Arc};
389
390    use async_trait::async_trait;
391    use odp_directory::{HttpRequest, HttpResponse, Transport, TransportError};
392
393    use super::*;
394
395    struct DocumentTransport;
396
397    #[async_trait]
398    impl Transport for DocumentTransport {
399        async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, TransportError> {
400            Ok(HttpResponse {
401                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(),
402                headers: BTreeMap::from([(
403                    "content-type".to_owned(),
404                    "application/odp+json".to_owned(),
405                )]),
406                status: 200,
407            })
408        }
409    }
410
411    #[tokio::test]
412    async fn resolves_inline_sorts_to_their_filters() {
413        let client =
414            ServiceClient::with_transport("https://plants.example", Arc::new(DocumentTransport))
415                .unwrap();
416        let catalog = client.get_offering_search_capabilities(None).await.unwrap();
417        assert!(catalog.issues.is_empty());
418        assert_eq!(catalog.filters["price"].title, "Price");
419        assert_eq!(catalog.sorts["price-lowest"].filters[0].id, "price");
420    }
421}