1use std::sync::Arc;
2
3use futures::{StreamExt, stream};
4use odp_core::{Offering, OfferingSearchRequest, Representation};
5use odp_directory::{
6 DirectoryClient, DirectoryService, Environment, IterationOptions, SearchRequest,
7};
8
9use crate::{AgentError, ServiceClient, TraversalOptions};
10
11pub trait ServiceClientFactory: Send + Sync {
12 fn create(&self, service: &DirectoryService) -> Result<ServiceClient, AgentError>;
13}
14
15struct DefaultServiceClientFactory;
16
17impl ServiceClientFactory for DefaultServiceClientFactory {
18 fn create(&self, service: &DirectoryService) -> Result<ServiceClient, AgentError> {
19 ServiceClient::new(&service.service_origin)
20 }
21}
22
23#[derive(Clone, Debug, Default, PartialEq)]
24pub struct FederatedSearchRequest {
25 pub concurrency: usize,
26 pub max_offerings_per_service: usize,
27 pub max_services: usize,
28 pub offerings: OfferingSearchRequest,
29 pub services: SearchRequest,
30}
31
32#[derive(Clone, Debug, PartialEq)]
33pub struct DiscoveryEvent {
34 pub issue: Option<String>,
35 pub offering: Option<Offering>,
36 pub service: DirectoryService,
37}
38
39pub struct Agent {
40 directory: DirectoryClient,
41 factory: Arc<dyn ServiceClientFactory>,
42}
43
44impl Agent {
45 pub fn new(environment: Environment) -> Result<Self, AgentError> {
46 let directory = DirectoryClient::new(environment)
47 .map_err(|error| AgentError::Directory(error.to_string()))?;
48 Ok(Self {
49 directory,
50 factory: Arc::new(DefaultServiceClientFactory),
51 })
52 }
53
54 pub fn with_clients(
55 directory: DirectoryClient,
56 factory: Arc<dyn ServiceClientFactory>,
57 ) -> Self {
58 Self { directory, factory }
59 }
60
61 pub const fn environment(&self) -> Environment {
62 self.directory.environment()
63 }
64
65 pub async fn search_offerings_across_services(
66 &self,
67 request: &FederatedSearchRequest,
68 ) -> Result<Vec<DiscoveryEvent>, AgentError> {
69 let maximum_services = bounded(request.max_services, 10, 100, "max_services")?;
70 let maximum_offerings = bounded(
71 request.max_offerings_per_service,
72 10,
73 100,
74 "max_offerings_per_service",
75 )?;
76 let concurrency = bounded(request.concurrency, 4, 16, "concurrency")?;
77 let services = self
78 .directory
79 .search_services(
80 &request.services,
81 IterationOptions {
82 max_items: maximum_services,
83 max_pages: 0,
84 },
85 )
86 .await
87 .map_err(|error| AgentError::Directory(error.to_string()))?;
88 let offerings = request.offerings.clone();
89 let factory = &self.factory;
90 let results = stream::iter(services.into_iter().map(|service| {
91 let offerings = offerings.clone();
92 async move {
93 let result =
94 search_service(factory.as_ref(), &service, &offerings, maximum_offerings).await;
95 (service, result)
96 }
97 }))
98 .buffered(concurrency)
99 .collect::<Vec<_>>()
100 .await;
101 Ok(results
102 .into_iter()
103 .flat_map(|(service, result)| match result {
104 Ok(offerings) => offerings
105 .into_iter()
106 .map(|offering| DiscoveryEvent {
107 issue: None,
108 offering: Some(offering),
109 service: service.clone(),
110 })
111 .collect(),
112 Err(error) => vec![DiscoveryEvent {
113 issue: Some(error.to_string()),
114 offering: None,
115 service,
116 }],
117 })
118 .collect())
119 }
120}
121
122async fn search_service(
123 factory: &dyn ServiceClientFactory,
124 service: &DirectoryService,
125 request: &OfferingSearchRequest,
126 maximum: usize,
127) -> Result<Vec<Offering>, AgentError> {
128 let client = factory.create(service)?;
129 let traversal = TraversalOptions {
130 max_items: maximum,
131 max_pages: 0,
132 };
133 if has_search(request) {
134 client
135 .search_all_offerings(request, Representation::Terse, traversal)
136 .await
137 } else {
138 client
139 .list_all_offerings(Representation::Terse, 0, traversal)
140 .await
141 }
142}
143
144fn has_search(request: &OfferingSearchRequest) -> bool {
145 !request.query.is_empty()
146 || !request.filters.is_empty()
147 || request.include_descendants
148 || !request.sort.is_empty()
149 || !request.refinements.is_empty()
150 || !request.collection_id.is_empty()
151}
152
153fn bounded(value: usize, fallback: usize, maximum: usize, name: &str) -> Result<usize, AgentError> {
154 let value = if value == 0 { fallback } else { value };
155 if value > maximum {
156 return Err(AgentError::InvalidRequest(format!(
157 "{name} must be from 1 through {maximum}"
158 )));
159 }
160 Ok(value)
161}
162
163#[cfg(test)]
164mod tests {
165 use std::{collections::BTreeMap, sync::Arc};
166
167 use async_trait::async_trait;
168 use odp_directory::{HttpRequest, HttpResponse, Transport, TransportError};
169
170 use super::*;
171
172 struct DirectoryTransport;
173
174 #[async_trait]
175 impl Transport for DirectoryTransport {
176 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, TransportError> {
177 Ok(json_response(br#"{"items":[{"description":"One","indexed_at":"2026-08-25T00:00:00Z","language":"en","localizations":["en"],"name":"One","operations":[],"service_origin":"https://one.example"},{"description":"Two","indexed_at":"2026-08-25T00:00:00Z","language":"en","localizations":["en"],"name":"Two","operations":[],"service_origin":"https://two.example"}]}"#))
178 }
179 }
180
181 struct ServiceTransport;
182
183 #[async_trait]
184 impl Transport for ServiceTransport {
185 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, TransportError> {
186 if request.url.ends_with("/.well-known/odp") {
187 return Ok(odp_response(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"}]}"#));
188 }
189 let id = if request.url.starts_with("https://one.example") {
190 "one"
191 } else {
192 "two"
193 };
194 Ok(odp_response(
195 format!(
196 r#"{{"items":[{{"id":"{id}","name":"Plant {id}","odp_version":"1.0"}}],"odp_version":"1.0"}}"#
197 )
198 .as_bytes(),
199 ))
200 }
201 }
202
203 struct Factory;
204
205 impl ServiceClientFactory for Factory {
206 fn create(&self, service: &DirectoryService) -> Result<ServiceClient, AgentError> {
207 ServiceClient::with_transport(&service.service_origin, Arc::new(ServiceTransport))
208 }
209 }
210
211 fn json_response(body: &[u8]) -> HttpResponse {
212 HttpResponse {
213 body: body.to_vec(),
214 headers: BTreeMap::from([("content-type".to_owned(), "application/json".to_owned())]),
215 status: 200,
216 }
217 }
218
219 fn odp_response(body: &[u8]) -> HttpResponse {
220 HttpResponse {
221 body: body.to_vec(),
222 headers: BTreeMap::from([(
223 "content-type".to_owned(),
224 "application/odp+json".to_owned(),
225 )]),
226 status: 200,
227 }
228 }
229
230 #[tokio::test]
231 async fn preserves_directory_order_across_concurrent_service_searches() {
232 let directory =
233 DirectoryClient::with_transport(Environment::Production, Arc::new(DirectoryTransport));
234 let agent = Agent::with_clients(directory, Arc::new(Factory));
235 let events = agent
236 .search_offerings_across_services(&FederatedSearchRequest {
237 concurrency: 2,
238 ..FederatedSearchRequest::default()
239 })
240 .await
241 .unwrap();
242 assert_eq!(events.len(), 2);
243 assert_eq!(events[0].service.name, "One");
244 assert_eq!(events[1].service.name, "Two");
245 }
246}