Skip to main content

odp_agent/
details.rs

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}