use anyhow::{Context, Result, anyhow, bail};
use clap::{Parser, Subcommand, ValueEnum};
use roas::loader::Loader;
use roas::validation::Options;
use roas_file_fetcher::FileFetcher;
use roas_http_fetcher::HttpFetcher;
use std::fs;
use std::path::PathBuf;
mod versioned;
use versioned::{SpecVersion, parse_value, path_looks_like_yaml};
#[derive(Parser)]
#[command(name = "roas", about, version, propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Validate(ValidateArgs),
Convert(ConvertArgs),
}
#[derive(clap::Args)]
struct ValidateArgs {
file: PathBuf,
#[arg(long, value_enum)]
from: Option<SpecVersion>,
#[arg(long, value_enum)]
load: Vec<LoaderKind>,
#[arg(long, value_enum)]
ignore: Vec<Options>,
}
#[derive(clap::Args)]
struct ConvertArgs {
file: PathBuf,
#[arg(long, value_enum)]
to: SpecVersion,
#[arg(long, value_enum)]
from: Option<SpecVersion>,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
enum LoaderKind {
File,
Http,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Validate(args) => run_validate(args),
Command::Convert(args) => run_convert(args),
}
}
fn read_and_parse(path: &std::path::Path) -> Result<serde_json::Value> {
let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
parse_value(&raw, path_looks_like_yaml(path))
}
fn build_loader(kinds: &[LoaderKind]) -> Option<Loader> {
if kinds.is_empty() {
return None;
}
let mut loader = Loader::new();
for kind in kinds {
match kind {
LoaderKind::File => {
loader.register_fetcher("file://", FileFetcher::new());
}
LoaderKind::Http => {
let fetcher = HttpFetcher::new();
loader.register_fetcher("http://", fetcher.clone());
loader.register_fetcher("https://", fetcher);
}
}
}
Some(loader)
}
fn run_validate(args: ValidateArgs) -> Result<()> {
let value = read_and_parse(&args.file)?;
let detected = versioned::detect_or_use(args.from, value)?;
let mut loader = build_loader(&args.load);
let mut options = enumset::EnumSet::<Options>::new();
for ignore in &args.ignore {
options |= *ignore;
}
match detected.validate(options, loader.as_mut()) {
Ok(()) => {
eprintln!("{}: valid {}", args.file.display(), detected.label());
Ok(())
}
Err(err) => {
for e in &err.errors {
eprintln!("- {e}");
}
Err(anyhow!(
"{}: validation failed ({} error{})",
args.file.display(),
err.errors.len(),
if err.errors.len() == 1 { "" } else { "s" }
))
}
}
}
fn run_convert(args: ConvertArgs) -> Result<()> {
let value = read_and_parse(&args.file)?;
let detected = versioned::detect_or_use(args.from, value)?;
let target = args.to;
if (detected.version() as u8) > (target as u8) {
bail!(
"downconversion is not supported: input is {}, target is {}",
detected.label(),
target.label(),
);
}
let converted = detected.convert_to(target)?;
let json = serde_json::to_string_pretty(&converted).context("serializing converted spec")?;
println!("{json}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::io::Write;
#[test]
fn build_loader_returns_none_for_empty_kinds() {
assert!(build_loader(&[]).is_none());
}
#[test]
fn build_loader_returns_some_for_file_kind() {
assert!(build_loader(&[LoaderKind::File]).is_some());
}
#[test]
fn build_loader_returns_some_for_http_kind() {
assert!(build_loader(&[LoaderKind::Http]).is_some());
}
#[test]
fn build_loader_returns_some_for_combined_kinds() {
assert!(build_loader(&[LoaderKind::File, LoaderKind::Http]).is_some());
}
#[test]
fn spec_version_discriminants_order_by_version() {
assert!((SpecVersion::V2 as u8) < (SpecVersion::V3_0 as u8));
assert!((SpecVersion::V3_0 as u8) < (SpecVersion::V3_1 as u8));
assert!((SpecVersion::V3_1 as u8) < (SpecVersion::V3_2 as u8));
}
#[test]
fn cli_parses_minimal_validate_invocation() {
let cli = Cli::try_parse_from(["roas", "validate", "spec.json"]).expect("validate parse");
match cli.command {
Command::Validate(args) => {
assert_eq!(args.file.to_string_lossy(), "spec.json");
assert!(args.from.is_none());
assert!(args.load.is_empty());
assert!(args.ignore.is_empty());
}
Command::Convert(_) => panic!("expected Validate"),
}
}
#[test]
fn cli_parses_ignore_flag_into_options_variants() {
let cli = Cli::try_parse_from([
"roas",
"validate",
"--ignore",
"missing-tags",
"--ignore",
"unused-server-variables",
"--ignore",
"empty-info-title",
"spec.json",
])
.expect("validate parse");
match cli.command {
Command::Validate(args) => {
assert_eq!(
args.ignore,
vec![
Options::IgnoreMissingTags,
Options::IgnoreUnusedServerVariables,
Options::IgnoreEmptyInfoTitle,
]
);
}
Command::Convert(_) => panic!("expected Validate"),
}
}
#[test]
fn cli_rejects_unknown_ignore_value() {
let res =
Cli::try_parse_from(["roas", "validate", "--ignore", "no-such-check", "spec.json"]);
assert!(res.is_err(), "unknown --ignore value must error");
}
#[test]
fn cli_parses_repeated_load_flag_into_vec() {
let cli = Cli::try_parse_from([
"roas",
"validate",
"--load",
"file",
"--load",
"http",
"spec.json",
])
.expect("validate parse");
match cli.command {
Command::Validate(args) => {
assert_eq!(args.load.len(), 2);
assert!(matches!(args.load[0], LoaderKind::File));
assert!(matches!(args.load[1], LoaderKind::Http));
}
Command::Convert(_) => panic!("expected Validate"),
}
}
#[test]
fn cli_parses_convert_with_explicit_from() {
let cli = Cli::try_parse_from([
"roas",
"convert",
"--from",
"v2",
"--to",
"v3_2",
"spec.json",
])
.expect("convert parse");
match cli.command {
Command::Convert(args) => {
assert_eq!(args.from, Some(SpecVersion::V2));
assert_eq!(args.to, SpecVersion::V3_2);
}
Command::Validate(_) => panic!("expected Convert"),
}
}
#[test]
fn cli_rejects_convert_without_to() {
let res = Cli::try_parse_from(["roas", "convert", "spec.json"]);
assert!(res.is_err(), "convert without --to must error");
}
fn temp_path(suffix: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"roas-cli-test-{}-{}-{suffix}",
std::process::id(),
n,
))
}
struct TempFile(std::path::PathBuf);
impl TempFile {
fn write(suffix: &str, body: &[u8]) -> Self {
let path = temp_path(suffix);
let mut f = std::fs::File::create(&path).expect("create temp file");
f.write_all(body).expect("write temp file");
Self(path)
}
}
impl Drop for TempFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[test]
fn read_and_parse_json_file_returns_parsed_value() {
let f = TempFile::write("ok.json", br#"{"hello":"world"}"#);
let v = read_and_parse(&f.0).expect("parse ok");
assert_eq!(v, serde_json::json!({"hello": "world"}));
}
#[test]
fn read_and_parse_yaml_file_routes_through_yaml_parser() {
let f = TempFile::write("ok.yaml", b"name: pet\ncount: 3\n");
let v = read_and_parse(&f.0).expect("parse ok");
assert_eq!(v, serde_json::json!({"name": "pet", "count": 3}));
}
#[test]
fn read_and_parse_missing_file_errors_with_reading_context() {
let p = temp_path("missing.json");
let err = read_and_parse(&p).expect_err("missing file must error");
assert!(
err.to_string().contains("reading"),
"expected `reading` context, got: {err}",
);
}
#[test]
fn read_and_parse_invalid_json_surfaces_parser_error() {
let f = TempFile::write("bad.json", b"@@@ not json");
let err = read_and_parse(&f.0).expect_err("invalid JSON must error");
assert!(
err.to_string().contains("parsing JSON"),
"expected `parsing JSON` context, got: {err}",
);
}
#[test]
fn read_and_parse_invalid_yaml_surfaces_parser_error() {
let f = TempFile::write("bad.yaml", b"key:\n\tvalue: oops\n");
let err = read_and_parse(&f.0).expect_err("invalid YAML must error");
assert!(
err.to_string().contains("parsing YAML"),
"expected `parsing YAML` context, got: {err}",
);
}
const MINIMAL_V3_2: &[u8] =
br#"{"openapi":"3.2.0","info":{"title":"x","version":"1"},"paths":{}}"#;
const MINIMAL_V2: &[u8] = br#"{"swagger":"2.0","info":{"title":"x","version":"1"},"paths":{}}"#;
#[test]
fn run_validate_returns_ok_for_clean_spec() {
let f = TempFile::write("clean.json", MINIMAL_V3_2);
let args = ValidateArgs {
file: f.0.clone(),
from: None,
load: Vec::new(),
ignore: Vec::new(),
};
run_validate(args).expect("clean spec must validate");
}
#[test]
fn run_validate_returns_err_for_spec_with_unused_tag() {
let body = br#"{"openapi":"3.2.0","info":{"title":"x","version":"1"},"paths":{},"tags":[{"name":"unused"}]}"#;
let f = TempFile::write("unused-tag.json", body);
let args = ValidateArgs {
file: f.0.clone(),
from: None,
load: Vec::new(),
ignore: Vec::new(),
};
let err = run_validate(args).expect_err("unused tag must fail");
assert!(err.to_string().contains("validation failed"), "got: {err}",);
}
#[test]
fn run_validate_with_ignore_suppresses_validation_failure() {
let body = br#"{"openapi":"3.2.0","info":{"title":"x","version":"1"},"paths":{},"tags":[{"name":"unused"}]}"#;
let f = TempFile::write("ignored.json", body);
let args = ValidateArgs {
file: f.0.clone(),
from: None,
load: Vec::new(),
ignore: vec![Options::IgnoreUnusedTags],
};
run_validate(args).expect("--ignore unused-tags must suppress");
}
#[test]
fn run_validate_with_load_file_builds_loader() {
let f = TempFile::write("with-load.json", MINIMAL_V3_2);
let args = ValidateArgs {
file: f.0.clone(),
from: None,
load: vec![LoaderKind::File],
ignore: Vec::new(),
};
run_validate(args).expect("clean spec with file loader must validate");
}
#[test]
fn run_validate_missing_file_errors_with_reading_context() {
let args = ValidateArgs {
file: temp_path("missing.json"),
from: None,
load: Vec::new(),
ignore: Vec::new(),
};
let err = run_validate(args).expect_err("missing file must error");
assert!(
err.to_string().contains("reading"),
"expected `reading` context, got: {err}",
);
}
#[test]
fn run_convert_v2_to_v3_2_succeeds() {
let f = TempFile::write("v2.json", MINIMAL_V2);
let args = ConvertArgs {
file: f.0.clone(),
to: SpecVersion::V3_2,
from: None,
};
run_convert(args).expect("v2 → v3.2 must succeed");
}
#[test]
fn run_convert_rejects_downconversion() {
let f = TempFile::write("v3.json", MINIMAL_V3_2);
let args = ConvertArgs {
file: f.0.clone(),
to: SpecVersion::V2,
from: None,
};
let err = run_convert(args).expect_err("downconversion must error");
assert!(
err.to_string().contains("downconversion is not supported"),
"got: {err}",
);
}
#[test]
fn run_convert_missing_file_errors_with_reading_context() {
let args = ConvertArgs {
file: temp_path("missing.json"),
to: SpecVersion::V3_2,
from: None,
};
let err = run_convert(args).expect_err("missing file must error");
assert!(
err.to_string().contains("reading"),
"expected `reading` context, got: {err}",
);
}
}