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};
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}