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)]
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 #[allow(dead_code)]
262 pub fn get_by_tag(&self, tag: &str) -> Vec<&ApiEndpoint> {
263 self.endpoints
264 .iter()
265 .filter(|ep| ep.tags.contains(&tag.to_string()))
266 .collect()
267 }
268
269 #[allow(dead_code)]
270 pub fn get_by_path_prefix(&self, prefix: &str) -> Vec<&ApiEndpoint> {
271 self.endpoints
272 .iter()
273 .filter(|ep| ep.path.starts_with(prefix))
274 .collect()
275 }
276
277 #[allow(dead_code)]
278 pub fn search(&self, query: &str) -> Vec<&ApiEndpoint> {
279 let query_lower = query.to_lowercase();
280 self.endpoints
281 .iter()
282 .filter(|ep| {
283 ep.path.to_lowercase().contains(&query_lower)
284 || ep.method.to_lowercase().contains(&query_lower)
285 || ep
286 .summary
287 .as_ref()
288 .map(|s| s.to_lowercase().contains(&query_lower))
289 .unwrap_or(false)
290 || ep
291 .description
292 .as_ref()
293 .map(|s| s.to_lowercase().contains(&query_lower))
294 .unwrap_or(false)
295 })
296 .collect()
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn skips_path_item_parameters_key_as_operation() {
306 let json = r#"{
307 "openapi": "3.0.0",
308 "paths": {
309 "/api/foo/{id}": {
310 "parameters": [
311 {
312 "name": "id",
313 "in": "path",
314 "required": true,
315 "schema": { "type": "string" }
316 }
317 ],
318 "summary": "path summary",
319 "get": {
320 "summary": "get foo",
321 "responses": { "200": { "description": "ok" } }
322 }
323 }
324 }
325 }"#;
326
327 let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
328 assert_eq!(reg.endpoints.len(), 1);
329 let ep = ®.endpoints[0];
330 assert_eq!(ep.method, "GET");
331 assert_eq!(ep.path, "/api/foo/{id}");
332 assert_eq!(ep.path_params.len(), 1);
333 assert_eq!(ep.path_params[0].name, "id");
334 }
335
336 #[test]
337 fn operation_parameters_override_path_level_same_name() {
338 let json = r#"{
339 "openapi": "3.0.0",
340 "paths": {
341 "/x": {
342 "parameters": [
343 {
344 "name": "q",
345 "in": "query",
346 "required": false,
347 "schema": { "type": "string", "default": "base" }
348 }
349 ],
350 "get": {
351 "parameters": [
352 {
353 "name": "q",
354 "in": "query",
355 "required": true,
356 "schema": { "type": "string", "default": "op" }
357 }
358 ],
359 "responses": { "200": { "description": "ok" } }
360 }
361 }
362 }
363 }"#;
364
365 let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
366 assert_eq!(reg.endpoints[0].query_params.len(), 1);
367 assert_eq!(
368 reg.endpoints[0].query_params[0].default.as_deref(),
369 Some("op")
370 );
371 assert!(reg.endpoints[0].query_params[0].required);
372 }
373
374 #[test]
375 fn is_openapi_operation_method_cases() {
376 assert!(is_openapi_operation_method("get"));
377 assert!(is_openapi_operation_method("GET"));
378 assert!(!is_openapi_operation_method("parameters"));
379 assert!(!is_openapi_operation_method("summary"));
380 }
381
382 #[test]
383 fn resolve_path_template_substitutes_and_encodes() {
384 let p = resolve_path_template("/api/roms/{id}/files", &[("id".into(), "42".into())])
385 .expect("ok");
386 assert_eq!(p, "/api/roms/42/files");
387 }
388
389 #[test]
390 fn resolve_path_template_errors_on_missing_placeholder() {
391 let e = resolve_path_template("/api/{x}", &[]).unwrap_err();
392 assert!(e.to_string().contains("unresolved"));
393 }
394}