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}