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