1use std::collections::{BTreeMap, BTreeSet};
2
3use odp_core::{
4 Action, ActionRelation, ActionRequest, AuthenticationRequirement, Offering, OpenApiActionTarget,
5};
6use serde_json::Value;
7use url::Url;
8
9use crate::{AgentError, ServiceClient};
10
11const MAXIMUM_OPENAPI_BYTES: usize = 1_048_576;
12const MAXIMUM_SCHEMA_BYTES: usize = 524_288;
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum OfferingIssueScope {
16 Action,
17 AttributeSchema,
18 Attributes,
19}
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct OfferingIssue {
23 pub action_id: Option<String>,
24 pub message: String,
25 pub scope: OfferingIssueScope,
26}
27
28#[derive(Clone, Debug, PartialEq)]
29pub struct DiscoveredHttpAction {
30 pub method: String,
31 pub request: Option<ActionRequest>,
32 pub response_content_types: Vec<String>,
33 pub url: String,
34}
35
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct DiscoveredOpenApiAction {
38 pub operation_id: String,
39 pub url: String,
40}
41
42#[derive(Clone, Debug, PartialEq)]
43pub struct DiscoveredAction {
44 pub authentication: AuthenticationRequirement,
45 pub description: String,
46 pub http: Option<DiscoveredHttpAction>,
47 pub id: String,
48 pub openapi: Option<DiscoveredOpenApiAction>,
49 pub rel: ActionRelation,
50}
51
52#[derive(Clone, Debug, PartialEq)]
53pub struct OfferingDetails {
54 pub actions: Vec<DiscoveredAction>,
55 pub attribute_schema: Option<Value>,
56 pub issues: Vec<OfferingIssue>,
57 pub offering: Offering,
58}
59
60#[derive(Clone, Debug, PartialEq)]
61pub struct ResolvedAction {
62 pub action: DiscoveredAction,
63 pub openapi_document: Option<Value>,
64 pub operation: Option<Value>,
65 pub request_schema: Option<Value>,
66}
67
68impl ServiceClient {
69 pub async fn get_offering_details(&self, id: &str) -> Result<OfferingDetails, AgentError> {
70 let inspection = self.inspect().await?;
71 let mut offering = self.get_offering(id).await?;
72 let service_openapi = inspection
73 .document
74 .http
75 .openapi
76 .as_ref()
77 .map(|value| value.url.as_str())
78 .unwrap_or_default();
79 let (actions, mut issues) =
80 normalize_actions(&offering.actions, self.service_origin(), service_openapi);
81 let mut attribute_schema = None;
82 if let Some(reference) = &offering.schema {
83 match resolve_https_reference(&reference.url, self.service_origin()) {
84 Ok(target) => match self
85 .resolve_schema(&target, Some(&offering.attributes))
86 .await
87 {
88 Ok((schema, valid)) => {
89 if valid == Some(false) {
90 offering.attributes.clear();
91 issues.push(OfferingIssue {
92 action_id: None,
93 message: "Offering attributes do not match their Attribute Schema"
94 .to_owned(),
95 scope: OfferingIssueScope::Attributes,
96 });
97 }
98 attribute_schema = Some(schema);
99 }
100 Err(error) => {
101 offering.attributes.clear();
102 issues.push(OfferingIssue {
103 action_id: None,
104 message: error.to_string(),
105 scope: OfferingIssueScope::AttributeSchema,
106 });
107 }
108 },
109 Err(error) => {
110 offering.attributes.clear();
111 issues.push(OfferingIssue {
112 action_id: None,
113 message: error.to_string(),
114 scope: OfferingIssueScope::AttributeSchema,
115 });
116 }
117 }
118 }
119 Ok(OfferingDetails {
120 actions,
121 attribute_schema,
122 issues,
123 offering,
124 })
125 }
126
127 pub async fn resolve_action(
128 &self,
129 offering_id: &str,
130 action_id: &str,
131 ) -> Result<ResolvedAction, AgentError> {
132 let details = self.get_offering_details(offering_id).await?;
133 let action = details
134 .actions
135 .into_iter()
136 .find(|action| action.id == action_id)
137 .ok_or_else(|| {
138 AgentError::InvalidRequest(format!(
139 "ODP Offering does not expose usable Action {action_id}"
140 ))
141 })?;
142 let mut result = ResolvedAction {
143 action,
144 openapi_document: None,
145 operation: None,
146 request_schema: None,
147 };
148 if let Some(http) = &result.action.http {
149 if let Some(reference) = http
150 .request
151 .as_ref()
152 .and_then(|value| value.schema.as_ref())
153 {
154 let target = resolve_https_reference(&reference.url, self.service_origin())?;
155 result.request_schema = Some(self.resolve_schema(&target, None).await?.0);
156 }
157 return Ok(result);
158 }
159 let openapi = result.action.openapi.as_ref().ok_or_else(|| {
160 AgentError::InvalidResponse("ODP Action has no usable target".to_owned())
161 })?;
162 let (document, operation) = self
163 .resolve_openapi(&openapi.url, &openapi.operation_id)
164 .await?;
165 result.openapi_document = Some(document);
166 result.operation = Some(operation);
167 Ok(result)
168 }
169
170 async fn resolve_schema(
171 &self,
172 target: &str,
173 attributes: Option<&BTreeMap<String, Value>>,
174 ) -> Result<(Value, Option<bool>), AgentError> {
175 let schema = self
176 .supporting_json(
177 target,
178 "schema",
179 "application/schema+json, application/json;q=0.9",
180 &["application/schema+json", "application/json"],
181 MAXIMUM_SCHEMA_BYTES,
182 )
183 .await?;
184 let validator = jsonschema::validator_for(&schema)
185 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
186 let valid = attributes.map(|attributes| {
187 serde_json::to_value(attributes)
188 .map(|value| validator.is_valid(&value))
189 .unwrap_or(false)
190 });
191 Ok((schema, valid))
192 }
193
194 async fn resolve_openapi(
195 &self,
196 target: &str,
197 operation_id: &str,
198 ) -> Result<(Value, Value), AgentError> {
199 let document = self
200 .supporting_json(
201 target,
202 "openapi",
203 "application/vnd.oai.openapi+json;version=3.1, application/json;q=0.9",
204 &["application/vnd.oai.openapi+json", "application/json"],
205 MAXIMUM_OPENAPI_BYTES,
206 )
207 .await?;
208 let version = document
209 .get("openapi")
210 .and_then(Value::as_str)
211 .unwrap_or_default();
212 if !version.starts_with("3.1.") {
213 return Err(AgentError::InvalidResponse(
214 "ODP Action requires an OpenAPI 3.1 document".to_owned(),
215 ));
216 }
217 let paths = document
218 .get("paths")
219 .and_then(Value::as_object)
220 .ok_or_else(|| {
221 AgentError::InvalidResponse("ODP OpenAPI document must contain paths".to_owned())
222 })?;
223 let mut matches = Vec::new();
224 for path in paths.values().filter_map(Value::as_object) {
225 for method in [
226 "delete", "get", "head", "options", "patch", "post", "put", "trace",
227 ] {
228 if let Some(operation) = path.get(method).filter(|value| {
229 value.get("operationId").and_then(Value::as_str) == Some(operation_id)
230 }) {
231 matches.push(operation.clone());
232 }
233 }
234 }
235 if matches.len() != 1 {
236 return Err(AgentError::InvalidResponse(format!(
237 "ODP Action operation_id {operation_id} must resolve exactly once"
238 )));
239 }
240 Ok((document, matches.remove(0)))
241 }
242}
243
244fn normalize_actions(
245 actions: &[Action],
246 service_origin: &str,
247 service_openapi: &str,
248) -> (Vec<DiscoveredAction>, Vec<OfferingIssue>) {
249 let mut counts = BTreeMap::new();
250 for action in actions {
251 *counts.entry(action.id.as_str()).or_insert(0_usize) += 1;
252 }
253 let mut reported = BTreeSet::new();
254 let mut discovered = Vec::new();
255 let mut issues = Vec::new();
256 for action in actions {
257 if counts.get(action.id.as_str()).copied().unwrap_or_default() > 1 {
258 if reported.insert(action.id.clone()) {
259 issues.push(action_issue(
260 action,
261 format!("Duplicate Action identifier {}", action.id),
262 ));
263 }
264 continue;
265 }
266 match normalize_action(action, service_origin, service_openapi) {
267 Ok(Some(value)) => discovered.push(value),
268 Ok(None) => {}
269 Err(error) => issues.push(action_issue(action, error.to_string())),
270 }
271 }
272 (discovered, issues)
273}
274
275fn normalize_action(
276 action: &Action,
277 service_origin: &str,
278 service_openapi: &str,
279) -> Result<Option<DiscoveredAction>, AgentError> {
280 let mut discovered = DiscoveredAction {
281 authentication: action.authentication,
282 description: action.description.clone(),
283 http: None,
284 id: action.id.clone(),
285 openapi: None,
286 rel: action.rel,
287 };
288 if let Some(http) = &action.http {
289 discovered.http = Some(DiscoveredHttpAction {
290 method: http.method.clone(),
291 request: http.request.clone(),
292 response_content_types: http.response_content_types.clone(),
293 url: resolve_http_reference(&http.href, service_origin)?,
294 });
295 return Ok(Some(discovered));
296 }
297 if let Some(openapi) = &action.openapi {
298 let target = openapi_target(openapi, service_openapi)?;
299 discovered.openapi = Some(DiscoveredOpenApiAction {
300 operation_id: openapi.operation_id.clone(),
301 url: resolve_https_reference(target, service_origin)?,
302 });
303 return Ok(Some(discovered));
304 }
305 Ok(None)
306}
307
308fn openapi_target<'a>(
309 action: &'a OpenApiActionTarget,
310 service_openapi: &'a str,
311) -> Result<&'a str, AgentError> {
312 if !action.url.is_empty() {
313 Ok(&action.url)
314 } else if !service_openapi.is_empty() {
315 Ok(service_openapi)
316 } else {
317 Err(AgentError::InvalidResponse(
318 "OpenAPI Action has no OpenAPI document URL".to_owned(),
319 ))
320 }
321}
322
323fn resolve_http_reference(reference: &str, base: &str) -> Result<String, AgentError> {
324 let base = Url::parse(base).map_err(|error| AgentError::InvalidRequest(error.to_string()))?;
325 let target = base
326 .join(reference)
327 .map_err(|error| AgentError::InvalidResponse(error.to_string()))?;
328 if !matches!(target.scheme(), "http" | "https") || target.host_str().is_none() {
329 return Err(AgentError::InvalidResponse(
330 "ODP Action target must use HTTP or HTTPS".to_owned(),
331 ));
332 }
333 Ok(target.to_string())
334}
335
336fn resolve_https_reference(reference: &str, base: &str) -> Result<String, AgentError> {
337 let target = resolve_http_reference(reference, base)?;
338 if Url::parse(&target)
339 .map(|value| value.scheme() != "https")
340 .unwrap_or(true)
341 {
342 return Err(AgentError::InvalidResponse(
343 "ODP supporting document URL must use HTTPS".to_owned(),
344 ));
345 }
346 Ok(target)
347}
348
349fn action_issue(action: &Action, message: String) -> OfferingIssue {
350 OfferingIssue {
351 action_id: Some(action.id.clone()),
352 message,
353 scope: OfferingIssueScope::Action,
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use odp_core::parse_offering;
360
361 use super::*;
362
363 #[test]
364 fn normalizes_relative_action_targets_without_invoking_them() {
365 let offering = parse_offering(br#"{"actions":[{"authentication":"not-required","description":"Download","http":{"href":"/downloads/plant.pdf","method":"GET","response_content_types":["application/pdf"]},"id":"download","rel":"download"}],"id":"plant","name":"Plant","odp_version":"1.0"}"#).unwrap();
366 let (actions, issues) = normalize_actions(&offering.actions, "https://plants.example", "");
367 assert!(issues.is_empty());
368 assert_eq!(
369 actions[0].http.as_ref().map(|value| value.url.as_str()),
370 Some("https://plants.example/downloads/plant.pdf")
371 );
372 }
373}