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