Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
3 changes: 2 additions & 1 deletion src/api/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
3 changes: 2 additions & 1 deletion src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
218 changes: 218 additions & 0 deletions src/cli_v1.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

/// 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<String>,

#[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<String>,

/// Path to the output directory.
#[arg(short, long)]
output_dir: Option<Utf8PathBuf>,

/// 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<String>,
},
}

#[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::<anyhow::Result<Api>>()?;

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<String> {
let empty_obj = serde_json::Map::new();
let mut referenced_components = std::collections::BTreeSet::<String>::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::<Vec<String>>()
}
3 changes: 2 additions & 1 deletion src/codesamples.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
Api, Resource,
types::{EnumVariantType, Field, FieldType, StructEnumRepr, Type, TypeData},
},
cli_v1::IncludeMode,
template,
};
use aide::openapi::OpenApi;
Expand Down Expand Up @@ -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(),
)?;
Expand Down
Loading