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