1use console::Style;
2use reqwest::blocking::Client;
3use serde_json::Value;
4use std::time::Duration;
5
6pub fn validate_openapi_json(json: &Value) -> Result<SpecInfo, String> {
8 let version = json
9 .get("openapi")
10 .and_then(|v| v.as_str())
11 .ok_or("OpenAPI spec is malformed: missing `openapi` field")?;
12
13 if !version.starts_with("3.") {
14 return Err(format!(
15 "OpenAPI spec version `{version}` is not supported (expected 3.x)"
16 ));
17 }
18
19 let paths = json
20 .get("paths")
21 .and_then(|v| v.as_object())
22 .ok_or("OpenAPI spec is malformed: missing `paths` field")?;
23
24 if paths.is_empty() {
25 return Err("OpenAPI spec has no API paths defined".to_string());
26 }
27
28 let http_methods = [
29 "get", "post", "put", "patch", "delete", "head", "options", "trace",
30 ];
31 let mut operation_count = 0;
32 for (_path, methods) in paths {
33 if let Some(obj) = methods.as_object() {
34 for key in obj.keys() {
35 if http_methods.contains(&key.as_str()) {
36 operation_count += 1;
37 }
38 }
39 }
40 }
41
42 if operation_count == 0 {
43 return Err("OpenAPI spec has paths but no operations defined".to_string());
44 }
45
46 Ok(SpecInfo {
47 version: version.to_string(),
48 operation_count,
49 path_count: paths.len(),
50 })
51}
52
53#[derive(Debug)]
55pub struct SpecInfo {
56 pub version: String,
57 pub operation_count: usize,
58 pub path_count: usize,
59}
60
61fn find_first_endpoint(json: &Value) -> Option<(String, String)> {
63 let paths = json.get("paths")?.as_object()?;
64 let http_methods = [
65 "get", "post", "put", "patch", "delete", "head", "options", "trace",
66 ];
67
68 for (path, methods) in paths {
70 if let Some(obj) = methods.as_object() {
71 if obj.contains_key("get") {
72 return Some((path.clone(), "GET".to_string()));
73 }
74 }
75 }
76
77 for (path, methods) in paths {
79 if let Some(obj) = methods.as_object() {
80 for key in obj.keys() {
81 if http_methods.contains(&key.as_str()) {
82 return Some((path.clone(), key.to_uppercase()));
83 }
84 }
85 }
86 }
87
88 None
89}
90
91pub fn run(url: String, api_key: Option<String>, spec_path: String) {
93 let green = Style::new().green();
94 let red = Style::new().red();
95 let dim = Style::new().dim();
96
97 let base_url = url.trim_end_matches('/');
98 let spec_url = format!("{base_url}{spec_path}");
99
100 println!("Checking API at {base_url}...\n");
101
102 let client = Client::builder()
103 .timeout(Duration::from_secs(5))
104 .build()
105 .expect("Failed to create HTTP client");
106
107 match client.get(&spec_url).send() {
109 Ok(_) => {
110 println!(" {} Server is running", green.apply_to("\u{2713}"));
111 }
112 Err(_) => {
113 println!(" {} Server not reachable", red.apply_to("\u{2717}"));
114 println!(
115 " {} Start your server with: cargo run",
116 dim.apply_to("\u{2192}")
117 );
118 println!("\n Stopped \u{2014} fix the above issues and try again.");
119 return;
120 }
121 }
122
123 let spec_response = match client.get(&spec_url).send() {
125 Ok(resp) => resp,
126 Err(_) => {
127 println!(
128 " {} Could not fetch OpenAPI spec",
129 red.apply_to("\u{2717}")
130 );
131 println!("\n Stopped \u{2014} fix the above issues and try again.");
132 return;
133 }
134 };
135
136 let status = spec_response.status();
137 if status.as_u16() == 404 {
138 println!(
139 " {} OpenAPI spec not found at {spec_path}",
140 red.apply_to("\u{2717}")
141 );
142 println!(
143 " {} Did you register docs_routes()?",
144 dim.apply_to("\u{2192}")
145 );
146 println!("\n Stopped \u{2014} fix the above issues and try again.");
147 return;
148 }
149
150 if !status.is_success() {
151 println!(
152 " {} OpenAPI spec returned HTTP {status}",
153 red.apply_to("\u{2717}")
154 );
155 println!("\n Stopped \u{2014} fix the above issues and try again.");
156 return;
157 }
158
159 let spec_json: Value = match spec_response.json() {
160 Ok(v) => v,
161 Err(_) => {
162 println!(
163 " {} OpenAPI spec endpoint returned non-JSON response",
164 red.apply_to("\u{2717}")
165 );
166 println!("\n Stopped \u{2014} fix the above issues and try again.");
167 return;
168 }
169 };
170
171 println!(
172 " {} OpenAPI spec available at {spec_path}",
173 green.apply_to("\u{2713}")
174 );
175
176 let spec_info = match validate_openapi_json(&spec_json) {
178 Ok(info) => info,
179 Err(msg) => {
180 println!(" {} {msg}", red.apply_to("\u{2717}"));
181 println!("\n Stopped \u{2014} fix the above issues and try again.");
182 return;
183 }
184 };
185
186 println!(
187 " {} Valid OpenAPI {} spec \u{2014} {} operations across {} paths",
188 green.apply_to("\u{2713}"),
189 spec_info.version,
190 spec_info.operation_count,
191 spec_info.path_count,
192 );
193
194 if let Some(ref key) = api_key {
196 if let Some((endpoint_path, method)) = find_first_endpoint(&spec_json) {
197 let endpoint_url = format!("{base_url}{endpoint_path}");
198 let request = match method.as_str() {
199 "GET" => client.get(&endpoint_url),
200 "POST" => client.post(&endpoint_url),
201 "PUT" => client.put(&endpoint_url),
202 "PATCH" => client.patch(&endpoint_url),
203 "DELETE" => client.delete(&endpoint_url),
204 "HEAD" => client.head(&endpoint_url),
205 _ => client.get(&endpoint_url),
206 };
207
208 match request.header("X-API-Key", key).send() {
209 Ok(resp) if resp.status().as_u16() == 401 => {
210 println!(" {} API key rejected", red.apply_to("\u{2717}"));
211 println!(
212 " {} Check if the key is valid and not expired.",
213 dim.apply_to("\u{2192}")
214 );
215 println!("\n Stopped \u{2014} fix the above issues and try again.");
216 return;
217 }
218 Ok(_) => {
219 println!(
220 " {} API key authentication working",
221 green.apply_to("\u{2713}")
222 );
223 }
224 Err(e) => {
225 println!(" {} Could not test API key: {e}", red.apply_to("\u{2717}"));
226 println!("\n Stopped \u{2014} fix the above issues and try again.");
227 return;
228 }
229 }
230 } else {
231 println!(
232 " {} No API endpoints found to test authentication",
233 red.apply_to("\u{2717}")
234 );
235 println!("\n Stopped \u{2014} fix the above issues and try again.");
236 return;
237 }
238 } else {
239 println!(" {} API key authentication", dim.apply_to("-"));
240 println!(
241 " {} Skipped \u{2014} provide --api-key to test authentication",
242 dim.apply_to("\u{2192}")
243 );
244 }
245
246 println!();
248 println!(" Ready for MCP! Configure ferro-api-mcp:");
249 if let Some(ref key) = api_key {
250 println!(" ferro-api-mcp --spec-url {spec_url} --api-key {key}");
251 } else {
252 println!(" ferro-api-mcp --spec-url {spec_url} --api-key <your-key>");
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use serde_json::json;
260
261 #[test]
262 fn valid_spec_returns_correct_info() {
263 let spec = json!({
264 "openapi": "3.1.0",
265 "info": { "title": "Test", "version": "1.0" },
266 "paths": {
267 "/users": {
268 "get": { "summary": "List users" },
269 "post": { "summary": "Create user" }
270 },
271 "/users/{id}": {
272 "get": { "summary": "Get user" },
273 "put": { "summary": "Update user" },
274 "delete": { "summary": "Delete user" }
275 }
276 }
277 });
278
279 let info = validate_openapi_json(&spec).unwrap();
280 assert_eq!(info.version, "3.1.0");
281 assert_eq!(info.path_count, 2);
282 assert_eq!(info.operation_count, 5);
283 }
284
285 #[test]
286 fn missing_openapi_field_errors() {
287 let spec = json!({
288 "info": { "title": "Test" },
289 "paths": { "/x": { "get": {} } }
290 });
291
292 let err = validate_openapi_json(&spec).unwrap_err();
293 assert!(err.contains("missing `openapi` field"), "got: {err}");
294 }
295
296 #[test]
297 fn missing_paths_field_errors() {
298 let spec = json!({
299 "openapi": "3.1.0",
300 "info": { "title": "Test" }
301 });
302
303 let err = validate_openapi_json(&spec).unwrap_err();
304 assert!(err.contains("missing `paths` field"), "got: {err}");
305 }
306
307 #[test]
308 fn empty_paths_errors() {
309 let spec = json!({
310 "openapi": "3.1.0",
311 "info": { "title": "Test" },
312 "paths": {}
313 });
314
315 let err = validate_openapi_json(&spec).unwrap_err();
316 assert!(err.contains("no API paths defined"), "got: {err}");
317 }
318
319 #[test]
320 fn non_3x_version_errors() {
321 let spec = json!({
322 "openapi": "2.0",
323 "info": { "title": "Test" },
324 "paths": { "/x": { "get": {} } }
325 });
326
327 let err = validate_openapi_json(&spec).unwrap_err();
328 assert!(err.contains("not supported"), "got: {err}");
329 }
330
331 #[test]
332 fn multiple_methods_per_path_counted_correctly() {
333 let spec = json!({
334 "openapi": "3.0.3",
335 "info": { "title": "Test", "version": "1.0" },
336 "paths": {
337 "/a": { "get": {}, "post": {}, "put": {} },
338 "/b": { "delete": {} },
339 "/c": { "get": {}, "patch": {} }
340 }
341 });
342
343 let info = validate_openapi_json(&spec).unwrap();
344 assert_eq!(info.version, "3.0.3");
345 assert_eq!(info.path_count, 3);
346 assert_eq!(info.operation_count, 6);
347 }
348
349 #[test]
350 fn paths_with_no_operations_errors() {
351 let spec = json!({
352 "openapi": "3.1.0",
353 "info": { "title": "Test" },
354 "paths": {
355 "/a": { "parameters": [] },
356 "/b": { "summary": "just metadata" }
357 }
358 });
359
360 let err = validate_openapi_json(&spec).unwrap_err();
361 assert!(err.contains("no operations defined"), "got: {err}");
362 }
363}