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