openapi_snapshot/
lib.rs

1use clap::{Args, Parser, Subcommand};
2use reqwest::blocking::Client;
3use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
4use serde_json::Value;
5use std::fs::{self, OpenOptions};
6use std::io::{self, IsTerminal, Write};
7use std::path::{Path, PathBuf};
8use std::thread;
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11const DEFAULT_URL: &str = "http://localhost:3000/api-docs/openapi.json";
12const DEFAULT_OUT: &str = "openapi/backend_openapi.min.json";
13const DEFAULT_REDUCE: &str = "paths,components";
14const DEFAULT_INTERVAL_MS: u64 = 2_000;
15
16#[derive(Parser, Debug)]
17#[command(
18    name = "openapi-snapshot",
19    version,
20    about = "Fetch and save a minified OpenAPI JSON snapshot.",
21    after_help = "Examples:\n  openapi-snapshot\n  openapi-snapshot watch\n  openapi-snapshot --url http://localhost:3000/api-docs/openapi.json --out openapi/backend_openapi.min.json"
22)]
23pub struct Cli {
24    #[command(subcommand)]
25    pub command: Option<Command>,
26    #[command(flatten)]
27    pub common: CommonArgs,
28}
29
30#[derive(Subcommand, Debug)]
31pub enum Command {
32    Watch(WatchArgs),
33}
34
35#[derive(Args, Debug, Clone)]
36pub struct CommonArgs {
37    #[arg(long)]
38    pub url: Option<String>,
39    #[arg(long)]
40    pub out: Option<PathBuf>,
41    #[arg(long)]
42    pub reduce: Option<String>,
43    #[arg(
44        long,
45        default_value_t = true,
46        value_parser = clap::builder::BoolishValueParser::new()
47    )]
48    pub minify: bool,
49    #[arg(long, default_value_t = 10_000)]
50    pub timeout_ms: u64,
51    #[arg(long)]
52    pub header: Vec<String>,
53    #[arg(long)]
54    pub stdout: bool,
55}
56
57#[derive(Args, Debug, Clone)]
58pub struct WatchArgs {
59    #[arg(long, default_value_t = DEFAULT_INTERVAL_MS)]
60    pub interval_ms: u64,
61}
62
63#[derive(Debug, Clone, Copy)]
64pub enum Mode {
65    Snapshot,
66    Watch { interval_ms: u64 },
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ReduceKey {
71    Paths,
72    Components,
73}
74
75impl ReduceKey {
76    pub fn as_str(self) -> &'static str {
77        match self {
78            ReduceKey::Paths => "paths",
79            ReduceKey::Components => "components",
80        }
81    }
82}
83
84#[derive(Debug)]
85pub struct Config {
86    pub url: String,
87    pub url_from_default: bool,
88    pub out: Option<PathBuf>,
89    pub reduce: Vec<ReduceKey>,
90    pub minify: bool,
91    pub timeout_ms: u64,
92    pub headers: Vec<String>,
93    pub stdout: bool,
94}
95
96impl Config {
97    pub fn from_cli(cli: Cli) -> Result<(Self, Mode), AppError> {
98        let mode = match cli.command {
99            Some(Command::Watch(args)) => Mode::Watch {
100                interval_ms: args.interval_ms,
101            },
102            None => Mode::Snapshot,
103        };
104
105        let reduce_value = match (&cli.common.reduce, mode) {
106            (Some(value), _) => Some(value.as_str()),
107            (None, Mode::Watch { .. }) => Some(DEFAULT_REDUCE),
108            _ => None,
109        };
110        let reduce = match reduce_value {
111            Some(value) => parse_reduce_list(value)?,
112            None => Vec::new(),
113        };
114
115        let url_from_default = cli.common.url.is_none();
116        let url = cli
117            .common
118            .url
119            .unwrap_or_else(|| DEFAULT_URL.to_string());
120        let out = if cli.common.stdout {
121            cli.common.out
122        } else {
123            Some(cli.common.out.unwrap_or_else(|| PathBuf::from(DEFAULT_OUT)))
124        };
125
126        Ok((
127            Self {
128                url,
129                url_from_default,
130                out,
131                reduce,
132                minify: cli.common.minify,
133                timeout_ms: cli.common.timeout_ms,
134                headers: cli.common.header,
135                stdout: cli.common.stdout,
136            },
137            mode,
138        ))
139    }
140}
141
142#[derive(Debug)]
143pub enum AppError {
144    Usage(String),
145    Network(String),
146    Json(String),
147    Reduce(String),
148    Io(String),
149}
150
151impl AppError {
152    pub fn exit_code(&self) -> i32 {
153        match self {
154            AppError::Usage(_) => 1,
155            AppError::Network(_) => 1,
156            AppError::Json(_) => 2,
157            AppError::Reduce(_) => 3,
158            AppError::Io(_) => 4,
159        }
160    }
161
162    pub fn is_url_related(&self) -> bool {
163        matches!(self, AppError::Network(_) | AppError::Json(_))
164    }
165}
166
167impl std::fmt::Display for AppError {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        match self {
170            AppError::Usage(msg)
171            | AppError::Network(msg)
172            | AppError::Json(msg)
173            | AppError::Reduce(msg)
174            | AppError::Io(msg) => write!(f, "{msg}"),
175        }
176    }
177}
178
179impl std::error::Error for AppError {}
180
181pub fn validate_config(config: &Config) -> Result<(), AppError> {
182    if !config.stdout && config.out.is_none() {
183        return Err(AppError::Usage(
184            "--out is required unless --stdout is set.".to_string(),
185        ));
186    }
187    Ok(())
188}
189
190pub fn build_output(config: &Config) -> Result<String, AppError> {
191    let body = fetch_openapi(config)?;
192    let mut json = parse_json(&body)?;
193    if !config.reduce.is_empty() {
194        json = reduce_openapi(json, &config.reduce)?;
195    }
196    serialize_json(&json, config.minify)
197}
198
199pub fn write_output(config: &Config, payload: &str) -> Result<(), AppError> {
200    if config.stdout {
201        println!("{payload}");
202        return Ok(());
203    }
204
205    let out_path = config
206        .out
207        .as_ref()
208        .ok_or_else(|| AppError::Usage("--out is required unless --stdout is set.".to_string()))?;
209    write_atomic(out_path, payload)
210}
211
212pub fn parse_reduce_list(value: &str) -> Result<Vec<ReduceKey>, AppError> {
213    if value.is_empty() {
214        return Err(AppError::Reduce("reduce list cannot be empty".to_string()));
215    }
216    let mut out = Vec::new();
217    for raw in value.split(',') {
218        let trimmed = raw.trim();
219        if trimmed.is_empty() {
220            continue;
221        }
222        if trimmed.to_lowercase() != trimmed {
223            return Err(AppError::Reduce(format!(
224                "reduce values must be lowercase: {trimmed}"
225            )));
226        }
227        match trimmed {
228            "paths" => push_unique(&mut out, ReduceKey::Paths),
229            "components" => push_unique(&mut out, ReduceKey::Components),
230            _ => {
231                return Err(AppError::Reduce(format!(
232                    "unsupported reduce value: {trimmed}"
233                )))
234            }
235        }
236    }
237    if out.is_empty() {
238        return Err(AppError::Reduce("reduce list cannot be empty".to_string()));
239    }
240    Ok(out)
241}
242
243fn push_unique(items: &mut Vec<ReduceKey>, key: ReduceKey) {
244    if !items.contains(&key) {
245        items.push(key);
246    }
247}
248
249fn fetch_openapi(config: &Config) -> Result<Vec<u8>, AppError> {
250    let client = Client::builder()
251        .timeout(Duration::from_millis(config.timeout_ms))
252        .build()
253        .map_err(|err| AppError::Network(format!("client error: {err}")))?;
254
255    let headers = build_headers(&config.headers)?;
256    let response = client
257        .get(&config.url)
258        .headers(headers)
259        .send()
260        .map_err(|err| AppError::Network(format!("request failed: {err}")))?;
261
262    let status = response.status();
263    if !status.is_success() {
264        return Err(AppError::Network(format!(
265            "unexpected status: {status}"
266        )));
267    }
268
269    response
270        .bytes()
271        .map(|bytes| bytes.to_vec())
272        .map_err(|err| AppError::Network(format!("failed to read response: {err}")))
273}
274
275fn build_headers(raw_headers: &[String]) -> Result<HeaderMap, AppError> {
276    let mut headers = HeaderMap::new();
277    for raw in raw_headers {
278        let (name, value) = parse_header(raw)?;
279        headers.insert(name, value);
280    }
281    Ok(headers)
282}
283
284fn parse_header(raw: &str) -> Result<(HeaderName, HeaderValue), AppError> {
285    let mut split = raw.splitn(2, ':');
286    let name = split
287        .next()
288        .map(str::trim)
289        .filter(|value| !value.is_empty())
290        .ok_or_else(|| AppError::Usage(format!("invalid header format: {raw}")))?;
291    let value = split
292        .next()
293        .map(str::trim)
294        .ok_or_else(|| AppError::Usage(format!("invalid header format: {raw}")))?;
295    let header_name = HeaderName::from_bytes(name.as_bytes())
296        .map_err(|_| AppError::Usage(format!("invalid header name: {name}")))?;
297    let header_value = HeaderValue::from_str(value)
298        .map_err(|_| AppError::Usage(format!("invalid header value for: {name}")))?;
299    Ok((header_name, header_value))
300}
301
302fn parse_json(bytes: &[u8]) -> Result<Value, AppError> {
303    serde_json::from_slice(bytes).map_err(|err| AppError::Json(format!("invalid JSON: {err}")))
304}
305
306fn reduce_openapi(value: Value, keys: &[ReduceKey]) -> Result<Value, AppError> {
307    let object = value.as_object().ok_or_else(|| {
308        AppError::Reduce("OpenAPI document must be a JSON object".to_string())
309    })?;
310    let mut reduced = serde_json::Map::new();
311    for key in keys {
312        let name = key.as_str();
313        let entry = object.get(name).ok_or_else(|| {
314            AppError::Reduce(format!("missing top-level key: {name}"))
315        })?;
316        reduced.insert(name.to_string(), entry.clone());
317    }
318    Ok(Value::Object(reduced))
319}
320
321fn serialize_json(value: &Value, minify: bool) -> Result<String, AppError> {
322    if minify {
323        serde_json::to_string(value).map_err(|err| AppError::Json(format!("json error: {err}")))
324    } else {
325        serde_json::to_string_pretty(value)
326            .map_err(|err| AppError::Json(format!("json error: {err}")))
327    }
328}
329
330fn write_atomic(path: &Path, contents: &str) -> Result<(), AppError> {
331    let parent = path
332        .parent()
333        .ok_or_else(|| AppError::Io("output path has no parent directory".to_string()))?;
334    if let Err(err) = fs::create_dir_all(parent) {
335        return Err(AppError::Io(format!(
336            "failed to create output directory: {err}"
337        )));
338    }
339
340    let timestamp = SystemTime::now()
341        .duration_since(UNIX_EPOCH)
342        .unwrap_or_default()
343        .as_millis();
344    let temp_name = format!(
345        ".{}.{}.tmp",
346        path.file_name()
347            .and_then(|name| name.to_str())
348            .unwrap_or("openapi_snapshot"),
349        timestamp
350    );
351    let temp_path = parent.join(temp_name);
352
353    let mut file = OpenOptions::new()
354        .create_new(true)
355        .write(true)
356        .open(&temp_path)
357        .map_err(|err| AppError::Io(format!("failed to create temp file: {err}")))?;
358
359    if let Err(err) = file.write_all(contents.as_bytes()) {
360        let _ = fs::remove_file(&temp_path);
361        return Err(AppError::Io(format!("failed to write temp file: {err}")));
362    }
363
364    if let Err(err) = file.sync_all() {
365        let _ = fs::remove_file(&temp_path);
366        return Err(AppError::Io(format!("failed to flush temp file: {err}")));
367    }
368
369    if let Err(err) = fs::rename(&temp_path, path) {
370        let _ = fs::remove_file(&temp_path);
371        return Err(AppError::Io(format!("failed to move temp file: {err}")));
372    }
373
374    Ok(())
375}
376
377pub fn run_watch(config: &mut Config, interval_ms: u64) -> Result<(), AppError> {
378    let mut prompted = false;
379    loop {
380        match build_output(config) {
381            Ok(payload) => {
382                if let Err(err) = write_output(config, &payload) {
383                    eprintln!("{err}");
384                }
385            }
386            Err(err) => {
387                if !prompted && config.url_from_default && err.is_url_related() {
388                    if let Some(new_url) = prompt_for_url(&config.url)? {
389                        config.url = new_url;
390                        config.url_from_default = false;
391                        prompted = true;
392                        continue;
393                    }
394                    prompted = true;
395                }
396                eprintln!("{err}");
397            }
398        }
399        thread::sleep(Duration::from_millis(interval_ms.max(250)));
400    }
401}
402
403pub fn maybe_prompt_for_url(config: &mut Config, err: &AppError) -> Result<bool, AppError> {
404    if !config.url_from_default || !err.is_url_related() {
405        return Ok(false);
406    }
407    if let Some(new_url) = prompt_for_url(&config.url)? {
408        config.url = new_url;
409        config.url_from_default = false;
410        return Ok(true);
411    }
412    Ok(false)
413}
414
415fn prompt_for_url(default_url: &str) -> Result<Option<String>, AppError> {
416    if !io::stdin().is_terminal() {
417        return Ok(None);
418    }
419
420    let mut input = String::new();
421    loop {
422        eprint!("OpenAPI URL (default: {default_url}) - enter port or URL: ");
423        io::stdout()
424            .flush()
425            .map_err(|err| AppError::Io(format!("failed to flush prompt: {err}")))?;
426        input.clear();
427        io::stdin()
428            .read_line(&mut input)
429            .map_err(|err| AppError::Io(format!("failed to read input: {err}")))?;
430        let trimmed = input.trim();
431        if trimmed.is_empty() {
432            return Ok(None);
433        }
434        if let Some(url) = normalize_user_url(trimmed) {
435            return Ok(Some(url));
436        }
437        eprintln!("Invalid input. Enter a port (e.g., 3000) or full URL.");
438    }
439}
440
441fn normalize_user_url(input: &str) -> Option<String> {
442    let trimmed = input.trim();
443    if trimmed.is_empty() {
444        return None;
445    }
446    if trimmed.chars().all(|c| c.is_ascii_digit()) {
447        return Some(format!(
448            "http://localhost:{trimmed}/api-docs/openapi.json"
449        ));
450    }
451    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
452        return Some(trimmed.to_string());
453    }
454    if trimmed.contains(':') {
455        return Some(format!("http://{trimmed}/api-docs/openapi.json"));
456    }
457    None
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn parse_reduce_list_accepts_paths_components() {
466        let keys = parse_reduce_list("paths,components").unwrap();
467        assert_eq!(keys, vec![ReduceKey::Paths, ReduceKey::Components]);
468    }
469
470    #[test]
471    fn parse_reduce_list_rejects_mixed_case() {
472        let err = parse_reduce_list("Paths").unwrap_err();
473        assert!(matches!(err, AppError::Reduce(_)));
474    }
475
476    #[test]
477    fn reduce_openapi_keeps_only_requested_keys() {
478        let input = serde_json::json!({
479            "paths": {"x": 1},
480            "components": {"y": 2},
481            "extra": {"z": 3}
482        });
483        let output = reduce_openapi(input, &[ReduceKey::Components]).unwrap();
484        assert!(output.get("paths").is_none());
485        assert!(output.get("components").is_some());
486        assert!(output.get("extra").is_none());
487    }
488
489    #[test]
490    fn reduce_openapi_missing_key_is_error() {
491        let input = serde_json::json!({"paths": {"x": 1}});
492        let err = reduce_openapi(input, &[ReduceKey::Components]).unwrap_err();
493        assert!(matches!(err, AppError::Reduce(_)));
494    }
495
496    #[test]
497    fn defaults_apply_for_watch_mode() {
498        let cli = Cli {
499            command: Some(Command::Watch(WatchArgs { interval_ms: 500 })),
500            common: CommonArgs {
501                url: None,
502                out: None,
503                reduce: None,
504                minify: true,
505                timeout_ms: 10_000,
506                header: Vec::new(),
507                stdout: false,
508            },
509        };
510        let (config, mode) = Config::from_cli(cli).unwrap();
511        assert_eq!(config.url, DEFAULT_URL);
512        assert!(config.url_from_default);
513        assert_eq!(config.out.unwrap(), PathBuf::from(DEFAULT_OUT));
514        assert_eq!(config.reduce, vec![ReduceKey::Paths, ReduceKey::Components]);
515        assert!(matches!(mode, Mode::Watch { .. }));
516    }
517
518    #[test]
519    fn normalize_user_url_accepts_port() {
520        let url = normalize_user_url("3001").unwrap();
521        assert_eq!(url, "http://localhost:3001/api-docs/openapi.json");
522    }
523
524    #[test]
525    fn normalize_user_url_accepts_full_url() {
526        let url = normalize_user_url("https://example.com/openapi.json").unwrap();
527        assert_eq!(url, "https://example.com/openapi.json");
528    }
529
530    #[test]
531    fn normalize_user_url_accepts_host_port() {
532        let url = normalize_user_url("localhost:4000").unwrap();
533        assert_eq!(url, "http://localhost:4000/api-docs/openapi.json");
534    }
535
536    #[test]
537    fn normalize_user_url_rejects_invalid() {
538        assert!(normalize_user_url("not a url").is_none());
539    }
540}