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(parse_as) | TypeRef::ParsedInteger(parse_as) => {
223 parse_as_rust_type(*parse_as)
224 }
225 TypeRef::Integer(integer_type) => integer_rust_type(*integer_type),
226 TypeRef::F32 => parse_quote!(f32),
227 TypeRef::F64 => parse_quote!(f64),
228 TypeRef::Bool => parse_quote!(bool),
229 TypeRef::Array(item) => {
230 let item = rust_type(item);
231 parse_quote!(Vec<#item>)
232 }
233 TypeRef::Map(value) => {
234 let value = rust_type(value);
235 parse_quote!(BTreeMap<String, #value>)
236 }
237 TypeRef::JsonValue => parse_quote!(satay_runtime::JsonValue),
238 TypeRef::Range(range_type) => {
239 let name = ident(&range_type.rust_name);
240 parse_quote!(#name)
241 }
242 TypeRef::Named(name)
243 | TypeRef::Constrained {
244 rust_name: name, ..
245 } => {
246 let name = ident(name);
247 parse_quote!(#name)
248 }
249 TypeRef::Option(inner) => {
250 let inner = rust_type(inner);
251 parse_quote!(Option<#inner>)
252 }
253 }
254}
255
256pub fn range_scalar_rust_type(scalar: RangeScalar) -> syn::Type {
257 match scalar {
258 RangeScalar::Integer(integer_type) => integer_rust_type(integer_type),
259 RangeScalar::F32 => parse_quote!(f32),
260 RangeScalar::F64 => parse_quote!(f64),
261 }
262}
263
264pub fn integer_rust_type(integer_type: IntegerType) -> syn::Type {
265 match integer_type {
266 IntegerType::U8 => parse_quote!(u8),
267 IntegerType::U16 => parse_quote!(u16),
268 IntegerType::U32 => parse_quote!(u32),
269 IntegerType::U64 => parse_quote!(u64),
270 IntegerType::I8 => parse_quote!(i8),
271 IntegerType::I16 => parse_quote!(i16),
272 IntegerType::I32 => parse_quote!(i32),
273 IntegerType::I64 => parse_quote!(i64),
274 }
275}
276
277pub fn parse_as_rust_type(parse_as: ParseAs) -> syn::Type {
278 match parse_as {
279 ParseAs::U8 => parse_quote!(u8),
280 ParseAs::U16 => parse_quote!(u16),
281 ParseAs::U32 => parse_quote!(u32),
282 ParseAs::U64 => parse_quote!(u64),
283 ParseAs::I8 => parse_quote!(i8),
284 ParseAs::I16 => parse_quote!(i16),
285 ParseAs::I32 => parse_quote!(i32),
286 ParseAs::I64 => parse_quote!(i64),
287 ParseAs::F32 => parse_quote!(f32),
288 ParseAs::F64 => parse_quote!(f64),
289 ParseAs::Bool => parse_quote!(bool),
290 ParseAs::Date => parse_quote!(satay_runtime::Date),
291 ParseAs::NaiveDateTime => parse_quote!(satay_runtime::PrimitiveDateTime),
292 ParseAs::OffsetDateTime => parse_quote!(satay_runtime::OffsetDateTime),
293 ParseAs::UnixTime => parse_quote!(satay_runtime::OffsetDateTime),
294 ParseAs::Time => parse_quote!(satay_runtime::Time),
295 ParseAs::IntegerRange | ParseAs::NumberRange => {
296 unreachable!("range parse-as uses generated range types")
297 }
298 }
299}
300
301pub fn parse_as_string_serde_module(parse_as: ParseAs) -> &'static str {
302 match parse_as {
303 ParseAs::U8 => "satay_runtime::serde_string::as_u8",
304 ParseAs::U16 => "satay_runtime::serde_string::as_u16",
305 ParseAs::U32 => "satay_runtime::serde_string::as_u32",
306 ParseAs::U64 => "satay_runtime::serde_string::as_u64",
307 ParseAs::I8 => "satay_runtime::serde_string::as_i8",
308 ParseAs::I16 => "satay_runtime::serde_string::as_i16",
309 ParseAs::I32 => "satay_runtime::serde_string::as_i32",
310 ParseAs::I64 => "satay_runtime::serde_string::as_i64",
311 ParseAs::F32 => "satay_runtime::serde_string::as_f32",
312 ParseAs::F64 => "satay_runtime::serde_string::as_f64",
313 ParseAs::Bool => "satay_runtime::serde_string::as_bool",
314 ParseAs::Date => "satay_runtime::serde_string::as_date",
315 ParseAs::NaiveDateTime => "satay_runtime::serde_string::as_naive_datetime",
316 ParseAs::OffsetDateTime => "satay_runtime::serde_string::as_offset_datetime",
317 ParseAs::UnixTime => "satay_runtime::serde_string::as_unix_time",
318 ParseAs::Time => "satay_runtime::serde_string::as_time",
319 ParseAs::IntegerRange | ParseAs::NumberRange => {
320 unreachable!("range parse-as uses generated range types")
321 }
322 }
323}
324
325pub fn parse_as_integer_serde_module(parse_as: ParseAs) -> &'static str {
326 match parse_as {
327 ParseAs::Bool => "satay_runtime::serde_integer::as_bool",
328 ParseAs::UnixTime => "satay_runtime::serde_integer::as_unix_time",
329 ParseAs::U8
330 | ParseAs::U16
331 | ParseAs::U32
332 | ParseAs::U64
333 | ParseAs::I8
334 | ParseAs::I16
335 | ParseAs::I32
336 | ParseAs::I64
337 | ParseAs::F32
338 | ParseAs::F64
339 | ParseAs::Date
340 | ParseAs::NaiveDateTime
341 | ParseAs::OffsetDateTime
342 | ParseAs::Time
343 | ParseAs::IntegerRange
344 | ParseAs::NumberRange => unreachable!("only bool and unixtime can parse from integer"),
345 }
346}
347
348pub fn rust_field_type(ty: &TypeRef, required: bool, treat_error_as_none: bool) -> syn::Type {
349 if (required && !treat_error_as_none) || ty.is_option() {
350 rust_type(ty)
351 } else {
352 let ty = rust_type(ty);
353 parse_quote!(Option<#ty>)
354 }
355}
356
357pub fn input_fields(operation: &Operation) -> Vec<Field> {
358 let mut input_fields = Vec::with_capacity(
359 operation.parameters.len() + usize::from(operation.request_body.is_some()),
360 );
361 input_fields.extend(operation.parameters.iter().map(|parameter| Field {
362 wire_name: parameter.wire_name.clone(),
363 identifier_words: None,
364 rust_name: parameter.rust_name.clone(),
365 description: parameter.description.clone(),
366 ty: parameter.ty.clone(),
367 required: parameter.required,
368 treat_error_as_none: false,
369 none_if: vec![],
370 }));
371 if let Some(body) = &operation.request_body {
372 input_fields.push(Field {
373 wire_name: body.field_name.clone(),
374 identifier_words: None,
375 rust_name: body.field_name.clone(),
376 description: body.description.clone(),
377 ty: body.ty.clone(),
378 required: body.required,
379 treat_error_as_none: false,
380 none_if: vec![],
381 });
382 }
383
384 input_fields
385}
386
387pub fn input_setter_name(field: &Field) -> Ident {
388 if field.rust_name == "new" {
389 ident("with_new")
390 } else {
391 ident(&field.rust_name)
392 }
393}
394
395pub fn input_builder_arg_type(ty: &TypeRef) -> TokenStream {
396 if ty == &TypeRef::String {
397 quote!(impl Into<String>)
398 } else {
399 let ty = rust_type(ty);
400 quote!(#ty)
401 }
402}
403
404pub fn input_builder_value(value: TokenStream, ty: &TypeRef) -> TokenStream {
405 if ty == &TypeRef::String {
406 quote!(#value.into())
407 } else {
408 value
409 }
410}
411
412pub fn request_from_parts_expr(operation: &Operation) -> syn::Expr {
413 match &operation.request_body {
414 Some(body) if body.required => parse_quote!(satay_runtime::into_json_request(parts)),
415 Some(_) => parse_quote!(satay_runtime::into_optional_json_request(parts)),
416 None => parse_quote!(satay_runtime::into_empty_request(parts)),
417 }
418}
419
420pub fn input_field(field: &str) -> syn::Expr {
421 let field = ident(field);
422 parse_quote!(input.#field)
423}
424
425mod api;
426mod endpoint;
427mod group;
428mod types;
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use crate::model::{
434 ApiGroup, Component, ComponentKind, GroupOperation, HttpMethod, RequestBody, ResponseCase,
435 };
436 use crate::model::{PathSegment, ResponseStatus};
437 use quote::{ToTokens, quote};
438 use syn::{Fields, GenericArgument, Item, PathArguments, Type};
439
440 #[test]
441 fn render_file_exposes_struct_ast_without_source_comparison() {
442 let api = Api::new(
443 String::new(),
444 vec![],
445 vec![Component {
446 rust_name: "Pet".to_owned(),
447 description: None,
448 kind: ComponentKind::Struct(vec![
449 Field {
450 wire_name: "id".to_owned(),
451 identifier_words: None,
452 rust_name: "id".to_owned(),
453 description: None,
454 ty: TypeRef::String,
455 required: true,
456 treat_error_as_none: false,
457 none_if: vec![],
458 },
459 Field {
460 wire_name: "tag_count".to_owned(),
461 identifier_words: None,
462 rust_name: "tag_count".to_owned(),
463 description: None,
464 ty: TypeRef::Integer(IntegerType::I32),
465 required: false,
466 treat_error_as_none: false,
467 none_if: vec![],
468 },
469 ]),
470 }],
471 vec![],
472 vec![],
473 vec![],
474 );
475
476 let file = types::render_types_file(&api);
477 assert_eq!(file.items.len(), 1);
478 let Item::Struct(item) = &file.items[0] else {
479 panic!("expected struct item");
480 };
481 assert_eq!(item.ident, "Pet");
482 let Fields::Named(fields) = &item.fields else {
483 panic!("expected named fields");
484 };
485 assert_eq!(fields.named.len(), 2);
486
487 let mut fields = fields.named.iter();
488 let id = fields.next().expect("id field");
489 assert_eq!(id.ident.as_ref().expect("field ident"), "id");
490 assert!(type_path_is(&id.ty, "String"));
491
492 let tag_count = fields.next().expect("tag_count field");
493 assert_eq!(tag_count.ident.as_ref().expect("field ident"), "tag_count");
494 let Some(inner) = option_inner(&tag_count.ty) else {
495 panic!("optional field should render as Option<T>");
496 };
497 assert!(type_path_is(inner, "i32"));
498 }
499
500 #[test]
501 fn render_file_exposes_operation_items_without_source_comparison() {
502 let api = Api::new(
503 String::new(),
504 vec![],
505 vec![],
506 vec![],
507 vec![ApiGroup {
508 wire_name: Some("pets".to_owned()),
509 rust_name: "pets".to_owned(),
510 description: None,
511 operations: vec![GroupOperation {
512 operation_index: 0,
513 method_name: "create_pet".to_owned(),
514 }],
515 }],
516 vec![Operation {
517 fn_name: "create_pet".to_owned(),
518 tags: vec!["pets".to_owned()],
519 description: None,
520 input_name: "CreatePetInput".to_owned(),
521 response_name: "CreatePetResponse".to_owned(),
522 method: HttpMethod::Post,
523 path: "/pets".to_owned(),
524 path_segments: vec![PathSegment::Literal("/pets".to_owned())],
525 parameters: vec![],
526 request_body: Some(RequestBody {
527 field_name: "body".to_owned(),
528 description: None,
529 content_type: "application/json".to_owned(),
530 ty: TypeRef::Named("Pet".to_owned()),
531 required: true,
532 }),
533 responses: vec![ResponseCase {
534 status: ResponseStatus::Exact(201),
535 variant_name: "Created".to_owned(),
536 description: None,
537 body: Some(TypeRef::Named("Pet".to_owned())),
538 projection: None,
539 }],
540 }],
541 );
542
543 let files = render_api(&api, GenerateOptions::default());
544 assert!(files.iter().any(|f| f.relative_path == "mod.rs"));
545 assert!(files.iter().any(|f| f.relative_path == "create_pet/mod.rs"));
546 assert!(files.iter().any(|f| f.relative_path == "pets.rs"));
547 assert!(
548 files
549 .iter()
550 .any(|f| f.relative_path == "create_pet/parts.rs")
551 );
552 assert!(
553 files
554 .iter()
555 .any(|f| f.relative_path == "create_pet/json.rs")
556 );
557 }
558
559 #[test]
560 fn rust_field_type_wraps_optional_and_treat_error_as_none_fields() {
561 assert_eq!(
562 rust_field_type(&TypeRef::String, true, false)
563 .to_token_stream()
564 .to_string(),
565 "String"
566 );
567 assert_eq!(
568 rust_field_type(&TypeRef::String, false, false)
569 .to_token_stream()
570 .to_string(),
571 "Option < String >"
572 );
573 assert_eq!(
574 rust_field_type(&TypeRef::String, true, true)
575 .to_token_stream()
576 .to_string(),
577 "Option < String >"
578 );
579 assert_eq!(
580 rust_field_type(&TypeRef::Option(Box::new(TypeRef::String)), true, false)
581 .to_token_stream()
582 .to_string(),
583 "Option < String >"
584 );
585 }
586
587 #[test]
588 fn input_builder_arguments_convert_strings_only() {
589 assert_eq!(
590 input_builder_arg_type(&TypeRef::String).to_string(),
591 "impl Into < String >"
592 );
593 assert_eq!(
594 input_builder_arg_type(&TypeRef::Integer(IntegerType::I32)).to_string(),
595 "i32"
596 );
597 assert_eq!(
598 input_builder_value(quote!(value), &TypeRef::String).to_string(),
599 "value . into ()"
600 );
601 assert_eq!(
602 input_builder_value(quote!(value), &TypeRef::Integer(IntegerType::I32)).to_string(),
603 "value"
604 );
605 }
606
607 #[test]
608 fn request_conversion_mode_matches_body_requirement() {
609 assert_eq!(
610 request_from_parts_expr(&operation_with_body(None))
611 .to_token_stream()
612 .to_string(),
613 "satay_runtime :: into_empty_request (parts)"
614 );
615 assert_eq!(
616 request_from_parts_expr(&operation_with_body(Some(true)))
617 .to_token_stream()
618 .to_string(),
619 "satay_runtime :: into_json_request (parts)"
620 );
621 assert_eq!(
622 request_from_parts_expr(&operation_with_body(Some(false)))
623 .to_token_stream()
624 .to_string(),
625 "satay_runtime :: into_optional_json_request (parts)"
626 );
627 }
628
629 fn operation_with_body(required: Option<bool>) -> Operation {
630 Operation {
631 fn_name: "create_pet".to_owned(),
632 tags: vec![],
633 description: None,
634 input_name: "CreatePetInput".to_owned(),
635 response_name: "CreatePetResponse".to_owned(),
636 method: HttpMethod::Post,
637 path: "/pets".to_owned(),
638 path_segments: vec![PathSegment::Literal("/pets".to_owned())],
639 parameters: vec![],
640 request_body: required.map(|required| RequestBody {
641 field_name: "body".to_owned(),
642 description: None,
643 content_type: "application/json".to_owned(),
644 ty: TypeRef::Named("Pet".to_owned()),
645 required,
646 }),
647 responses: vec![],
648 }
649 }
650
651 fn type_path_is(ty: &syn::Type, expected: &str) -> bool {
652 let Type::Path(path) = ty else {
653 return false;
654 };
655 path.path.is_ident(expected)
656 }
657
658 fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
659 let Type::Path(path) = ty else {
660 return None;
661 };
662 let segment = path.path.segments.first()?;
663 if segment.ident != "Option" {
664 return None;
665 }
666 let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
667 return None;
668 };
669 let GenericArgument::Type(inner) = arguments.args.first()? else {
670 return None;
671 };
672 Some(inner)
673 }
674}