|
| 1 | +use std::{ |
| 2 | + collections::BTreeSet, |
| 3 | + io, |
| 4 | + path::{Path, PathBuf}, |
| 5 | +}; |
| 6 | + |
| 7 | +use aide::openapi::OpenApi; |
| 8 | +use anyhow::{Context as _, bail}; |
| 9 | +use camino::Utf8PathBuf; |
| 10 | +use clap::{Parser, Subcommand}; |
| 11 | +use fs_err::{self as fs}; |
| 12 | +use schemars::schema::Schema; |
| 13 | +use tempfile::TempDir; |
| 14 | + |
| 15 | +use crate::{api::Api, generator::generate}; |
| 16 | + |
| 17 | +#[derive(Parser)] |
| 18 | +struct CliArgs { |
| 19 | + /// Which operations to include. |
| 20 | + #[arg(global = true, long, value_enum, default_value_t = IncludeMode::OnlyPublic)] |
| 21 | + include_mode: IncludeMode, |
| 22 | + |
| 23 | + /// Ignore a specified operation id |
| 24 | + #[arg(global = true, short, long = "exclude-op-id")] |
| 25 | + excluded_operations: Vec<String>, |
| 26 | + |
| 27 | + /// Only include specified operations |
| 28 | + /// |
| 29 | + /// This option only works with `--include-mode=only-specified`. |
| 30 | + /// |
| 31 | + /// Use this option, to run the codegen with a limited set of operations. |
| 32 | + /// Op webhook models will be excluded from the generation |
| 33 | + #[arg(global = true, long = "include-op-id")] |
| 34 | + specified_operations: Vec<String>, |
| 35 | + |
| 36 | + #[command(subcommand)] |
| 37 | + command: Command, |
| 38 | +} |
| 39 | + |
| 40 | +#[derive(Clone, Subcommand)] |
| 41 | +enum Command { |
| 42 | + /// Generate code from an OpenAPI spec. |
| 43 | + Generate { |
| 44 | + /// Path to a template file to use (`.jinja` extension can be omitted). |
| 45 | + #[arg(short, long)] |
| 46 | + template: Utf8PathBuf, |
| 47 | + |
| 48 | + /// Path to the input file(s). |
| 49 | + #[arg(short, long)] |
| 50 | + input_file: Vec<String>, |
| 51 | + |
| 52 | + /// Path to the output directory. |
| 53 | + #[arg(short, long)] |
| 54 | + output_dir: Option<Utf8PathBuf>, |
| 55 | + |
| 56 | + /// Disable automatic postprocessing of the output (formatting and automatic style fixes). |
| 57 | + #[arg(long)] |
| 58 | + no_postprocess: bool, |
| 59 | + }, |
| 60 | + /// Generate api.ron and types.ron files, for debugging. |
| 61 | + Debug { |
| 62 | + /// Path to the input file(s). |
| 63 | + #[arg(short, long)] |
| 64 | + input_file: Vec<String>, |
| 65 | + }, |
| 66 | +} |
| 67 | + |
| 68 | +#[derive(Copy, Clone, clap::ValueEnum)] |
| 69 | +#[clap(rename_all = "kebab-case")] |
| 70 | +pub(crate) enum IncludeMode { |
| 71 | + /// Only public options |
| 72 | + OnlyPublic, |
| 73 | + /// Both public operations and operations marked with `x-internal` |
| 74 | + PublicAndInternal, |
| 75 | + /// Only operations marked with `x-internal` |
| 76 | + OnlyInternal, |
| 77 | + /// Only operations that were specified in `--include-op-id` |
| 78 | + OnlySpecified, |
| 79 | +} |
| 80 | + |
| 81 | +pub fn run_cli_v1_main() -> anyhow::Result<()> { |
| 82 | + tracing_subscriber::fmt().with_writer(io::stderr).init(); |
| 83 | + |
| 84 | + let args = CliArgs::parse(); |
| 85 | + |
| 86 | + let excluded_operations = BTreeSet::from_iter(args.excluded_operations); |
| 87 | + let specified_operations = BTreeSet::from_iter(args.specified_operations); |
| 88 | + |
| 89 | + let input_files = match &args.command { |
| 90 | + Command::Generate { input_file, .. } => input_file, |
| 91 | + Command::Debug { input_file } => input_file, |
| 92 | + }; |
| 93 | + |
| 94 | + let api = input_files |
| 95 | + .iter() |
| 96 | + .map(|input_file| { |
| 97 | + let input_file = Path::new(input_file); |
| 98 | + let input_file_ext = input_file |
| 99 | + .extension() |
| 100 | + .context("input file must have a file extension")?; |
| 101 | + let input_file_contents = fs::read_to_string(input_file)?; |
| 102 | + |
| 103 | + if input_file_ext == "json" { |
| 104 | + let spec: OpenApi = serde_json::from_str(&input_file_contents) |
| 105 | + .context("failed to parse OpenAPI spec")?; |
| 106 | + |
| 107 | + let webhooks = get_webhooks(&spec); |
| 108 | + Api::new( |
| 109 | + spec.paths.context("found no endpoints in input spec")?, |
| 110 | + &mut spec.components.unwrap_or_default(), |
| 111 | + &webhooks, |
| 112 | + args.include_mode, |
| 113 | + &excluded_operations, |
| 114 | + &specified_operations, |
| 115 | + ) |
| 116 | + .context("converting OpenAPI spec to our own representation") |
| 117 | + } else if input_file_ext == "ron" { |
| 118 | + ron::from_str(&input_file_contents).context("parsing ron file") |
| 119 | + } else { |
| 120 | + bail!("input file extension must be .json or .ron"); |
| 121 | + } |
| 122 | + }) |
| 123 | + .collect::<anyhow::Result<Api>>()?; |
| 124 | + |
| 125 | + match args.command { |
| 126 | + Command::Generate { |
| 127 | + template, |
| 128 | + output_dir, |
| 129 | + no_postprocess, |
| 130 | + .. |
| 131 | + } => { |
| 132 | + let generated_paths = match &output_dir { |
| 133 | + Some(path) => { |
| 134 | + let generated_paths = generate(api, template.into(), path, no_postprocess)?; |
| 135 | + println!("done! output written to {path}"); |
| 136 | + generated_paths |
| 137 | + } |
| 138 | + None => { |
| 139 | + let output_dir_root = PathBuf::from("out"); |
| 140 | + if !output_dir_root.exists() { |
| 141 | + fs::create_dir(&output_dir_root).context("failed to create out dir")?; |
| 142 | + } |
| 143 | + |
| 144 | + let tpl_file_name = template |
| 145 | + .file_name() |
| 146 | + .context("template must have a file name")?; |
| 147 | + let prefix = tpl_file_name |
| 148 | + .strip_suffix(".jinja") |
| 149 | + .unwrap_or(tpl_file_name); |
| 150 | + |
| 151 | + let output_dir = |
| 152 | + TempDir::with_prefix_in(prefix.to_owned() + ".", output_dir_root) |
| 153 | + .context("failed to create tempdir")?; |
| 154 | + |
| 155 | + let path = output_dir |
| 156 | + .path() |
| 157 | + .try_into() |
| 158 | + .context("non-UTF8 tempdir path")?; |
| 159 | + |
| 160 | + let generated_paths = generate(api, template.into(), path, no_postprocess)?; |
| 161 | + println!("done! output written to {path}"); |
| 162 | + |
| 163 | + // Persist the TempDir if everything was successful |
| 164 | + _ = output_dir.keep(); |
| 165 | + generated_paths |
| 166 | + } |
| 167 | + }; |
| 168 | + let paths: Vec<&str> = generated_paths.iter().map(|p| p.as_str()).collect(); |
| 169 | + let serialized = serde_json::to_string_pretty(&paths)?; |
| 170 | + fs::write(".generated_paths.json", serialized)?; |
| 171 | + } |
| 172 | + Command::Debug { .. } => { |
| 173 | + let serialized = ron::ser::to_string_pretty(&api, Default::default())?; |
| 174 | + fs::write("debug.ron", serialized)?; |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + Ok(()) |
| 179 | +} |
| 180 | + |
| 181 | +fn get_webhooks(spec: &OpenApi) -> Vec<String> { |
| 182 | + let empty_obj = serde_json::Map::new(); |
| 183 | + let mut referenced_components = std::collections::BTreeSet::<String>::new(); |
| 184 | + if let Some(webhooks) = spec.extensions.get("x-webhooks") { |
| 185 | + for req in webhooks.as_object().unwrap_or(&empty_obj).values() { |
| 186 | + for method in req.as_object().unwrap_or(&empty_obj).values() { |
| 187 | + if let Some(schema_ref) = |
| 188 | + method["requestBody"]["content"]["application/json"]["schema"]["$ref"].as_str() |
| 189 | + && let Some(schema_name) = schema_ref.split('/').next_back() |
| 190 | + { |
| 191 | + referenced_components.insert(schema_name.to_string()); |
| 192 | + } |
| 193 | + } |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + // also check the spec.webhooks |
| 198 | + for (_, webhook) in &spec.webhooks { |
| 199 | + let Some(item) = webhook.as_item() else { |
| 200 | + continue; |
| 201 | + }; |
| 202 | + |
| 203 | + for (_, op) in item.iter() { |
| 204 | + if let Some(body) = &op.request_body |
| 205 | + && let Some(item) = body.as_item() |
| 206 | + && let Some(json_content) = item.content.get("application/json") |
| 207 | + && let Some(schema) = &json_content.schema |
| 208 | + && let Schema::Object(obj) = &schema.json_schema |
| 209 | + && let Some(reference) = &obj.reference |
| 210 | + && let Some(component_name) = reference.split('/').next_back() |
| 211 | + { |
| 212 | + referenced_components.insert(component_name.to_owned()); |
| 213 | + } |
| 214 | + } |
| 215 | + } |
| 216 | + |
| 217 | + referenced_components.into_iter().collect::<Vec<String>>() |
| 218 | +} |
0 commit comments