use proc_macro2::{Span, TokenStream};
use quote::quote;
use syn::{Ident, LitStr, parse_quote};
use tracing::info;
use crate::model::{Api, Field, IntegerType, Operation, ParseAs, RangeScalar, TypeRef};
use crate::{GenerateOptions, RootModule};
const PREAMBLE: &str = "\
//! @generated by satay. Do not edit by hand.
";
#[derive(Debug)]
pub struct GeneratedFile {
pub relative_path: String,
pub contents: String,
}
pub(crate) fn render_api(api: &Api, options: GenerateOptions) -> Vec<GeneratedFile> {
info!(
components = api.components.len(),
operations = api.operations.len(),
"rendering API"
);
let mut files = vec![];
let root_module = match options.root_module {
RootModule::ModRs => "mod.rs",
RootModule::LibRs => "lib.rs",
};
let top_mod = render_top_mod(api);
files.push(GeneratedFile {
relative_path: root_module.to_owned(),
contents: format_file(top_mod),
});
if !api.components.is_empty() || !api.constrained_types.is_empty() {
let types_file = types::render_types_file(api);
files.push(GeneratedFile {
relative_path: "types.rs".to_owned(),
contents: format_file(types_file),
});
}
let api_file = api::render_api_file(api);
files.push(GeneratedFile {
relative_path: "api.rs".to_owned(),
contents: format_file(api_file),
});
for operation in &api.operations {
let dir = &operation.fn_name;
let endpoint_mod = endpoint::render_endpoint_mod(operation);
files.push(GeneratedFile {
relative_path: format!("{dir}/mod.rs"),
contents: format_file(endpoint_mod),
});
let parts_file = endpoint::render_endpoint_parts_file(api, operation);
files.push(GeneratedFile {
relative_path: format!("{dir}/parts.rs"),
contents: format_file(parts_file),
});
let json_file = endpoint::render_endpoint_json_file(api, operation);
files.push(GeneratedFile {
relative_path: format!("{dir}/json.rs"),
contents: format_file(json_file),
});
}
info!(files = files.len(), "rendered API");
files
}
fn format_file(file: syn::File) -> String {
let code = prettyplease::unparse(&file);
let mut formatted = String::with_capacity(PREAMBLE.len() + code.len());
formatted.push_str(PREAMBLE);
formatted.push_str(&code);
formatted
}
fn render_top_mod(api: &Api) -> syn::File {
let mut items: Vec<syn::Item> = vec![];
let server_url = lit_str(&api.server_url);
items.push(parse_quote!(pub const SERVER_URL: &str = #server_url;));
let has_types = !api.components.is_empty() || !api.constrained_types.is_empty();
if has_types {
items.push(parse_quote!(
pub mod types;
));
items.push(parse_quote!(
pub use types::*;
));
}
items.push(parse_quote!(
#[cfg(feature = "json")]
mod api;
));
items.push(parse_quote!(
#[cfg(feature = "json")]
pub use api::*;
));
for operation in &api.operations {
let module = ident(&operation.fn_name);
items.push(parse_quote!(pub mod #module;));
items.push(parse_quote!(pub use #module::*;));
}
syn::File {
shebang: None,
attrs: vec![],
items,
}
}
pub fn ident(value: &str) -> Ident {
Ident::new(value, Span::call_site())
}
pub fn lit_str(value: &str) -> LitStr {
LitStr::new(value, Span::call_site())
}
pub fn doc_attrs(description: Option<&str>) -> Vec<syn::Attribute> {
let Some(description) = description.filter(|description| !description.trim().is_empty()) else {
return vec![];
};
description
.lines()
.map(|line| {
let doc_line = if line.is_empty() {
String::new()
} else {
format!(" {line}")
};
let doc_line = lit_str(&doc_line);
parse_quote!(#[doc = #doc_line])
})
.collect()
}
pub fn rust_type(ty: &TypeRef) -> syn::Type {
match ty {
TypeRef::String => parse_quote!(String),
TypeRef::ParsedString(parse_as) | TypeRef::ParsedInteger(parse_as) => {
parse_as_rust_type(*parse_as)
}
TypeRef::Integer(integer_type) => integer_rust_type(*integer_type),
TypeRef::F32 => parse_quote!(f32),
TypeRef::F64 => parse_quote!(f64),
TypeRef::Bool => parse_quote!(bool),
TypeRef::Array(item) => {
let item = rust_type(item);
parse_quote!(Vec<#item>)
}
TypeRef::Range(range_type) => {
let name = ident(&range_type.rust_name);
parse_quote!(#name)
}
TypeRef::Named(name)
| TypeRef::Constrained {
rust_name: name, ..
} => {
let name = ident(name);
parse_quote!(#name)
}
TypeRef::Option(inner) => {
let inner = rust_type(inner);
parse_quote!(Option<#inner>)
}
}
}
pub fn range_scalar_rust_type(scalar: RangeScalar) -> syn::Type {
match scalar {
RangeScalar::Integer(integer_type) => integer_rust_type(integer_type),
RangeScalar::F32 => parse_quote!(f32),
RangeScalar::F64 => parse_quote!(f64),
}
}
pub fn integer_rust_type(integer_type: IntegerType) -> syn::Type {
match integer_type {
IntegerType::U8 => parse_quote!(u8),
IntegerType::U16 => parse_quote!(u16),
IntegerType::U32 => parse_quote!(u32),
IntegerType::U64 => parse_quote!(u64),
IntegerType::I8 => parse_quote!(i8),
IntegerType::I16 => parse_quote!(i16),
IntegerType::I32 => parse_quote!(i32),
IntegerType::I64 => parse_quote!(i64),
}
}
pub fn parse_as_rust_type(parse_as: ParseAs) -> syn::Type {
match parse_as {
ParseAs::U8 => parse_quote!(u8),
ParseAs::U16 => parse_quote!(u16),
ParseAs::U32 => parse_quote!(u32),
ParseAs::U64 => parse_quote!(u64),
ParseAs::I8 => parse_quote!(i8),
ParseAs::I16 => parse_quote!(i16),
ParseAs::I32 => parse_quote!(i32),
ParseAs::I64 => parse_quote!(i64),
ParseAs::F32 => parse_quote!(f32),
ParseAs::F64 => parse_quote!(f64),
ParseAs::Bool => parse_quote!(bool),
ParseAs::Date => parse_quote!(satay_runtime::Date),
ParseAs::NaiveDateTime => parse_quote!(satay_runtime::PrimitiveDateTime),
ParseAs::OffsetDateTime => parse_quote!(satay_runtime::OffsetDateTime),
ParseAs::Time => parse_quote!(satay_runtime::Time),
ParseAs::IntegerRange | ParseAs::NumberRange => {
unreachable!("range parse-as uses generated range types")
}
}
}
pub fn parse_as_string_serde_module(parse_as: ParseAs) -> &'static str {
match parse_as {
ParseAs::U8 => "satay_runtime::serde_string::as_u8",
ParseAs::U16 => "satay_runtime::serde_string::as_u16",
ParseAs::U32 => "satay_runtime::serde_string::as_u32",
ParseAs::U64 => "satay_runtime::serde_string::as_u64",
ParseAs::I8 => "satay_runtime::serde_string::as_i8",
ParseAs::I16 => "satay_runtime::serde_string::as_i16",
ParseAs::I32 => "satay_runtime::serde_string::as_i32",
ParseAs::I64 => "satay_runtime::serde_string::as_i64",
ParseAs::F32 => "satay_runtime::serde_string::as_f32",
ParseAs::F64 => "satay_runtime::serde_string::as_f64",
ParseAs::Bool => "satay_runtime::serde_string::as_bool",
ParseAs::Date => "satay_runtime::serde_string::as_date",
ParseAs::NaiveDateTime => "satay_runtime::serde_string::as_naive_datetime",
ParseAs::OffsetDateTime => "satay_runtime::serde_string::as_offset_datetime",
ParseAs::Time => "satay_runtime::serde_string::as_time",
ParseAs::IntegerRange | ParseAs::NumberRange => {
unreachable!("range parse-as uses generated range types")
}
}
}
pub fn parse_as_integer_serde_module(parse_as: ParseAs) -> &'static str {
match parse_as {
ParseAs::Bool => "satay_runtime::serde_integer::as_bool",
ParseAs::U8
| ParseAs::U16
| ParseAs::U32
| ParseAs::U64
| ParseAs::I8
| ParseAs::I16
| ParseAs::I32
| ParseAs::I64
| ParseAs::F32
| ParseAs::F64
| ParseAs::Date
| ParseAs::NaiveDateTime
| ParseAs::OffsetDateTime
| ParseAs::Time
| ParseAs::IntegerRange
| ParseAs::NumberRange => unreachable!("only bool can parse from integer"),
}
}
pub fn rust_field_type(ty: &TypeRef, required: bool, treat_error_as_none: bool) -> syn::Type {
if (required && !treat_error_as_none) || ty.is_option() {
rust_type(ty)
} else {
let ty = rust_type(ty);
parse_quote!(Option<#ty>)
}
}
pub fn input_fields(operation: &Operation) -> Vec<Field> {
let mut input_fields = Vec::with_capacity(
operation.parameters.len() + usize::from(operation.request_body.is_some()),
);
input_fields.extend(operation.parameters.iter().map(|parameter| Field {
wire_name: parameter.wire_name.clone(),
rust_name: parameter.rust_name.clone(),
description: parameter.description.clone(),
ty: parameter.ty.clone(),
required: parameter.required,
treat_error_as_none: false,
}));
if let Some(body) = &operation.request_body {
input_fields.push(Field {
wire_name: body.field_name.clone(),
rust_name: body.field_name.clone(),
description: body.description.clone(),
ty: body.ty.clone(),
required: body.required,
treat_error_as_none: false,
});
}
input_fields
}
pub fn input_setter_name(field: &Field) -> Ident {
if field.rust_name == "new" {
ident("with_new")
} else {
ident(&field.rust_name)
}
}
pub fn input_builder_arg_type(ty: &TypeRef) -> TokenStream {
if ty == &TypeRef::String {
quote!(impl Into<String>)
} else {
let ty = rust_type(ty);
quote!(#ty)
}
}
pub fn input_builder_value(value: TokenStream, ty: &TypeRef) -> TokenStream {
if ty == &TypeRef::String {
quote!(#value.into())
} else {
value
}
}
pub fn request_from_parts_expr(operation: &Operation) -> syn::Expr {
match &operation.request_body {
Some(body) if body.required => parse_quote!(satay_runtime::into_json_request(parts)),
Some(_) => parse_quote!(satay_runtime::into_optional_json_request(parts)),
None => parse_quote!(satay_runtime::into_empty_request(parts)),
}
}
pub fn input_field(field: &str) -> syn::Expr {
let field = ident(field);
parse_quote!(input.#field)
}
mod api;
mod endpoint;
mod types;
#[cfg(test)]
mod tests {
use super::*;
use crate::model::PathSegment;
use crate::model::{Component, ComponentKind, HttpMethod, RequestBody, ResponseCase};
use quote::{ToTokens, quote};
use syn::{Fields, GenericArgument, Item, PathArguments, Type};
#[test]
fn render_file_exposes_struct_ast_without_source_comparison() {
let api = Api::new(
String::new(),
vec![],
vec![Component {
rust_name: "Pet".to_owned(),
description: None,
kind: ComponentKind::Struct(vec![
Field {
wire_name: "id".to_owned(),
rust_name: "id".to_owned(),
description: None,
ty: TypeRef::String,
required: true,
treat_error_as_none: false,
},
Field {
wire_name: "tag_count".to_owned(),
rust_name: "tag_count".to_owned(),
description: None,
ty: TypeRef::Integer(IntegerType::I32),
required: false,
treat_error_as_none: false,
},
]),
}],
vec![],
vec![],
);
let file = types::render_types_file(&api);
assert_eq!(file.items.len(), 1);
let Item::Struct(item) = &file.items[0] else {
panic!("expected struct item");
};
assert_eq!(item.ident, "Pet");
let Fields::Named(fields) = &item.fields else {
panic!("expected named fields");
};
assert_eq!(fields.named.len(), 2);
let mut fields = fields.named.iter();
let id = fields.next().expect("id field");
assert_eq!(id.ident.as_ref().expect("field ident"), "id");
assert!(type_path_is(&id.ty, "String"));
let tag_count = fields.next().expect("tag_count field");
assert_eq!(tag_count.ident.as_ref().expect("field ident"), "tag_count");
let Some(inner) = option_inner(&tag_count.ty) else {
panic!("optional field should render as Option<T>");
};
assert!(type_path_is(inner, "i32"));
}
#[test]
fn render_file_exposes_operation_items_without_source_comparison() {
let api = Api::new(
String::new(),
vec![],
vec![],
vec![],
vec![Operation {
fn_name: "create_pet".to_owned(),
description: None,
input_name: "CreatePetInput".to_owned(),
response_name: "CreatePetResponse".to_owned(),
method: HttpMethod::Post,
path: "/pets".to_owned(),
path_segments: vec![PathSegment::Literal("/pets".to_owned())],
parameters: vec![],
request_body: Some(RequestBody {
field_name: "body".to_owned(),
description: None,
content_type: "application/json".to_owned(),
ty: TypeRef::Named("Pet".to_owned()),
required: true,
}),
responses: vec![ResponseCase {
status: 201,
variant_name: "Created".to_owned(),
description: None,
body: Some(TypeRef::Named("Pet".to_owned())),
}],
}],
);
let files = render_api(&api, GenerateOptions::default());
assert!(files.iter().any(|f| f.relative_path == "mod.rs"));
assert!(files.iter().any(|f| f.relative_path == "create_pet/mod.rs"));
assert!(
files
.iter()
.any(|f| f.relative_path == "create_pet/parts.rs")
);
assert!(
files
.iter()
.any(|f| f.relative_path == "create_pet/json.rs")
);
}
#[test]
fn rust_field_type_wraps_optional_and_treat_error_as_none_fields() {
assert_eq!(
rust_field_type(&TypeRef::String, true, false)
.to_token_stream()
.to_string(),
"String"
);
assert_eq!(
rust_field_type(&TypeRef::String, false, false)
.to_token_stream()
.to_string(),
"Option < String >"
);
assert_eq!(
rust_field_type(&TypeRef::String, true, true)
.to_token_stream()
.to_string(),
"Option < String >"
);
assert_eq!(
rust_field_type(&TypeRef::Option(Box::new(TypeRef::String)), true, false)
.to_token_stream()
.to_string(),
"Option < String >"
);
}
#[test]
fn input_builder_arguments_convert_strings_only() {
assert_eq!(
input_builder_arg_type(&TypeRef::String).to_string(),
"impl Into < String >"
);
assert_eq!(
input_builder_arg_type(&TypeRef::Integer(IntegerType::I32)).to_string(),
"i32"
);
assert_eq!(
input_builder_value(quote!(value), &TypeRef::String).to_string(),
"value . into ()"
);
assert_eq!(
input_builder_value(quote!(value), &TypeRef::Integer(IntegerType::I32)).to_string(),
"value"
);
}
#[test]
fn request_conversion_mode_matches_body_requirement() {
assert_eq!(
request_from_parts_expr(&operation_with_body(None))
.to_token_stream()
.to_string(),
"satay_runtime :: into_empty_request (parts)"
);
assert_eq!(
request_from_parts_expr(&operation_with_body(Some(true)))
.to_token_stream()
.to_string(),
"satay_runtime :: into_json_request (parts)"
);
assert_eq!(
request_from_parts_expr(&operation_with_body(Some(false)))
.to_token_stream()
.to_string(),
"satay_runtime :: into_optional_json_request (parts)"
);
}
fn operation_with_body(required: Option<bool>) -> Operation {
Operation {
fn_name: "create_pet".to_owned(),
description: None,
input_name: "CreatePetInput".to_owned(),
response_name: "CreatePetResponse".to_owned(),
method: HttpMethod::Post,
path: "/pets".to_owned(),
path_segments: vec![PathSegment::Literal("/pets".to_owned())],
parameters: vec![],
request_body: required.map(|required| RequestBody {
field_name: "body".to_owned(),
description: None,
content_type: "application/json".to_owned(),
ty: TypeRef::Named("Pet".to_owned()),
required,
}),
responses: vec![],
}
}
fn type_path_is(ty: &syn::Type, expected: &str) -> bool {
let Type::Path(path) = ty else {
return false;
};
path.path.is_ident(expected)
}
fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
let Type::Path(path) = ty else {
return None;
};
let segment = path.path.segments.first()?;
if segment.ident != "Option" {
return None;
}
let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
return None;
};
let GenericArgument::Type(inner) = arguments.args.first()? else {
return None;
};
Some(inner)
}
}