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