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
300/// Leaf module of `satay_runtime::serde_string` providing string-backed
301/// serde for this parse-as.
302pub 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
326/// Leaf module of `satay_runtime::serde_integer` providing integer-backed
327/// serde for this parse-as.
328pub 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}