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