diff --git a/src/api/mod.rs b/src/api/mod.rs index d9a4aa0..a8dc97e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,7 +7,7 @@ pub(crate) mod types; use aide::openapi; use serde::{Deserialize, Serialize}; -use crate::IncludeMode; +use crate::cli_v1::IncludeMode; pub(crate) use self::{ resources::{Resource, Resources}, diff --git a/src/api/resources.rs b/src/api/resources.rs index d0b1196..7076c53 100644 --- a/src/api/resources.rs +++ b/src/api/resources.rs @@ -6,11 +6,12 @@ use indexmap::IndexMap; use schemars::schema::{InstanceType, Schema}; use serde::{Deserialize, Serialize}; +use crate::cli_v1::IncludeMode; + use super::{ get_schema_name, types::{FieldType, serialize_field_type}, }; -use crate::IncludeMode; /// The API operations of the API client we generate. /// diff --git a/src/api/types.rs b/src/api/types.rs index 12bfcb3..2a74a67 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -12,11 +12,12 @@ use schemars::schema::{ }; use serde::{Deserialize, Serialize}; +use crate::cli_v1::IncludeMode; + use super::{ get_schema_name, resources::{self, Resources}, }; -use crate::IncludeMode; /// Named types referenced by API operations. /// diff --git a/src/cli_v1.rs b/src/cli_v1.rs new file mode 100644 index 0000000..bf14108 --- /dev/null +++ b/src/cli_v1.rs @@ -0,0 +1,218 @@ +use std::{ + collections::BTreeSet, + io, + path::{Path, PathBuf}, +}; + +use aide::openapi::OpenApi; +use anyhow::{Context as _, bail}; +use camino::Utf8PathBuf; +use clap::{Parser, Subcommand}; +use fs_err::{self as fs}; +use schemars::schema::Schema; +use tempfile::TempDir; + +use crate::{api::Api, generator::generate}; + +#[derive(Parser)] +struct CliArgs { + /// Which operations to include. + #[arg(global = true, long, value_enum, default_value_t = IncludeMode::OnlyPublic)] + include_mode: IncludeMode, + + /// Ignore a specified operation id + #[arg(global = true, short, long = "exclude-op-id")] + excluded_operations: Vec, + + /// Only include specified operations + /// + /// This option only works with `--include-mode=only-specified`. + /// + /// Use this option, to run the codegen with a limited set of operations. + /// Op webhook models will be excluded from the generation + #[arg(global = true, long = "include-op-id")] + specified_operations: Vec, + + #[command(subcommand)] + command: Command, +} + +#[derive(Clone, Subcommand)] +enum Command { + /// Generate code from an OpenAPI spec. + Generate { + /// Path to a template file to use (`.jinja` extension can be omitted). + #[arg(short, long)] + template: Utf8PathBuf, + + /// Path to the input file(s). + #[arg(short, long)] + input_file: Vec, + + /// Path to the output directory. + #[arg(short, long)] + output_dir: Option, + + /// Disable automatic postprocessing of the output (formatting and automatic style fixes). + #[arg(long)] + no_postprocess: bool, + }, + /// Generate api.ron and types.ron files, for debugging. + Debug { + /// Path to the input file(s). + #[arg(short, long)] + input_file: Vec, + }, +} + +#[derive(Copy, Clone, clap::ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub(crate) enum IncludeMode { + /// Only public options + OnlyPublic, + /// Both public operations and operations marked with `x-internal` + PublicAndInternal, + /// Only operations marked with `x-internal` + OnlyInternal, + /// Only operations that were specified in `--include-op-id` + OnlySpecified, +} + +pub fn run_cli_v1_main() -> anyhow::Result<()> { + tracing_subscriber::fmt().with_writer(io::stderr).init(); + + let args = CliArgs::parse(); + + let excluded_operations = BTreeSet::from_iter(args.excluded_operations); + let specified_operations = BTreeSet::from_iter(args.specified_operations); + + let input_files = match &args.command { + Command::Generate { input_file, .. } => input_file, + Command::Debug { input_file } => input_file, + }; + + let api = input_files + .iter() + .map(|input_file| { + let input_file = Path::new(input_file); + let input_file_ext = input_file + .extension() + .context("input file must have a file extension")?; + let input_file_contents = fs::read_to_string(input_file)?; + + if input_file_ext == "json" { + let spec: OpenApi = serde_json::from_str(&input_file_contents) + .context("failed to parse OpenAPI spec")?; + + let webhooks = get_webhooks(&spec); + Api::new( + spec.paths.context("found no endpoints in input spec")?, + &mut spec.components.unwrap_or_default(), + &webhooks, + args.include_mode, + &excluded_operations, + &specified_operations, + ) + .context("converting OpenAPI spec to our own representation") + } else if input_file_ext == "ron" { + ron::from_str(&input_file_contents).context("parsing ron file") + } else { + bail!("input file extension must be .json or .ron"); + } + }) + .collect::>()?; + + match args.command { + Command::Generate { + template, + output_dir, + no_postprocess, + .. + } => { + let generated_paths = match &output_dir { + Some(path) => { + let generated_paths = generate(api, template.into(), path, no_postprocess)?; + println!("done! output written to {path}"); + generated_paths + } + None => { + let output_dir_root = PathBuf::from("out"); + if !output_dir_root.exists() { + fs::create_dir(&output_dir_root).context("failed to create out dir")?; + } + + let tpl_file_name = template + .file_name() + .context("template must have a file name")?; + let prefix = tpl_file_name + .strip_suffix(".jinja") + .unwrap_or(tpl_file_name); + + let output_dir = + TempDir::with_prefix_in(prefix.to_owned() + ".", output_dir_root) + .context("failed to create tempdir")?; + + let path = output_dir + .path() + .try_into() + .context("non-UTF8 tempdir path")?; + + let generated_paths = generate(api, template.into(), path, no_postprocess)?; + println!("done! output written to {path}"); + + // Persist the TempDir if everything was successful + _ = output_dir.keep(); + generated_paths + } + }; + let paths: Vec<&str> = generated_paths.iter().map(|p| p.as_str()).collect(); + let serialized = serde_json::to_string_pretty(&paths)?; + fs::write(".generated_paths.json", serialized)?; + } + Command::Debug { .. } => { + let serialized = ron::ser::to_string_pretty(&api, Default::default())?; + fs::write("debug.ron", serialized)?; + } + } + + Ok(()) +} + +fn get_webhooks(spec: &OpenApi) -> Vec { + let empty_obj = serde_json::Map::new(); + let mut referenced_components = std::collections::BTreeSet::::new(); + if let Some(webhooks) = spec.extensions.get("x-webhooks") { + for req in webhooks.as_object().unwrap_or(&empty_obj).values() { + for method in req.as_object().unwrap_or(&empty_obj).values() { + if let Some(schema_ref) = + method["requestBody"]["content"]["application/json"]["schema"]["$ref"].as_str() + && let Some(schema_name) = schema_ref.split('/').next_back() + { + referenced_components.insert(schema_name.to_string()); + } + } + } + } + + // also check the spec.webhooks + for (_, webhook) in &spec.webhooks { + let Some(item) = webhook.as_item() else { + continue; + }; + + for (_, op) in item.iter() { + if let Some(body) = &op.request_body + && let Some(item) = body.as_item() + && let Some(json_content) = item.content.get("application/json") + && let Some(schema) = &json_content.schema + && let Schema::Object(obj) = &schema.json_schema + && let Some(reference) = &obj.reference + && let Some(component_name) = reference.split('/').next_back() + { + referenced_components.insert(component_name.to_owned()); + } + } + } + + referenced_components.into_iter().collect::>() +} diff --git a/src/codesamples.rs b/src/codesamples.rs index 33c6b68..acd7603 100644 --- a/src/codesamples.rs +++ b/src/codesamples.rs @@ -10,6 +10,7 @@ use crate::{ Api, Resource, types::{EnumVariantType, Field, FieldType, StructEnumRepr, Type, TypeData}, }, + cli_v1::IncludeMode, template, }; use aide::openapi::OpenApi; @@ -202,7 +203,7 @@ pub async fn generate_codesamples( .context("found no endpoints in input spec")?, &mut openapi_spec.components.clone().unwrap_or_default(), &[], - crate::IncludeMode::OnlyPublic, + IncludeMode::OnlyPublic, &excluded_operation_ids, &BTreeSet::new(), )?; diff --git a/src/lib.rs b/src/lib.rs index a85c637..25e2db0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,229 +1,12 @@ -use std::{ - collections::BTreeSet, - io, - path::{Path, PathBuf}, -}; - -use aide::openapi::OpenApi; -use anyhow::{Context as _, bail}; -use camino::Utf8PathBuf; -use clap::{Parser, Subcommand}; -use fs_err::{self as fs}; -use schemars::schema::Schema; -use tempfile::TempDir; - mod api; +mod cli_v1; mod codesamples; mod generator; mod postprocessing; mod template; -use self::{api::Api, generator::generate}; - pub use crate::{ + cli_v1::run_cli_v1_main, codesamples::{CodeSample, CodesampleTemplates, generate_codesamples}, postprocessing::CodegenLanguage, }; - -#[derive(Parser)] -struct CliArgs { - /// Which operations to include. - #[arg(global = true, long, value_enum, default_value_t = IncludeMode::OnlyPublic)] - include_mode: IncludeMode, - - /// Ignore a specified operation id - #[arg(global = true, short, long = "exclude-op-id")] - excluded_operations: Vec, - - /// Only include specified operations - /// - /// This option only works with `--include-mode=only-specified`. - /// - /// Use this option, to run the codegen with a limited set of operations. - /// Op webhook models will be excluded from the generation - #[arg(global = true, long = "include-op-id")] - specified_operations: Vec, - - #[command(subcommand)] - command: Command, -} - -#[derive(Clone, Subcommand)] -enum Command { - /// Generate code from an OpenAPI spec. - Generate { - /// Path to a template file to use (`.jinja` extension can be omitted). - #[arg(short, long)] - template: Utf8PathBuf, - - /// Path to the input file(s). - #[arg(short, long)] - input_file: Vec, - - /// Path to the output directory. - #[arg(short, long)] - output_dir: Option, - - /// Disable automatic postprocessing of the output (formatting and automatic style fixes). - #[arg(long)] - no_postprocess: bool, - }, - /// Generate api.ron and types.ron files, for debugging. - Debug { - /// Path to the input file(s). - #[arg(short, long)] - input_file: Vec, - }, -} - -#[derive(Copy, Clone, clap::ValueEnum)] -#[clap(rename_all = "kebab-case")] -enum IncludeMode { - /// Only public options - OnlyPublic, - /// Both public operations and operations marked with `x-internal` - PublicAndInternal, - /// Only operations marked with `x-internal` - OnlyInternal, - /// Only operations that were specified in `--include-op-id` - OnlySpecified, -} - -pub fn run_cli_main() -> anyhow::Result<()> { - tracing_subscriber::fmt().with_writer(io::stderr).init(); - - let args = CliArgs::parse(); - - let excluded_operations = BTreeSet::from_iter(args.excluded_operations); - let specified_operations = BTreeSet::from_iter(args.specified_operations); - - let input_files = match &args.command { - Command::Generate { input_file, .. } => input_file, - Command::Debug { input_file } => input_file, - }; - - let api = input_files - .iter() - .map(|input_file| { - let input_file = Path::new(input_file); - let input_file_ext = input_file - .extension() - .context("input file must have a file extension")?; - let input_file_contents = fs::read_to_string(input_file)?; - - if input_file_ext == "json" { - let spec: OpenApi = serde_json::from_str(&input_file_contents) - .context("failed to parse OpenAPI spec")?; - - let webhooks = get_webhooks(&spec); - Api::new( - spec.paths.context("found no endpoints in input spec")?, - &mut spec.components.unwrap_or_default(), - &webhooks, - args.include_mode, - &excluded_operations, - &specified_operations, - ) - .context("converting OpenAPI spec to our own representation") - } else if input_file_ext == "ron" { - ron::from_str(&input_file_contents).context("parsing ron file") - } else { - bail!("input file extension must be .json or .ron"); - } - }) - .collect::>()?; - - match args.command { - Command::Generate { - template, - output_dir, - no_postprocess, - .. - } => { - let generated_paths = match &output_dir { - Some(path) => { - let generated_paths = generate(api, template.into(), path, no_postprocess)?; - println!("done! output written to {path}"); - generated_paths - } - None => { - let output_dir_root = PathBuf::from("out"); - if !output_dir_root.exists() { - fs::create_dir(&output_dir_root).context("failed to create out dir")?; - } - - let tpl_file_name = template - .file_name() - .context("template must have a file name")?; - let prefix = tpl_file_name - .strip_suffix(".jinja") - .unwrap_or(tpl_file_name); - - let output_dir = - TempDir::with_prefix_in(prefix.to_owned() + ".", output_dir_root) - .context("failed to create tempdir")?; - - let path = output_dir - .path() - .try_into() - .context("non-UTF8 tempdir path")?; - - let generated_paths = generate(api, template.into(), path, no_postprocess)?; - println!("done! output written to {path}"); - - // Persist the TempDir if everything was successful - _ = output_dir.keep(); - generated_paths - } - }; - let paths: Vec<&str> = generated_paths.iter().map(|p| p.as_str()).collect(); - let serialized = serde_json::to_string_pretty(&paths)?; - fs::write(".generated_paths.json", serialized)?; - } - Command::Debug { .. } => { - let serialized = ron::ser::to_string_pretty(&api, Default::default())?; - fs::write("debug.ron", serialized)?; - } - } - - Ok(()) -} - -fn get_webhooks(spec: &OpenApi) -> Vec { - let empty_obj = serde_json::Map::new(); - let mut referenced_components = std::collections::BTreeSet::::new(); - if let Some(webhooks) = spec.extensions.get("x-webhooks") { - for req in webhooks.as_object().unwrap_or(&empty_obj).values() { - for method in req.as_object().unwrap_or(&empty_obj).values() { - if let Some(schema_ref) = - method["requestBody"]["content"]["application/json"]["schema"]["$ref"].as_str() - && let Some(schema_name) = schema_ref.split('/').next_back() - { - referenced_components.insert(schema_name.to_string()); - } - } - } - } - - // also check the spec.webhooks - for (_, webhook) in &spec.webhooks { - let Some(item) = webhook.as_item() else { - continue; - }; - - for (_, op) in item.iter() { - if let Some(body) = &op.request_body - && let Some(item) = body.as_item() - && let Some(json_content) = item.content.get("application/json") - && let Some(schema) = &json_content.schema - && let Schema::Object(obj) = &schema.json_schema - && let Some(reference) = &obj.reference - && let Some(component_name) = reference.split('/').next_back() - { - referenced_components.insert(component_name.to_owned()); - } - } - } - - referenced_components.into_iter().collect::>() -} diff --git a/src/main.rs b/src/main.rs index bb95029..41d0387 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,3 @@ fn main() -> anyhow::Result<()> { - openapi_codegen::run_cli_main() + openapi_codegen::run_cli_v1_main() }