1use proc_macro2::{Span, TokenStream};
2use quote::quote;
3use syn::{Ident, LitStr, parse_quote};
4use tracing::info;
5
6use crate::model::{Api, Field, IntegerType, Operation, ParseAs, RangeScalar, TypeRef};
7use crate::{GenerateOptions, RootModule};
8
9const PREAMBLE: &str = "\
10//! @generated by satay. Do not edit by hand.
11
12";
13
14#[derive(Debug)]
15pub struct GeneratedFile {
16 pub relative_path: String,
17 pub contents: String,
18}
19
20pub(crate) fn render_api(api: &Api, options: GenerateOptions) -> Vec<GeneratedFile> {
21 info!(
22 components = api.components.len(),
23 operations = api.operations.len(),
24 "rendering API"
25 );
26 let mut files = vec![];
27
28 let root_module = match options.root_module {
29 RootModule::ModRs => "mod.rs",
30 RootModule::LibRs => "lib.rs",
31 };
32 let top_mod = render_top_mod(api);
33 files.push(GeneratedFile {
34 relative_path: root_module.to_owned(),
35 contents: format_file(top_mod),
36 });
37
38 if !api.components.is_empty() || !api.constrained_types.is_empty() {
39 let types_file = types::render_types_file(api);
40 files.push(GeneratedFile {
41 relative_path: "types.rs".to_owned(),
42 contents: format_file(types_file),
43 });
44 }
45
46 let api_file = api::render_api_file(api);
47 files.push(GeneratedFile {
48 relative_path: "api.rs".to_owned(),
49 contents: format_file(api_file),
50 });
51
52 for operation in &api.operations {
53 let dir = &operation.fn_name;
54 let endpoint_mod = endpoint::render_endpoint_mod(operation);
55 files.push(GeneratedFile {
56 relative_path: format!("{dir}/mod.rs"),
57 contents: format_file(endpoint_mod),
58 });
59
60 let parts_file = endpoint::render_endpoint_parts_file(api, operation);
61 files.push(GeneratedFile {
62 relative_path: format!("{dir}/parts.rs"),
63 contents: format_file(parts_file),
64 });
65
66 let json_file = endpoint::render_endpoint_json_file(api, operation);
67 files.push(GeneratedFile {
68 relative_path: format!("{dir}/json.rs"),
69 contents: format_file(json_file),
70 });
71 }
72
73 info!(files = files.len(), "rendered API");
74 files
75}
76
77fn format_file(file: syn::File) -> String {
78 let code = prettyplease::unparse(&file);
79 let mut formatted = String::with_capacity(PREAMBLE.len() + code.len());
80 formatted.push_str(PREAMBLE);
81 formatted.push_str(&code);
82 formatted
83}
84
85fn render_top_mod(api: &Api) -> syn::File {
86 let mut items: Vec<syn::Item> = vec![];
87 let server_url = lit_str(&api.server_url);
88 items.push(parse_quote!(pub const SERVER_URL: &str = #server_url;));
89
90 let has_types = !api.components.is_empty() || !api.constrained_types.is_empty();
91 if has_types {
92 items.push(parse_quote!(
93 pub mod types;
94 ));
95 items.push(parse_quote!(
96 pub use types::*;
97 ));
98 }
99
100 items.push(parse_quote!(
101 #[cfg(feature = "json")]
102 mod api;
103 ));
104 items.push(parse_quote!(
105 #[cfg(feature = "json")]
106 pub use api::*;
107 ));
108
109 for operation in &api.operations {
110 let module = ident(&operation.fn_name);
111 items.push(parse_quote!(pub mod #module;));
112 items.push(parse_quote!(pub use #module::*;));
113 }
114
115 syn::File {
116 shebang: None,
117 attrs: vec![],
118 items,
119 }
120}
121
122pub fn ident(value: &str) -> Ident {
123 if let Some(raw) = value.strip_prefix("r#") {
124 Ident::new_raw(raw, Span::call_site())
125 } else {
126 Ident::new(value, Span::call_site())
127 }
128}
129
130pub fn lit_str(value: &str) -> LitStr {
131 LitStr::new(value, Span::call_site())
132}
133
134pub fn doc_attrs(description: Option<&str>) -> Vec<syn::Attribute> {
135 let Some(description) = description.filter(|description| !description.trim().is_empty()) else {
136 return vec![];
137 };
138
139 description
140 .lines()
141 .map(|line| {
142 let doc_line = if line.is_empty() {
143 String::new()
144 } else {
145 format!(" {line}")
146 };
147 let doc_line = lit_str(&doc_line);
148 parse_quote!(#[doc = #doc_line])
149 })
150 .collect()
151}
152
153pub fn rust_type(ty: &TypeRef) -> syn::Type {
154 match ty {
155 TypeRef::String => parse_quote!(String),
156 TypeRef::ParsedString(parse_as) | TypeRef::ParsedInteger(parse_as) => {
157 parse_as_rust_type(*parse_as)
158 }
159 TypeRef::Integer(integer_type) => integer_rust_type(*integer_type),
160 TypeRef::F32 => parse_quote!(f32),
161 TypeRef::F64 => parse_quote!(f64),
162 TypeRef::Bool => parse_quote!(bool),
163 TypeRef::Array(item) => {
164 let item = rust_type(item);
165 parse_quote!(Vec<#item>)
166 }
167 TypeRef::Range(range_type) => {
168 let name = ident(&range_type.rust_name);
169 parse_quote!(#name)
170 }
171 TypeRef::Named(name)
172 | TypeRef::Constrained {
173 rust_name: name, ..
174 } => {
175 let name = ident(name);
176 parse_quote!(#name)
177 }
178 TypeRef::Option(inner) => {
179 let inner = rust_type(inner);
180 parse_quote!(Option<#inner>)
181 }
182 }
183}
184
185pub fn range_scalar_rust_type(scalar: RangeScalar) -> syn::Type {
186 match scalar {
187 RangeScalar::Integer(integer_type) => integer_rust_type(integer_type),
188 RangeScalar::F32 => parse_quote!(f32),
189 RangeScalar::F64 => parse_quote!(f64),
190 }
191}
192
193pub fn integer_rust_type(integer_type: IntegerType) -> syn::Type {
194 match integer_type {
195 IntegerType::U8 => parse_quote!(u8),
196 IntegerType::U16 => parse_quote!(u16),
197 IntegerType::U32 => parse_quote!(u32),
198 IntegerType::U64 => parse_quote!(u64),
199 IntegerType::I8 => parse_quote!(i8),
200 IntegerType::I16 => parse_quote!(i16),
201 IntegerType::I32 => parse_quote!(i32),
202 IntegerType::I64 => parse_quote!(i64),
203 }
204}
205
206pub fn parse_as_rust_type(parse_as: ParseAs) -> syn::Type {
207 match parse_as {
208 ParseAs::U8 => parse_quote!(u8),
209 ParseAs::U16 => parse_quote!(u16),
210 ParseAs::U32 => parse_quote!(u32),
211 ParseAs::U64 => parse_quote!(u64),
212 ParseAs::I8 => parse_quote!(i8),
213 ParseAs::I16 => parse_quote!(i16),
214 ParseAs::I32 => parse_quote!(i32),
215 ParseAs::I64 => parse_quote!(i64),
216 ParseAs::F32 => parse_quote!(f32),
217 ParseAs::F64 => parse_quote!(f64),
218 ParseAs::Bool => parse_quote!(bool),
219 ParseAs::Date => parse_quote!(satay_runtime::Date),
220 ParseAs::NaiveDateTime => parse_quote!(satay_runtime::PrimitiveDateTime),
221 ParseAs::OffsetDateTime => parse_quote!(satay_runtime::OffsetDateTime),
222 ParseAs::UnixTime => parse_quote!(satay_runtime::OffsetDateTime),
223 ParseAs::Time => parse_quote!(satay_runtime::Time),
224 ParseAs::IntegerRange | ParseAs::NumberRange => {
225 unreachable!("range parse-as uses generated range types")
226 }
227 }
228}
229
230pub fn parse_as_string_serde_module(parse_as: ParseAs) -> &'static str {
231 match parse_as {
232 ParseAs::U8 => "satay_runtime::serde_string::as_u8",
233 ParseAs::U16 => "satay_runtime::serde_string::as_u16",
234 ParseAs::U32 => "satay_runtime::serde_string::as_u32",
235 ParseAs::U64 => "satay_runtime::serde_string::as_u64",
236 ParseAs::I8 => "satay_runtime::serde_string::as_i8",
237 ParseAs::I16 => "satay_runtime::serde_string::as_i16",
238 ParseAs::I32 => "satay_runtime::serde_string::as_i32",
239 ParseAs::I64 => "satay_runtime::serde_string::as_i64",
240 ParseAs::F32 => "satay_runtime::serde_string::as_f32",
241 ParseAs::F64 => "satay_runtime::serde_string::as_f64",
242 ParseAs::Bool => "satay_runtime::serde_string::as_bool",
243 ParseAs::Date => "satay_runtime::serde_string::as_date",
244 ParseAs::NaiveDateTime => "satay_runtime::serde_string::as_naive_datetime",
245 ParseAs::OffsetDateTime => "satay_runtime::serde_string::as_offset_datetime",
246 ParseAs::UnixTime => "satay_runtime::serde_string::as_unix_time",
247 ParseAs::Time => "satay_runtime::serde_string::as_time",
248 ParseAs::IntegerRange | ParseAs::NumberRange => {
249 unreachable!("range parse-as uses generated range types")
250 }
251 }
252}
253
254pub fn parse_as_integer_serde_module(parse_as: ParseAs) -> &'static str {
255 match parse_as {
256 ParseAs::Bool => "satay_runtime::serde_integer::as_bool",
257 ParseAs::UnixTime => "satay_runtime::serde_integer::as_unix_time",
258 ParseAs::U8
259 | ParseAs::U16
260 | ParseAs::U32
261 | ParseAs::U64
262 | ParseAs::I8
263 | ParseAs::I16
264 | ParseAs::I32
265 | ParseAs::I64
266 | ParseAs::F32
267 | ParseAs::F64
268 | ParseAs::Date
269 | ParseAs::NaiveDateTime
270 | ParseAs::OffsetDateTime
271 | ParseAs::Time
272 | ParseAs::IntegerRange
273 | ParseAs::NumberRange => unreachable!("only bool and unixtime can parse from integer"),
274 }
275}
276
277pub fn rust_field_type(ty: &TypeRef, required: bool, treat_error_as_none: bool) -> syn::Type {
278 if (required && !treat_error_as_none) || ty.is_option() {
279 rust_type(ty)
280 } else {
281 let ty = rust_type(ty);
282 parse_quote!(Option<#ty>)
283 }
284}
285
286pub fn input_fields(operation: &Operation) -> Vec<Field> {
287 let mut input_fields = Vec::with_capacity(
288 operation.parameters.len() + usize::from(operation.request_body.is_some()),
289 );
290 input_fields.extend(operation.parameters.iter().map(|parameter| Field {
291 wire_name: parameter.wire_name.clone(),
292 rust_name: parameter.rust_name.clone(),
293 description: parameter.description.clone(),
294 ty: parameter.ty.clone(),
295 required: parameter.required,
296 treat_error_as_none: false,
297 }));
298 if let Some(body) = &operation.request_body {
299 input_fields.push(Field {
300 wire_name: body.field_name.clone(),
301 rust_name: body.field_name.clone(),
302 description: body.description.clone(),
303 ty: body.ty.clone(),
304 required: body.required,
305 treat_error_as_none: false,
306 });
307 }
308
309 input_fields
310}
311
312pub fn input_setter_name(field: &Field) -> Ident {
313 if field.rust_name == "new" {
314 ident("with_new")
315 } else {
316 ident(&field.rust_name)
317 }
318}
319
320pub fn input_builder_arg_type(ty: &TypeRef) -> TokenStream {
321 if ty == &TypeRef::String {
322 quote!(impl Into<String>)
323 } else {
324 let ty = rust_type(ty);
325 quote!(#ty)
326 }
327}
328
329pub fn input_builder_value(value: TokenStream, ty: &TypeRef) -> TokenStream {
330 if ty == &TypeRef::String {
331 quote!(#value.into())
332 } else {
333 value
334 }
335}
336
337pub fn request_from_parts_expr(operation: &Operation) -> syn::Expr {
338 match &operation.request_body {
339 Some(body) if body.required => parse_quote!(satay_runtime::into_json_request(parts)),
340 Some(_) => parse_quote!(satay_runtime::into_optional_json_request(parts)),
341 None => parse_quote!(satay_runtime::into_empty_request(parts)),
342 }
343}
344
345pub fn input_field(field: &str) -> syn::Expr {
346 let field = ident(field);
347 parse_quote!(input.#field)
348}
349
350mod api;
351mod endpoint;
352mod types;
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::model::PathSegment;
358 use crate::model::{Component, ComponentKind, HttpMethod, RequestBody, ResponseCase};
359 use quote::{ToTokens, quote};
360 use syn::{Fields, GenericArgument, Item, PathArguments, Type};
361
362 #[test]
363 fn render_file_exposes_struct_ast_without_source_comparison() {
364 let api = Api::new(
365 String::new(),
366 vec![],
367 vec![Component {
368 rust_name: "Pet".to_owned(),
369 description: None,
370 kind: ComponentKind::Struct(vec![
371 Field {
372 wire_name: "id".to_owned(),
373 rust_name: "id".to_owned(),
374 description: None,
375 ty: TypeRef::String,
376 required: true,
377 treat_error_as_none: false,
378 },
379 Field {
380 wire_name: "tag_count".to_owned(),
381 rust_name: "tag_count".to_owned(),
382 description: None,
383 ty: TypeRef::Integer(IntegerType::I32),
384 required: false,
385 treat_error_as_none: false,
386 },
387 ]),
388 }],
389 vec![],
390 vec![],
391 );
392
393 let file = types::render_types_file(&api);
394 assert_eq!(file.items.len(), 1);
395 let Item::Struct(item) = &file.items[0] else {
396 panic!("expected struct item");
397 };
398 assert_eq!(item.ident, "Pet");
399 let Fields::Named(fields) = &item.fields else {
400 panic!("expected named fields");
401 };
402 assert_eq!(fields.named.len(), 2);
403
404 let mut fields = fields.named.iter();
405 let id = fields.next().expect("id field");
406 assert_eq!(id.ident.as_ref().expect("field ident"), "id");
407 assert!(type_path_is(&id.ty, "String"));
408
409 let tag_count = fields.next().expect("tag_count field");
410 assert_eq!(tag_count.ident.as_ref().expect("field ident"), "tag_count");
411 let Some(inner) = option_inner(&tag_count.ty) else {
412 panic!("optional field should render as Option<T>");
413 };
414 assert!(type_path_is(inner, "i32"));
415 }
416
417 #[test]
418 fn render_file_exposes_operation_items_without_source_comparison() {
419 let api = Api::new(
420 String::new(),
421 vec![],
422 vec![],
423 vec![],
424 vec![Operation {
425 fn_name: "create_pet".to_owned(),
426 description: None,
427 input_name: "CreatePetInput".to_owned(),
428 response_name: "CreatePetResponse".to_owned(),
429 method: HttpMethod::Post,
430 path: "/pets".to_owned(),
431 path_segments: vec![PathSegment::Literal("/pets".to_owned())],
432 parameters: vec![],
433 request_body: Some(RequestBody {
434 field_name: "body".to_owned(),
435 description: None,
436 content_type: "application/json".to_owned(),
437 ty: TypeRef::Named("Pet".to_owned()),
438 required: true,
439 }),
440 responses: vec![ResponseCase {
441 status: 201,
442 variant_name: "Created".to_owned(),
443 description: None,
444 body: Some(TypeRef::Named("Pet".to_owned())),
445 }],
446 }],
447 );
448
449 let files = render_api(&api, GenerateOptions::default());
450 assert!(files.iter().any(|f| f.relative_path == "mod.rs"));
451 assert!(files.iter().any(|f| f.relative_path == "create_pet/mod.rs"));
452 assert!(
453 files
454 .iter()
455 .any(|f| f.relative_path == "create_pet/parts.rs")
456 );
457 assert!(
458 files
459 .iter()
460 .any(|f| f.relative_path == "create_pet/json.rs")
461 );
462 }
463
464 #[test]
465 fn rust_field_type_wraps_optional_and_treat_error_as_none_fields() {
466 assert_eq!(
467 rust_field_type(&TypeRef::String, true, false)
468 .to_token_stream()
469 .to_string(),
470 "String"
471 );
472 assert_eq!(
473 rust_field_type(&TypeRef::String, false, false)
474 .to_token_stream()
475 .to_string(),
476 "Option < String >"
477 );
478 assert_eq!(
479 rust_field_type(&TypeRef::String, true, true)
480 .to_token_stream()
481 .to_string(),
482 "Option < String >"
483 );
484 assert_eq!(
485 rust_field_type(&TypeRef::Option(Box::new(TypeRef::String)), true, false)
486 .to_token_stream()
487 .to_string(),
488 "Option < String >"
489 );
490 }
491
492 #[test]
493 fn input_builder_arguments_convert_strings_only() {
494 assert_eq!(
495 input_builder_arg_type(&TypeRef::String).to_string(),
496 "impl Into < String >"
497 );
498 assert_eq!(
499 input_builder_arg_type(&TypeRef::Integer(IntegerType::I32)).to_string(),
500 "i32"
501 );
502 assert_eq!(
503 input_builder_value(quote!(value), &TypeRef::String).to_string(),
504 "value . into ()"
505 );
506 assert_eq!(
507 input_builder_value(quote!(value), &TypeRef::Integer(IntegerType::I32)).to_string(),
508 "value"
509 );
510 }
511
512 #[test]
513 fn request_conversion_mode_matches_body_requirement() {
514 assert_eq!(
515 request_from_parts_expr(&operation_with_body(None))
516 .to_token_stream()
517 .to_string(),
518 "satay_runtime :: into_empty_request (parts)"
519 );
520 assert_eq!(
521 request_from_parts_expr(&operation_with_body(Some(true)))
522 .to_token_stream()
523 .to_string(),
524 "satay_runtime :: into_json_request (parts)"
525 );
526 assert_eq!(
527 request_from_parts_expr(&operation_with_body(Some(false)))
528 .to_token_stream()
529 .to_string(),
530 "satay_runtime :: into_optional_json_request (parts)"
531 );
532 }
533
534 fn operation_with_body(required: Option<bool>) -> Operation {
535 Operation {
536 fn_name: "create_pet".to_owned(),
537 description: None,
538 input_name: "CreatePetInput".to_owned(),
539 response_name: "CreatePetResponse".to_owned(),
540 method: HttpMethod::Post,
541 path: "/pets".to_owned(),
542 path_segments: vec![PathSegment::Literal("/pets".to_owned())],
543 parameters: vec![],
544 request_body: required.map(|required| RequestBody {
545 field_name: "body".to_owned(),
546 description: None,
547 content_type: "application/json".to_owned(),
548 ty: TypeRef::Named("Pet".to_owned()),
549 required,
550 }),
551 responses: vec![],
552 }
553 }
554
555 fn type_path_is(ty: &syn::Type, expected: &str) -> bool {
556 let Type::Path(path) = ty else {
557 return false;
558 };
559 path.path.is_ident(expected)
560 }
561
562 fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
563 let Type::Path(path) = ty else {
564 return None;
565 };
566 let segment = path.path.segments.first()?;
567 if segment.ident != "Option" {
568 return None;
569 }
570 let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
571 return None;
572 };
573 let GenericArgument::Type(inner) = arguments.args.first()? else {
574 return None;
575 };
576 Some(inner)
577 }
578}