use anyhow::Context;
use clap::{Parser, Subcommand, ValueEnum};
use std::collections::BTreeSet;
use std::io::Write;
use std::path::{Path, PathBuf};
const GENERATED_MARKER: &str = "This file was generated by reflectapi-cli";
const GENERATED_MANIFEST: &str = ".reflectapi-generated-files";
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[arg(short, long, action = clap::ArgAction::Count)]
debug: u8,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Codegen {
#[arg(short, long, value_name = "FILE")]
schema: Option<PathBuf>,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(short, long)]
language: Language,
#[arg(long, value_delimiter = ',')]
shared_modules: Option<Vec<String>>,
#[arg(short, long, value_delimiter = ',')]
include_tags: Vec<String>,
#[arg(short, long, value_delimiter = ',')]
exclude_tags: Vec<String>,
#[arg(short, long, default_value = "false")]
typecheck: bool,
#[arg(
short,
long,
default_value_t = true,
default_missing_value = "true",
num_args = 0..=1,
require_equals = true
)]
format: bool,
#[arg(short = 'I', long, default_value = "false")]
instrument: bool,
#[arg(long, default_value = "api_client")]
python_package_name: String,
#[arg(long, default_value = "true")]
python_async: bool,
#[arg(long, default_value = "false")]
python_sync: bool,
#[arg(long, default_value = "false")]
python_testing: bool,
},
#[command(subcommand)]
Doc(DocSubcommand),
}
#[derive(Subcommand)]
enum DocSubcommand {
Open {
#[arg(short, long, default_value = "8080")]
port: u16,
#[arg(default_value = "reflectapi.json")]
path: PathBuf,
},
}
#[derive(ValueEnum, Clone, Debug, PartialEq)]
enum Language {
Typescript,
Rust,
Python,
Openapi,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Doc(doc) => match doc {
DocSubcommand::Open { port, path } => {
let mut path = path.canonicalize()?;
if path.is_dir() {
path.push("reflectapi.json");
}
let schema: reflectapi::Schema = serde_json::from_reader(std::fs::File::open(
&path,
)?)
.context("Failed to parse schema file as JSON into reflectapi::Schema object")?;
let addr = format!("0.0.0.0:{port}");
eprintln!("Serving {} on http://{addr}", path.display());
let openapi = reflectapi::codegen::openapi::Spec::from(&schema);
rouille::start_server(addr, move |request| {
rouille::router!(request,
(GET) (/) => { rouille::Response::html(include_str!("../redoc.html")) },
(GET) (/openapi) => { rouille::Response::json(&openapi) },
_ => rouille::Response::empty_404()
)
})
}
},
Commands::Codegen {
schema,
output,
language,
shared_modules,
include_tags,
exclude_tags,
typecheck,
format,
instrument,
python_package_name,
python_async,
python_sync,
python_testing,
} => {
let include_tags = BTreeSet::from_iter(include_tags);
let exclude_tags = BTreeSet::from_iter(exclude_tags);
let schema_path = schema.unwrap_or(std::path::PathBuf::from("reflectapi.json"));
let schema_as_json = std::fs::read_to_string(schema_path.clone())
.context(format!("Failed to read schema file: {schema_path:?}"))?;
let schema: reflectapi::Schema = serde_json::from_str(&schema_as_json)
.context("Failed to parse schema file as JSON into reflectapi::Schema object")?;
let files: std::collections::BTreeMap<String, String> = match language {
Language::Typescript => reflectapi::codegen::typescript::generate(
schema,
reflectapi::codegen::typescript::Config::default()
.format(format)
.typecheck(typecheck)
.include_tags(include_tags)
.exclude_tags(exclude_tags),
)?,
Language::Rust => {
let content = reflectapi::codegen::rust::generate(
schema,
reflectapi::codegen::rust::Config::default()
.format(format)
.typecheck(typecheck)
.instrument(instrument)
.include_tags(include_tags)
.exclude_tags(exclude_tags)
.shared_modules(
shared_modules.unwrap_or_default().into_iter().collect(),
),
)?;
let mut files = std::collections::BTreeMap::new();
files.insert("generated.rs".to_string(), content);
files
}
Language::Python => {
let config = reflectapi::codegen::python::Config {
package_name: python_package_name,
generate_async: python_async,
generate_sync: python_sync,
generate_testing: python_testing,
format,
base_url: None,
};
reflectapi::codegen::python::generate_files(schema, &config)?
}
Language::Openapi => {
let content = reflectapi::codegen::openapi::generate(
&schema,
reflectapi::codegen::openapi::Config::default()
.include_tags(include_tags)
.exclude_tags(exclude_tags),
)?;
let mut files = std::collections::BTreeMap::new();
files.insert("openapi.json".to_string(), content);
files
}
};
let primary_filename = match language {
Language::Typescript => "generated.ts",
Language::Rust => "generated.rs",
Language::Python => "generated.py",
Language::Openapi => "openapi.json",
};
if output == Some(std::path::PathBuf::from("-")) {
if let Some(content) = files.get(primary_filename) {
println!("{content}");
} else if let Some(content) = files.values().next() {
println!("{content}");
}
return Ok(());
}
let output_path = output.unwrap_or_else(|| std::path::PathBuf::from("./"));
let primary_name_in_path = output_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let looks_like_file = files.contains_key(primary_name_in_path)
&& !output_path.is_dir()
&& !output_path.to_string_lossy().ends_with('/');
if !looks_like_file {
std::fs::create_dir_all(&output_path).context(format!(
"Failed to create output directory: {output_path:?}"
))?;
let expected_files = generated_file_set(files.keys())?;
cleanup_stale_generated_files(&output_path, &expected_files, &language)?;
for (filename, content) in &files {
write_file(
&output_path.join(generated_relative_path(filename)?),
content,
)?;
}
write_generated_manifest(&output_path, &expected_files)?;
} else {
let parent = parent_or_dot(&output_path);
std::fs::create_dir_all(&parent)
.context(format!("Failed to create output directory: {parent:?}"))?;
for (filename, content) in &files {
let dest = if filename == primary_name_in_path {
output_path.clone()
} else {
parent.join(generated_relative_path(filename)?)
};
write_file(&dest, content)?;
}
}
Ok(())
}
}
}
fn generated_file_set<'a>(
filenames: impl Iterator<Item = &'a String>,
) -> anyhow::Result<BTreeSet<PathBuf>> {
filenames
.map(|filename| generated_relative_path(filename).map(Path::to_path_buf))
.collect()
}
fn cleanup_stale_generated_files(
output_dir: &Path,
expected_files: &BTreeSet<PathBuf>,
language: &Language,
) -> anyhow::Result<()> {
let manifest_path = output_dir.join(GENERATED_MANIFEST);
let stale_candidates = if manifest_path.is_file() {
read_generated_manifest(&manifest_path)?
} else {
let mut candidates = BTreeSet::new();
collect_legacy_generated_files(output_dir, output_dir, language, &mut candidates)?;
candidates
};
for relative_path in stale_candidates {
if expected_files.contains(&relative_path) {
continue;
}
let path = output_dir.join(&relative_path);
if path.is_file() && is_generated_file(&path, language) {
std::fs::remove_file(&path)
.context(format!("Failed to remove stale generated file: {path:?}"))?;
prune_empty_generated_dirs(output_dir, path.parent());
}
}
Ok(())
}
fn read_generated_manifest(path: &Path) -> anyhow::Result<BTreeSet<PathBuf>> {
let content = std::fs::read_to_string(path)
.context(format!("Failed to read generated file manifest: {path:?}"))?;
let mut files = BTreeSet::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
files.insert(generated_relative_path(line)?.to_path_buf());
}
Ok(files)
}
fn collect_legacy_generated_files(
output_dir: &Path,
dir: &Path,
language: &Language,
files: &mut BTreeSet<PathBuf>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir).context(format!("Failed to read directory: {dir:?}"))? {
let entry = entry?;
let path = entry.path();
let file_type = entry
.file_type()
.context(format!("Failed to inspect directory entry: {path:?}"))?;
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
collect_legacy_generated_files(output_dir, &path, language, files)?;
continue;
}
if path.file_name().and_then(|name| name.to_str()) == Some(GENERATED_MANIFEST) {
continue;
}
if is_generated_file(&path, language) {
let relative = path
.strip_prefix(output_dir)
.context(format!("Generated path escaped output directory: {path:?}"))?;
files.insert(generated_relative_path(&relative.to_string_lossy())?.to_path_buf());
}
}
Ok(())
}
fn is_generated_file(path: &Path, language: &Language) -> bool {
if !matches_language_extension(path, language) {
return false;
}
let Ok(content) = std::fs::read_to_string(path) else {
return false;
};
content.contains(GENERATED_MARKER)
}
fn matches_language_extension(path: &Path, language: &Language) -> bool {
let extension = path.extension().and_then(|extension| extension.to_str());
match language {
Language::Typescript => extension == Some("ts"),
Language::Rust => extension == Some("rs"),
Language::Python => extension == Some("py"),
Language::Openapi => extension == Some("json"),
}
}
fn prune_empty_generated_dirs(output_dir: &Path, mut dir: Option<&Path>) {
while let Some(current) = dir {
if current == output_dir || !current.starts_with(output_dir) {
break;
}
match std::fs::remove_dir(current) {
Ok(()) => dir = current.parent(),
Err(_) => break,
}
}
}
fn write_generated_manifest(output_dir: &Path, files: &BTreeSet<PathBuf>) -> anyhow::Result<()> {
let mut content = format!("# {GENERATED_MARKER}\n");
content.push_str("# Relative files generated during the last reflectapi codegen run.\n");
for file in files {
content.push_str(&file.to_string_lossy());
content.push('\n');
}
write_file(&output_dir.join(GENERATED_MANIFEST), &content)
}
fn parent_or_dot(path: &std::path::Path) -> std::path::PathBuf {
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::path::PathBuf::from("."))
}
fn generated_relative_path(filename: &str) -> anyhow::Result<&std::path::Path> {
let relative_path = std::path::Path::new(filename);
anyhow::ensure!(
relative_path.is_relative()
&& !relative_path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir)),
"Generated file path must be relative and stay within output directory: {filename}"
);
Ok(relative_path)
}
fn write_file(path: &std::path::Path, content: &str) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.context(format!("Failed to create output directory: {parent:?}"))?;
}
let mut file =
std::fs::File::create(path).context(format!("Failed to create file: {path:?}"))?;
file.write_all(content.as_bytes())
.context(format!("Failed to write to file: {path:?}"))?;
Ok(())
}