#![doc = include_str!("../README.md")]
#![allow(clippy::too_many_arguments)]
#![warn(missing_docs)]
use crate::api::operation::collect_operations;
use indexmap::IndexMap;
use itertools::Itertools;
use openapiv3::{OpenAPI, Operation};
use proc_macro2::{Ident, Span, TokenStream};
use quote::quote;
use serde_json::json;
use syn::parse2;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
mod api;
mod apigw;
mod inline;
mod model;
mod reference;
pub use openapiv3;
type DocCache = HashMap<PathBuf, serde_yaml::Mapping>;
#[derive(Debug)]
enum LambdaArnImpl {
CloudFormation {
logical_id: String,
},
Known {
api_gateway_region: String,
account_id: String,
function_region: String,
function_name: String,
alias_or_version: Option<String>,
},
}
impl LambdaArnImpl {
pub fn apigw_invocation_arn(&self) -> serde_json::Value {
match self {
LambdaArnImpl::CloudFormation { logical_id } => {
json!({
"Fn::Sub": format!(
"arn:aws:apigateway:${{AWS::Region}}:lambda:path/2015-03-31/functions/${{{logical_id}}}\
/invocations",
)
})
}
LambdaArnImpl::Known {
api_gateway_region,
account_id,
function_region,
function_name,
alias_or_version,
} => serde_json::Value::String(format!(
"arn:aws:apigateway:{api_gateway_region}:lambda:path/2015-03-31/functions/arn:aws\
:lambda:{function_region}:{account_id}:function:{function_name}{}/invocations",
alias_or_version
.as_ref()
.map(|alias| Cow::Owned(format!(":{alias}")))
.unwrap_or(Cow::Borrowed(""))
)),
}
}
}
#[derive(Debug)]
pub struct LambdaArn(LambdaArnImpl);
impl LambdaArn {
pub fn cloud_formation<L>(logical_id: L) -> Self
where
L: Into<String>,
{
Self(LambdaArnImpl::CloudFormation {
logical_id: logical_id.into(),
})
}
pub fn known<A, F, G, R>(
api_gateway_region: G,
account_id: A,
function_region: R,
function_name: F,
alias_or_version: Option<String>,
) -> Self
where
A: Into<String>,
F: Into<String>,
G: Into<String>,
R: Into<String>,
{
Self(LambdaArnImpl::Known {
api_gateway_region: api_gateway_region.into(),
account_id: account_id.into(),
function_region: function_region.into(),
function_name: function_name.into(),
alias_or_version,
})
}
}
type OpFilter = Box<dyn Fn(&Operation) -> bool + 'static>;
pub struct ApiLambda {
mod_name: String,
lambda_arn: LambdaArnImpl,
op_filter: Option<OpFilter>,
}
impl ApiLambda {
pub fn new<M>(mod_name: M, lambda_arn: LambdaArn) -> Self
where
M: Into<String>,
{
Self {
lambda_arn: lambda_arn.0,
mod_name: mod_name.into(),
op_filter: None,
}
}
pub fn with_op_filter<F>(mut self, op_filter: F) -> Self
where
F: Fn(&Operation) -> bool + 'static,
{
self.op_filter = Some(Box::new(op_filter));
self
}
}
pub struct CodeGenerator {
api_lambdas: IndexMap<String, ApiLambda>,
openapi_path: PathBuf,
out_dir: PathBuf,
}
impl CodeGenerator {
pub fn new<P, O>(openapi_path: P, out_dir: O) -> Self
where
P: Into<PathBuf>,
O: Into<PathBuf>,
{
Self {
api_lambdas: IndexMap::new(),
openapi_path: openapi_path.into(),
out_dir: out_dir.into(),
}
}
pub fn add_api_lambda(mut self, builder: ApiLambda) -> Self {
if self.api_lambdas.contains_key(&builder.mod_name) {
panic!(
"API Lambda module names must be unique: found duplicate `{}`",
builder.mod_name
)
}
self.api_lambdas.insert(builder.mod_name.clone(), builder);
self
}
pub fn generate(self) {
let cargo_out_dir = std::env::var("OUT_DIR").expect("OUT_DIR env not set");
log::info!("writing Rust codegen to {cargo_out_dir}");
log::info!("writing OpenAPI codegen to {}", self.out_dir.display());
if !self.out_dir.exists() {
std::fs::create_dir_all(&self.out_dir).unwrap_or_else(|err| {
panic!(
"failed to create directory `{}`: {err}",
self.out_dir.display()
)
});
}
let openapi_file = File::open(&self.openapi_path)
.unwrap_or_else(|err| panic!("failed to open {}: {err}", self.openapi_path.display()));
let openapi_yaml: serde_yaml::Mapping =
serde_path_to_error::deserialize(serde_yaml::Deserializer::from_reader(&openapi_file))
.unwrap_or_else(|err| panic!("Failed to parse OpenAPI spec as YAML: {err}"));
let mut cached_external_docs = DocCache::new();
#[allow(clippy::redundant_clone)]
cached_external_docs.insert(self.openapi_path.to_path_buf(), openapi_yaml.clone());
println!("cargo:rerun-if-changed={}", self.openapi_path.display());
let openapi: OpenAPI =
serde_path_to_error::deserialize(serde_yaml::Value::Mapping(openapi_yaml))
.unwrap_or_else(|err| panic!("Failed to parse OpenAPI spec: {err}"));
let crate_import = self.crate_use_name();
let (openapi_inline, models) =
self.generate_models(self.inline_openapi(openapi, cached_external_docs));
let openapi_inline_mapping =
serde_path_to_error::serialize(&*openapi_inline, serde_yaml::value::Serializer)
.expect("failed to serialize OpenAPI spec");
let serde_yaml::Value::Mapping(openapi_inline_mapping) = openapi_inline_mapping else {
panic!("OpenAPI spec should be a mapping: {:#?}", &*openapi_inline);
};
let operations = collect_operations(&openapi_inline, &openapi_inline_mapping);
let operations_by_api_lambda = self
.api_lambdas
.values()
.flat_map(|api_lambda| {
operations
.iter()
.filter(|op| {
api_lambda
.op_filter
.as_ref()
.map(|op_filter| (*op_filter)(&op.op))
.unwrap_or(true)
})
.map(|op| (&api_lambda.mod_name, op))
})
.into_group_map();
operations_by_api_lambda
.iter()
.flat_map(|(mod_name, ops)| {
ops
.iter()
.map(|op| ((&op.method, &op.request_path), *mod_name))
})
.into_group_map()
.into_iter()
.for_each(|((method, request_path), mod_names)| {
if mod_names.len() > 1 {
panic!(
"endpoint {method} {request_path} is mapped to multiple API Lambdas: {mod_names:?}"
);
}
});
let operation_id_to_api_lambda = operations_by_api_lambda
.iter()
.flat_map(|(mod_name, ops)| {
ops.iter().map(|op| {
(
op.op
.operation_id
.as_ref()
.unwrap_or_else(|| panic!("no operation_id for {} {}", op.method, op.request_path))
.as_str(),
self
.api_lambdas
.get(*mod_name)
.expect("mod name should exist in api_lambdas"),
)
})
})
.collect::<HashMap<_, _>>();
let components_schemas = openapi_inline
.components
.as_ref()
.map(|components| Cow::Borrowed(&components.schemas))
.unwrap_or_else(|| Cow::Owned(IndexMap::new()));
let apis_out = operations_by_api_lambda
.iter()
.sorted_by_key(|(mod_name, _)| **mod_name)
.map(|(mod_name, ops)| {
self.gen_api_module(
mod_name,
ops,
&openapi_inline_mapping,
&components_schemas,
&models,
)
})
.collect::<TokenStream>();
self.gen_openapi_apigw(openapi_inline, &operation_id_to_api_lambda);
let models_out = models
.into_iter()
.sorted_by(|(ident_a, _), (ident_b, _)| ident_a.cmp(ident_b))
.map(|(_, model)| model)
.collect::<TokenStream>();
let out_rs_path = Path::new(&cargo_out_dir).join("out.rs");
let out_tok = quote! {
pub mod models {
#![allow(unused_imports)]
#![allow(clippy::large_enum_variant)]
use #crate_import::__private::anyhow::{self, anyhow};
use #crate_import::__private::serde::{Deserialize, Serialize};
use #crate_import::models::chrono;
#models_out
}
#apis_out
};
File::create(&out_rs_path)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", out_rs_path.to_string_lossy()))
.write_all(
prettyplease::unparse(
&parse2(out_tok.clone())
.unwrap_or_else(|err| panic!("failed to parse generated code: {err}\n{out_tok}")),
)
.as_bytes(),
)
.unwrap_or_else(|err| {
panic!(
"failed to write to {}: {err}",
out_rs_path.to_string_lossy()
)
});
}
fn crate_use_name(&self) -> Ident {
Ident::new("openapi_lambda", Span::call_site())
}
fn rustfmt(&self, path: &Path) {
let rustfmt_result = Command::new("rustfmt")
.args(["--edition".as_ref(), "2021".as_ref(), path.as_os_str()])
.output()
.unwrap_or_else(|err| panic!("failed to run rustfmt: {err}"));
if !rustfmt_result.status.success() {
panic!(
"rustfmt failed with status {}:\n{}",
rustfmt_result.status,
String::from_utf8_lossy(rustfmt_result.stdout.as_slice())
+ String::from_utf8_lossy(rustfmt_result.stderr.as_slice())
);
}
}
}
fn description_to_doc_attr<S>(description: &S) -> TokenStream
where
S: AsRef<str>,
{
description
.as_ref()
.lines()
.map(|line| {
quote! {
#[doc = #line]
}
})
.collect()
}