Skip to main content

ferro_cli/commands/
api_check.rs

1use console::Style;
2use reqwest::blocking::Client;
3use serde_json::Value;
4use std::time::Duration;
5
6/// Validate OpenAPI spec structure and extract metadata.
7pub 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/// Metadata extracted from a valid OpenAPI spec.
54#[derive(Debug)]
55pub struct SpecInfo {
56    pub version: String,
57    pub operation_count: usize,
58    pub path_count: usize,
59}
60
61/// Find the first GET endpoint path in an OpenAPI spec, falling back to any method.
62fn 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    // Prefer GET endpoints
69    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    // Fall back to any method
78    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
91/// Check local API readiness for MCP integration.
92pub 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    // Check 1: Server connectivity
108    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    // Check 2: OpenAPI spec available
124    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    // Check 3: OpenAPI spec valid
177    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    // Check 4: API key authentication
195    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    // Summary
247    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}