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}