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