1use anyhow::{anyhow, Result};
6use serde_json::Value;
7
8const OPENAPI_OPERATION_METHODS: &[&str] = &[
10 "get", "post", "put", "delete", "patch", "options", "head", "trace",
11];
12
13pub fn is_openapi_operation_method(key: &str) -> bool {
15 OPENAPI_OPERATION_METHODS
16 .iter()
17 .any(|m| m.eq_ignore_ascii_case(key))
18}
19
20#[derive(Debug, Clone)]
21pub struct ApiParameter {
22 pub name: String,
23 pub param_type: String,
24 pub required: bool,
25 pub default: Option<String>,
26 #[allow(dead_code)]
27 pub description: Option<String>,
28}
29
30#[derive(Debug, Clone)]
31pub struct ApiEndpoint {
32 pub method: String,
33 pub path: String,
34 pub summary: Option<String>,
35 #[allow(dead_code)]
36 pub description: Option<String>,
37 pub query_params: Vec<ApiParameter>,
38 pub path_params: Vec<ApiParameter>,
39 pub has_body: bool,
40 pub tags: Vec<String>,
41}
42
43#[derive(Debug, Clone, Default)]
44pub struct EndpointRegistry {
45 pub endpoints: Vec<ApiEndpoint>,
46}
47
48fn parse_parameters_array(params: &[Value]) -> (Vec<ApiParameter>, Vec<ApiParameter>) {
50 let mut query_params = Vec::new();
51 let mut path_params = Vec::new();
52
53 for param in params {
54 let Some(param_obj) = param.as_object() else {
55 continue;
56 };
57 if param_obj.contains_key("$ref") {
59 continue;
60 }
61
62 let name = param_obj
63 .get("name")
64 .and_then(|v| v.as_str())
65 .map(|s| s.to_string())
66 .unwrap_or_default();
67
68 let param_in = param_obj
69 .get("in")
70 .and_then(|v| v.as_str())
71 .unwrap_or("query");
72
73 let required = param_obj
74 .get("required")
75 .and_then(|v| v.as_bool())
76 .unwrap_or(false);
77
78 let schema = param_obj.get("schema");
79 let param_type = schema
80 .and_then(|s| s.get("type"))
81 .and_then(|v| v.as_str())
82 .map(|s| s.to_string())
83 .unwrap_or_else(|| "string".to_string());
84
85 let default = schema.and_then(|s| s.get("default")).and_then(|v| {
86 if v.is_string() {
87 v.as_str().map(|s| s.to_string())
88 } else {
89 Some(v.to_string())
90 }
91 });
92
93 let description = param_obj
94 .get("description")
95 .and_then(|v| v.as_str())
96 .map(|s| s.to_string());
97
98 let api_param = ApiParameter {
99 name,
100 param_type,
101 required,
102 default,
103 description,
104 };
105
106 match param_in {
107 "query" => query_params.push(api_param),
108 "path" => path_params.push(api_param),
109 _ => {}
110 }
111 }
112
113 (query_params, path_params)
114}
115
116fn merge_parameter_lists(
118 path_query: Vec<ApiParameter>,
119 path_path: Vec<ApiParameter>,
120 op_query: Vec<ApiParameter>,
121 op_path: Vec<ApiParameter>,
122) -> (Vec<ApiParameter>, Vec<ApiParameter>) {
123 let mut query = path_query;
124 let mut path = path_path;
125
126 for p in op_query {
127 query.retain(|x| x.name != p.name);
128 query.push(p);
129 }
130 for p in op_path {
131 path.retain(|x| x.name != p.name);
132 path.push(p);
133 }
134
135 (query, path)
136}
137
138impl EndpointRegistry {
139 pub fn from_openapi_json(json_str: &str) -> Result<Self> {
140 let value: Value = serde_json::from_str(json_str)
141 .map_err(|e| anyhow!("Failed to parse OpenAPI JSON: {}", e))?;
142
143 let paths = value
144 .get("paths")
145 .and_then(|v| v.as_object())
146 .ok_or_else(|| anyhow!("OpenAPI JSON missing 'paths' object"))?;
147
148 let mut endpoints = Vec::new();
149
150 for (path, path_item) in paths {
151 let path_item = path_item
152 .as_object()
153 .ok_or_else(|| anyhow!("Invalid path definition for {}", path))?;
154
155 let path_level = path_item
156 .get("parameters")
157 .and_then(|v| v.as_array())
158 .map(|a| a.as_slice())
159 .unwrap_or(&[]);
160 let (path_q, path_p) = parse_parameters_array(path_level);
161
162 for (method_key, operation) in path_item {
163 if !is_openapi_operation_method(method_key) {
164 continue;
165 }
166
167 let operation = operation
168 .as_object()
169 .ok_or_else(|| anyhow!("Invalid operation for {} {}", method_key, path))?;
170
171 let summary = operation
172 .get("summary")
173 .and_then(|v| v.as_str())
174 .map(|s| s.to_string());
175 let description = operation
176 .get("description")
177 .and_then(|v| v.as_str())
178 .map(|s| s.to_string());
179
180 let tags = operation
181 .get("tags")
182 .and_then(|v| v.as_array())
183 .map(|arr| {
184 arr.iter()
185 .filter_map(|v| v.as_str())
186 .map(|s| s.to_string())
187 .collect()
188 })
189 .unwrap_or_default();
190
191 let op_level = operation
192 .get("parameters")
193 .and_then(|v| v.as_array())
194 .map(|a| a.as_slice())
195 .unwrap_or(&[]);
196 let (op_q, op_p) = parse_parameters_array(op_level);
197 let (query_params, path_params) =
198 merge_parameter_lists(path_q.clone(), path_p.clone(), op_q, op_p);
199
200 let has_body = operation.get("requestBody").is_some();
201
202 endpoints.push(ApiEndpoint {
203 method: method_key.to_uppercase(),
204 path: path.clone(),
205 summary,
206 description,
207 query_params,
208 path_params,
209 has_body,
210 tags,
211 });
212 }
213 }
214
215 endpoints.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
216
217 Ok(EndpointRegistry { endpoints })
218 }
219
220 pub fn from_file(path: &str) -> Result<Self> {
221 let content = std::fs::read_to_string(path)
222 .map_err(|e| anyhow!("Failed to read OpenAPI file {}: {}", path, e))?;
223 Self::from_openapi_json(&content)
224 }
225
226 pub fn has_endpoint(&self, method: &str, path: &str) -> bool {
227 self.endpoints
228 .iter()
229 .any(|ep| ep.method.eq_ignore_ascii_case(method) && ep.path == path)
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn skips_path_item_parameters_key_as_operation() {
239 let json = r#"{
240 "openapi": "3.0.0",
241 "paths": {
242 "/api/foo/{id}": {
243 "parameters": [
244 {
245 "name": "id",
246 "in": "path",
247 "required": true,
248 "schema": { "type": "string" }
249 }
250 ],
251 "summary": "path summary",
252 "get": {
253 "summary": "get foo",
254 "responses": { "200": { "description": "ok" } }
255 }
256 }
257 }
258 }"#;
259
260 let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
261 assert_eq!(reg.endpoints.len(), 1);
262 let ep = ®.endpoints[0];
263 assert_eq!(ep.method, "GET");
264 assert_eq!(ep.path, "/api/foo/{id}");
265 assert_eq!(ep.path_params.len(), 1);
266 assert_eq!(ep.path_params[0].name, "id");
267 }
268
269 #[test]
270 fn operation_parameters_override_path_level_same_name() {
271 let json = r#"{
272 "openapi": "3.0.0",
273 "paths": {
274 "/x": {
275 "parameters": [
276 {
277 "name": "q",
278 "in": "query",
279 "required": false,
280 "schema": { "type": "string", "default": "base" }
281 }
282 ],
283 "get": {
284 "parameters": [
285 {
286 "name": "q",
287 "in": "query",
288 "required": true,
289 "schema": { "type": "string", "default": "op" }
290 }
291 ],
292 "responses": { "200": { "description": "ok" } }
293 }
294 }
295 }
296 }"#;
297
298 let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
299 assert_eq!(reg.endpoints[0].query_params.len(), 1);
300 assert_eq!(
301 reg.endpoints[0].query_params[0].default.as_deref(),
302 Some("op")
303 );
304 assert!(reg.endpoints[0].query_params[0].required);
305 }
306
307 #[test]
308 fn has_endpoint_matches_exact_path_and_case_insensitive_method() {
309 let json = r#"{
310 "openapi": "3.0.0",
311 "paths": {
312 "/api/devices": {
313 "get": { "responses": { "200": { "description": "ok" } } }
314 },
315 "/api/sync/devices/{device_id}/push-pull": {
316 "post": { "responses": { "200": { "description": "ok" } } }
317 }
318 }
319 }"#;
320
321 let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
322 assert!(reg.has_endpoint("GET", "/api/devices"));
323 assert!(reg.has_endpoint("post", "/api/sync/devices/{device_id}/push-pull"));
324 assert!(!reg.has_endpoint("POST", "/api/devices"));
325 assert!(!reg.has_endpoint("POST", "/api/sync/devices/1/push-pull"));
326 }
327
328 #[test]
329 fn is_openapi_operation_method_cases() {
330 assert!(is_openapi_operation_method("get"));
331 assert!(is_openapi_operation_method("GET"));
332 assert!(!is_openapi_operation_method("parameters"));
333 assert!(!is_openapi_operation_method("summary"));
334 }
335}