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