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}