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}