Skip to main content

aep_core/
openapi.rs

1use url::Url;
2
3use crate::{CoreError, OpenApiTrailingSlash};
4
5#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct OpenApiPathMatchOptions {
7    pub method: String,
8    pub path: String,
9    pub trailing_slash: OpenApiTrailingSlash,
10}
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct OpenApiPathMatch {
14    pub method: String,
15    pub template: String,
16}
17
18pub fn match_openapi_path(
19    templates: &[String],
20    options: &OpenApiPathMatchOptions,
21) -> Result<OpenApiPathMatch, CoreError> {
22    let method = options.method.to_ascii_uppercase();
23    if method.is_empty() || options.path.is_empty() || options.path.contains('?') {
24        return Err(CoreError::Invalid(
25            "invalid OpenAPI operation target".to_owned(),
26        ));
27    }
28    let request_segments = path_segments(&options.path, options.trailing_slash);
29    let mut best: Option<(&str, Vec<u8>)> = None;
30    let mut ambiguous = false;
31    for template in templates {
32        let template_segments = path_segments(template, options.trailing_slash);
33        if template_segments.len() != request_segments.len() {
34            continue;
35        }
36        let mut specificity = Vec::with_capacity(template_segments.len());
37        let mut matched = true;
38        for (template_segment, request_segment) in
39            template_segments.iter().zip(request_segments.iter())
40        {
41            if template_segment.starts_with('{')
42                && template_segment.ends_with('}')
43                && template_segment.len() > 2
44            {
45                specificity.push(0);
46            } else {
47                specificity.push(1);
48                if template_segment != request_segment {
49                    matched = false;
50                    break;
51                }
52            }
53        }
54        if !matched {
55            continue;
56        }
57        match &best {
58            None => {
59                best = Some((template, specificity));
60                ambiguous = false;
61            }
62            Some((_current, score)) if specificity.as_slice() > score.as_slice() => {
63                best = Some((template, specificity));
64                ambiguous = false;
65            }
66            Some((_current, score)) if specificity == *score => ambiguous = true,
67            Some(_) => {}
68        }
69    }
70    let Some((template, _score)) = best else {
71        return Err(CoreError::Invalid(
72            "OpenAPI operation is not documented".to_owned(),
73        ));
74    };
75    if ambiguous {
76        return Err(CoreError::Invalid(
77            "ambiguous OpenAPI path templates".to_owned(),
78        ));
79    }
80    Ok(OpenApiPathMatch {
81        method,
82        template: template.to_owned(),
83    })
84}
85
86pub fn resolve_openapi_url(
87    final_inspect_url: &Url,
88    reference: &str,
89    allow_insecure_loopback: bool,
90) -> Result<Url, CoreError> {
91    if final_inspect_url.scheme() != "https"
92        || final_inspect_url.host_str().is_none()
93        || !final_inspect_url.username().is_empty()
94        || final_inspect_url.password().is_some()
95        || final_inspect_url.fragment().is_some()
96    {
97        return Err(CoreError::Invalid(
98            "invalid final AEP Inspect URL".to_owned(),
99        ));
100    }
101    let resolved = final_inspect_url.join(reference)?;
102    if !resolved.username().is_empty()
103        || resolved.password().is_some()
104        || resolved.fragment().is_some()
105        || resolved.host_str().is_none()
106    {
107        return Err(CoreError::Invalid("invalid AEP OpenAPI URL".to_owned()));
108    }
109    let secure = resolved.scheme() == "https";
110    let allowed_loopback = allow_insecure_loopback
111        && resolved.scheme() == "http"
112        && resolved.host_str().is_some_and(is_loopback_host);
113    if !secure && !allowed_loopback {
114        return Err(CoreError::Invalid(
115            "AEP OpenAPI URL requires HTTPS".to_owned(),
116        ));
117    }
118    Ok(resolved)
119}
120
121fn path_segments(path: &str, trailing_slash: OpenApiTrailingSlash) -> Vec<&str> {
122    let path = if trailing_slash == OpenApiTrailingSlash::Equivalent && path != "/" {
123        path.strip_suffix('/').unwrap_or(path)
124    } else {
125        path
126    };
127    path.strip_prefix('/').unwrap_or(path).split('/').collect()
128}
129
130pub(crate) fn is_loopback_host(host: &str) -> bool {
131    matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn prefers_the_most_specific_template() {
140        let matched = match_openapi_path(
141            &["/items/{id}".to_owned(), "/items/current".to_owned()],
142            &OpenApiPathMatchOptions {
143                method: "get".to_owned(),
144                path: "/items/current".to_owned(),
145                trailing_slash: OpenApiTrailingSlash::Strict,
146            },
147        )
148        .expect("operation match");
149        assert_eq!(matched.template, "/items/current");
150    }
151
152    #[test]
153    fn handles_trailing_slashes_and_ambiguous_templates() {
154        let matched = match_openapi_path(
155            &["/items/{id}".to_owned()],
156            &OpenApiPathMatchOptions {
157                method: "post".to_owned(),
158                path: "/items/one/".to_owned(),
159                trailing_slash: OpenApiTrailingSlash::Equivalent,
160            },
161        )
162        .expect("equivalent trailing slash");
163        assert_eq!(matched.method, "POST");
164        assert!(
165            match_openapi_path(
166                &["/items/{id}".to_owned(), "/items/{name}".to_owned()],
167                &OpenApiPathMatchOptions {
168                    method: "GET".to_owned(),
169                    path: "/items/one".to_owned(),
170                    trailing_slash: OpenApiTrailingSlash::Strict,
171                },
172            )
173            .is_err()
174        );
175        assert!(
176            match_openapi_path(
177                &["/items/{id}".to_owned()],
178                &OpenApiPathMatchOptions {
179                    method: "GET".to_owned(),
180                    path: "/other/one".to_owned(),
181                    trailing_slash: OpenApiTrailingSlash::Strict,
182                },
183            )
184            .is_err()
185        );
186    }
187
188    #[test]
189    fn resolves_only_safe_openapi_urls() {
190        let inspect = Url::parse("https://service.example/.well-known/aep").expect("Inspect URL");
191        assert_eq!(
192            resolve_openapi_url(&inspect, "/openapi.json", false)
193                .expect("resolved OpenAPI URL")
194                .as_str(),
195            "https://service.example/openapi.json"
196        );
197        assert!(
198            resolve_openapi_url(&inspect, "http://service.example/openapi.json", false).is_err()
199        );
200        let loopback = Url::parse("https://127.0.0.1/.well-known/aep").expect("Inspect URL");
201        assert!(resolve_openapi_url(&loopback, "http://127.0.0.1/openapi.json", true).is_ok());
202    }
203}