1use anyhow::{anyhow, Result};
6use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
7use serde_json::Value;
8
9const PATH_PARAM_ENCODE: &AsciiSet = &CONTROLS
11 .add(b' ')
12 .add(b'"')
13 .add(b'#')
14 .add(b'<')
15 .add(b'>')
16 .add(b'`')
17 .add(b'?')
18 .add(b'{')
19 .add(b'}')
20 .add(b'/')
21 .add(b'%');
22
23pub fn resolve_path_template(template: &str, values: &[(String, String)]) -> Result<String> {
26 let mut out = template.to_string();
27 for (name, raw) in values {
28 let token = format!("{{{}}}", name);
29 if out.contains(&token) {
30 let encoded = utf8_percent_encode(raw, PATH_PARAM_ENCODE).to_string();
31 out = out.replace(&token, &encoded);
32 }
33 }
34 if out.contains('{') {
35 return Err(anyhow!(
36 "unresolved path placeholders in {:?} (fill all path parameters)",
37 template
38 ));
39 }
40 Ok(out)
41}
42
43const OPENAPI_OPERATION_METHODS: &[&str] = &[
45 "get", "post", "put", "delete", "patch", "options", "head", "trace",
46];
47
48pub fn is_openapi_operation_method(key: &str) -> bool {
50 OPENAPI_OPERATION_METHODS
51 .iter()
52 .any(|m| m.eq_ignore_ascii_case(key))
53}
54
55#[derive(Debug, Clone)]
56pub struct ApiParameter {
57 pub name: String,
58 pub param_type: String,
59 pub required: bool,
60 pub default: Option<String>,
61 #[allow(dead_code)]
62 pub description: Option<String>,
63}
64
65#[derive(Debug, Clone)]
66pub struct ApiEndpoint {
67 pub method: String,
68 pub path: String,
69 pub summary: Option<String>,
70 #[allow(dead_code)]
71 pub description: Option<String>,
72 pub query_params: Vec<ApiParameter>,
73 pub path_params: Vec<ApiParameter>,
74 pub has_body: bool,
75 pub tags: Vec<String>,
76}
77
78#[derive(Debug, Clone, Default)]
79pub struct EndpointRegistry {
80 pub endpoints: Vec<ApiEndpoint>,
81}
82
83fn parse_parameters_array(params: &[Value]) -> (Vec<ApiParameter>, Vec<ApiParameter>) {
85 let mut query_params = Vec::new();
86 let mut path_params = Vec::new();
87
88 for param in params {
89 let Some(param_obj) = param.as_object() else {
90 continue;
91 };
92 if param_obj.contains_key("$ref") {
94 continue;
95 }
96
97 let name = param_obj
98 .get("name")
99 .and_then(|v| v.as_str())
100 .map(|s| s.to_string())
101 .unwrap_or_default();
102
103 let param_in = param_obj
104 .get("in")
105 .and_then(|v| v.as_str())
106 .unwrap_or("query");
107
108 let required = param_obj
109 .get("required")
110 .and_then(|v| v.as_bool())
111 .unwrap_or(false);
112
113 let schema = param_obj.get("schema");
114 let param_type = schema
115 .and_then(|s| s.get("type"))
116 .and_then(|v| v.as_str())
117 .map(|s| s.to_string())
118 .unwrap_or_else(|| "string".to_string());
119
120 let default = schema.and_then(|s| s.get("default")).and_then(|v| {
121 if v.is_string() {
122 v.as_str().map(|s| s.to_string())
123 } else {
124 Some(v.to_string())
125 }
126 });
127
128 let description = param_obj
129 .get("description")
130 .and_then(|v| v.as_str())
131 .map(|s| s.to_string());
132
133 let api_param = ApiParameter {
134 name,
135 param_type,
136 required,
137 default,
138 description,
139 };
140
141 match param_in {
142 "query" => query_params.push(api_param),
143 "path" => path_params.push(api_param),
144 _ => {}
145 }
146 }
147
148 (query_params, path_params)
149}
150
151fn merge_parameter_lists(
153 path_query: Vec<ApiParameter>,
154 path_path: Vec<ApiParameter>,
155 op_query: Vec<ApiParameter>,
156 op_path: Vec<ApiParameter>,
157) -> (Vec<ApiParameter>, Vec<ApiParameter>) {
158 let mut query = path_query;
159 let mut path = path_path;
160
161 for p in op_query {
162 query.retain(|x| x.name != p.name);
163 query.push(p);
164 }
165 for p in op_path {
166 path.retain(|x| x.name != p.name);
167 path.push(p);
168 }
169
170 (query, path)
171}
172
173impl EndpointRegistry {
174 pub fn from_openapi_json(json_str: &str) -> Result<Self> {
175 let value: Value = serde_json::from_str(json_str)
176 .map_err(|e| anyhow!("Failed to parse OpenAPI JSON: {}", e))?;
177
178 let paths = value
179 .get("paths")
180 .and_then(|v| v.as_object())
181 .ok_or_else(|| anyhow!("OpenAPI JSON missing 'paths' object"))?;
182
183 let mut endpoints = Vec::new();
184
185 for (path, path_item) in paths {
186 let path_item = path_item
187 .as_object()
188 .ok_or_else(|| anyhow!("Invalid path definition for {}", path))?;
189
190 let path_level = path_item
191 .get("parameters")
192 .and_then(|v| v.as_array())
193 .map(|a| a.as_slice())
194 .unwrap_or(&[]);
195 let (path_q, path_p) = parse_parameters_array(path_level);
196
197 for (method_key, operation) in path_item {
198 if !is_openapi_operation_method(method_key) {
199 continue;
200 }
201
202 let operation = operation
203 .as_object()
204 .ok_or_else(|| anyhow!("Invalid operation for {} {}", method_key, path))?;
205
206 let summary = operation
207 .get("summary")
208 .and_then(|v| v.as_str())
209 .map(|s| s.to_string());
210 let description = operation
211 .get("description")
212 .and_then(|v| v.as_str())
213 .map(|s| s.to_string());
214
215 let tags = operation
216 .get("tags")
217 .and_then(|v| v.as_array())
218 .map(|arr| {
219 arr.iter()
220 .filter_map(|v| v.as_str())
221 .map(|s| s.to_string())
222 .collect()
223 })
224 .unwrap_or_default();
225
226 let op_level = operation
227 .get("parameters")
228 .and_then(|v| v.as_array())
229 .map(|a| a.as_slice())
230 .unwrap_or(&[]);
231 let (op_q, op_p) = parse_parameters_array(op_level);
232 let (query_params, path_params) =
233 merge_parameter_lists(path_q.clone(), path_p.clone(), op_q, op_p);
234
235 let has_body = operation.get("requestBody").is_some();
236
237 endpoints.push(ApiEndpoint {
238 method: method_key.to_uppercase(),
239 path: path.clone(),
240 summary,
241 description,
242 query_params,
243 path_params,
244 has_body,
245 tags,
246 });
247 }
248 }
249
250 endpoints.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
251
252 Ok(EndpointRegistry { endpoints })
253 }
254
255 pub fn from_file(path: &str) -> Result<Self> {
256 let content = std::fs::read_to_string(path)
257 .map_err(|e| anyhow!("Failed to read OpenAPI file {}: {}", path, e))?;
258 Self::from_openapi_json(&content)
259 }
260
261 pub fn has_endpoint(&self, method: &str, path: &str) -> bool {
262 self.endpoints
263 .iter()
264 .any(|ep| ep.method.eq_ignore_ascii_case(method) && ep.path == path)
265 }
266
267 #[allow(dead_code)]
268 pub fn get_by_tag(&self, tag: &str) -> Vec<&ApiEndpoint> {
269 self.endpoints
270 .iter()
271 .filter(|ep| ep.tags.contains(&tag.to_string()))
272 .collect()
273 }
274
275 #[allow(dead_code)]
276 pub fn get_by_path_prefix(&self, prefix: &str) -> Vec<&ApiEndpoint> {
277 self.endpoints
278 .iter()
279 .filter(|ep| ep.path.starts_with(prefix))
280 .collect()
281 }
282
283 #[allow(dead_code)]
284 pub fn search(&self, query: &str) -> Vec<&ApiEndpoint> {
285 let query_lower = query.to_lowercase();
286 self.endpoints
287 .iter()
288 .filter(|ep| {
289 ep.path.to_lowercase().contains(&query_lower)
290 || ep.method.to_lowercase().contains(&query_lower)
291 || ep
292 .summary
293 .as_ref()
294 .map(|s| s.to_lowercase().contains(&query_lower))
295 .unwrap_or(false)
296 || ep
297 .description
298 .as_ref()
299 .map(|s| s.to_lowercase().contains(&query_lower))
300 .unwrap_or(false)
301 })
302 .collect()
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn skips_path_item_parameters_key_as_operation() {
312 let json = r#"{
313 "openapi": "3.0.0",
314 "paths": {
315 "/api/foo/{id}": {
316 "parameters": [
317 {
318 "name": "id",
319 "in": "path",
320 "required": true,
321 "schema": { "type": "string" }
322 }
323 ],
324 "summary": "path summary",
325 "get": {
326 "summary": "get foo",
327 "responses": { "200": { "description": "ok" } }
328 }
329 }
330 }
331 }"#;
332
333 let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
334 assert_eq!(reg.endpoints.len(), 1);
335 let ep = ®.endpoints[0];
336 assert_eq!(ep.method, "GET");
337 assert_eq!(ep.path, "/api/foo/{id}");
338 assert_eq!(ep.path_params.len(), 1);
339 assert_eq!(ep.path_params[0].name, "id");
340 }
341
342 #[test]
343 fn operation_parameters_override_path_level_same_name() {
344 let json = r#"{
345 "openapi": "3.0.0",
346 "paths": {
347 "/x": {
348 "parameters": [
349 {
350 "name": "q",
351 "in": "query",
352 "required": false,
353 "schema": { "type": "string", "default": "base" }
354 }
355 ],
356 "get": {
357 "parameters": [
358 {
359 "name": "q",
360 "in": "query",
361 "required": true,
362 "schema": { "type": "string", "default": "op" }
363 }
364 ],
365 "responses": { "200": { "description": "ok" } }
366 }
367 }
368 }
369 }"#;
370
371 let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
372 assert_eq!(reg.endpoints[0].query_params.len(), 1);
373 assert_eq!(
374 reg.endpoints[0].query_params[0].default.as_deref(),
375 Some("op")
376 );
377 assert!(reg.endpoints[0].query_params[0].required);
378 }
379
380 #[test]
381 fn has_endpoint_matches_exact_path_and_case_insensitive_method() {
382 let json = r#"{
383 "openapi": "3.0.0",
384 "paths": {
385 "/api/devices": {
386 "get": { "responses": { "200": { "description": "ok" } } }
387 },
388 "/api/sync/devices/{device_id}/push-pull": {
389 "post": { "responses": { "200": { "description": "ok" } } }
390 }
391 }
392 }"#;
393
394 let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
395 assert!(reg.has_endpoint("GET", "/api/devices"));
396 assert!(reg.has_endpoint("post", "/api/sync/devices/{device_id}/push-pull"));
397 assert!(!reg.has_endpoint("POST", "/api/devices"));
398 assert!(!reg.has_endpoint("POST", "/api/sync/devices/1/push-pull"));
399 }
400
401 #[test]
402 fn is_openapi_operation_method_cases() {
403 assert!(is_openapi_operation_method("get"));
404 assert!(is_openapi_operation_method("GET"));
405 assert!(!is_openapi_operation_method("parameters"));
406 assert!(!is_openapi_operation_method("summary"));
407 }
408
409 #[test]
410 fn resolve_path_template_substitutes_and_encodes() {
411 let p = resolve_path_template("/api/roms/{id}/files", &[("id".into(), "42".into())])
412 .expect("ok");
413 assert_eq!(p, "/api/roms/42/files");
414 }
415
416 #[test]
417 fn resolve_path_template_errors_on_missing_placeholder() {
418 let e = resolve_path_template("/api/{x}", &[]).unwrap_err();
419 assert!(e.to_string().contains("unresolved"));
420 }
421}