Skip to main content

satay_codegen/render/
mod.rs

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        /// Default Upstream URL
107        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            /// Where all types, structs and enums are
114            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        /// Low-level request parts and response codecs, organized by operation.
182        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}