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