torn_api_codegen/model/
path.rs

1use std::fmt::Write;
2
3use heck::{ToSnakeCase, ToUpperCamelCase};
4use indexmap::IndexMap;
5use proc_macro2::TokenStream;
6use quote::{format_ident, quote};
7use syn::Ident;
8
9use crate::openapi::{
10    parameter::OpenApiParameter,
11    path::{OpenApiPath, OpenApiPathParameter, OpenApiResponseBody},
12};
13
14use super::{
15    parameter::{Parameter, ParameterLocation, ParameterType},
16    union::Union,
17    ResolvedSchema,
18};
19
20#[derive(Debug, Clone)]
21pub enum PathSegment {
22    Constant(String),
23    Parameter { name: String },
24}
25
26#[derive(Debug, Clone)]
27pub enum PathParameter {
28    Inline(Parameter),
29    Component(Parameter),
30}
31
32#[derive(Debug, Clone)]
33pub enum PathResponse {
34    Component { name: String },
35    // TODO: needs to be implemented
36    ArbitraryUnion(Union),
37}
38
39#[derive(Debug, Clone)]
40pub struct Path {
41    pub segments: Vec<PathSegment>,
42    pub name: String,
43    pub summary: Option<String>,
44    pub description: Option<String>,
45    pub parameters: Vec<PathParameter>,
46    pub response: PathResponse,
47}
48
49impl Path {
50    pub fn from_schema(
51        path: &str,
52        schema: &OpenApiPath,
53        parameters: &IndexMap<&str, OpenApiParameter>,
54    ) -> Option<Self> {
55        let mut segments = Vec::new();
56        for segment in path.strip_prefix('/')?.split('/') {
57            if segment.starts_with('{') && segment.ends_with('}') {
58                segments.push(PathSegment::Parameter {
59                    name: segment[1..(segment.len() - 1)].to_owned(),
60                });
61            } else {
62                segments.push(PathSegment::Constant(segment.to_owned()));
63            }
64        }
65
66        let summary = schema.get.summary.as_deref().map(ToOwned::to_owned);
67        let description = schema.get.description.as_deref().map(ToOwned::to_owned);
68
69        let mut params = Vec::with_capacity(schema.get.parameters.len());
70        for parameter in &schema.get.parameters {
71            match &parameter {
72                OpenApiPathParameter::Link { ref_path } => {
73                    let name = ref_path
74                        .strip_prefix("#/components/parameters/")?
75                        .to_owned();
76                    let param = parameters.get(&name.as_str())?;
77                    params.push(PathParameter::Component(Parameter::from_schema(
78                        &name, param,
79                    )?));
80                }
81                OpenApiPathParameter::Inline(schema) => {
82                    let name = schema.name.to_upper_camel_case();
83                    let parameter = Parameter::from_schema(&name, schema)?;
84                    params.push(PathParameter::Inline(parameter));
85                }
86            };
87        }
88
89        let mut suffixes = vec![];
90        let mut name = String::new();
91
92        for seg in &segments {
93            match seg {
94                PathSegment::Constant(val) => {
95                    name.push_str(&val.to_upper_camel_case());
96                }
97                PathSegment::Parameter { name } => {
98                    suffixes.push(format!("For{}", name.to_upper_camel_case()));
99                }
100            }
101        }
102
103        for suffix in suffixes {
104            name.push_str(&suffix);
105        }
106
107        let response = match &schema.get.response_content {
108            OpenApiResponseBody::Schema(link) => PathResponse::Component {
109                name: link
110                    .ref_path
111                    .strip_prefix("#/components/schemas/")?
112                    .to_owned(),
113            },
114            OpenApiResponseBody::Union { any_of: _ } => PathResponse::ArbitraryUnion(
115                Union::from_schema("Response", &schema.get.response_content)?,
116            ),
117        };
118
119        Some(Self {
120            segments,
121            name,
122            summary,
123            description,
124            parameters: params,
125            response,
126        })
127    }
128
129    pub fn codegen_request(&self, resolved: &ResolvedSchema) -> Option<TokenStream> {
130        let name = if self.segments.len() == 1 {
131            let Some(PathSegment::Constant(first)) = self.segments.first() else {
132                return None;
133            };
134            format_ident!("{}Request", first.to_upper_camel_case())
135        } else {
136            format_ident!("{}Request", self.name)
137        };
138
139        let mut ns = PathNamespace {
140            path: self,
141            ident: None,
142            elements: Vec::new(),
143        };
144
145        let mut fields = Vec::with_capacity(self.parameters.len());
146        let mut convert_field = Vec::with_capacity(self.parameters.len());
147        let mut start_fields = Vec::new();
148        let mut discriminant = Vec::new();
149        let mut discriminant_val = Vec::new();
150        let mut fmt_val = Vec::new();
151
152        for param in &self.parameters {
153            let (is_inline, param) = match &param {
154                PathParameter::Inline(param) => (true, param),
155                PathParameter::Component(param) => (false, param),
156            };
157
158            let (ty, builder_param) = match &param.r#type {
159                ParameterType::I32 { .. } | ParameterType::Enum { .. } => {
160                    let ty_name = format_ident!("{}", param.name);
161
162                    if is_inline {
163                        ns.push_element(param.codegen(resolved)?);
164                        let path = ns.get_ident();
165
166                        (
167                            quote! {
168                                crate::request::models::#path::#ty_name
169                            },
170                            Some(quote! { #[cfg_attr(feature = "builder", builder(into))] }),
171                        )
172                    } else {
173                        (
174                            quote! {
175                                crate::parameters::#ty_name
176                            },
177                            Some(quote! { #[cfg_attr(feature = "builder", builder(into))]}),
178                        )
179                    }
180                }
181                ParameterType::String => (quote! { String }, None),
182                ParameterType::Boolean => (quote! { bool }, None),
183                ParameterType::Schema { type_name } => {
184                    let ty_name = format_ident!("{}", type_name);
185
186                    (
187                        quote! {
188                            crate::models::#ty_name
189                        },
190                        None,
191                    )
192                }
193                ParameterType::Array { .. } => {
194                    ns.push_element(param.codegen(resolved)?);
195                    let ty_name = param.r#type.codegen_type_name(&param.name);
196                    let path = ns.get_ident();
197                    (
198                        quote! {
199                            crate::request::models::#path::#ty_name
200                        },
201                        Some(quote! { #[cfg_attr(feature = "builder", builder(into))] }),
202                    )
203                }
204            };
205
206            let name = format_ident!("{}", param.name.to_snake_case());
207            let query_val = &param.value;
208
209            if param.location == ParameterLocation::Path {
210                discriminant.push(ty.clone());
211                discriminant_val.push(quote! { self.#name });
212                let path_name = format_ident!("{}", param.value);
213                start_fields.push(quote! {
214                    #[cfg_attr(feature = "builder", builder(start_fn))]
215                    #builder_param
216                    pub #name: #ty
217                });
218                fmt_val.push(quote! {
219                    #path_name=self.#name
220                });
221            } else {
222                let ty = if param.required {
223                    convert_field.push(quote! {
224                        .chain(std::iter::once(&self.#name).map(|v| (#query_val, v.to_string())))
225                    });
226                    ty
227                } else {
228                    convert_field.push(quote! {
229                        .chain(self.#name.as_ref().into_iter().map(|v| (#query_val, v.to_string())))
230                    });
231                    quote! { Option<#ty>}
232                };
233
234                fields.push(quote! {
235                    #builder_param
236                    pub #name: #ty
237                });
238            }
239        }
240
241        let response_ty = match &self.response {
242            PathResponse::Component { name } => {
243                let name = format_ident!("{name}");
244                quote! {
245                    crate::models::#name
246                }
247            }
248            PathResponse::ArbitraryUnion(union) => {
249                let path = ns.get_ident();
250                let ty_name = format_ident!("{}", union.name);
251
252                quote! {
253                    crate::request::models::#path::#ty_name
254                }
255            }
256        };
257
258        let mut path_fmt_str = String::new();
259        for seg in &self.segments {
260            match seg {
261                PathSegment::Constant(val) => _ = write!(path_fmt_str, "/{}", val),
262                PathSegment::Parameter { name } => _ = write!(path_fmt_str, "/{{{}}}", name),
263            }
264        }
265
266        if let PathResponse::ArbitraryUnion(union) = &self.response {
267            ns.push_element(union.codegen()?);
268        }
269
270        let ns = ns.codegen();
271
272        start_fields.extend(fields);
273
274        Some(quote! {
275            #ns
276
277            #[cfg_attr(feature = "builder", derive(bon::Builder))]
278            #[derive(Debug, Clone)]
279            #[cfg_attr(feature = "builder", builder(state_mod(vis = "pub(crate)"), on(String, into)))]
280            pub struct #name {
281                #(#start_fields),*
282            }
283
284            impl crate::request::IntoRequest for #name {
285                #[allow(unused_parens)]
286                type Discriminant = (#(#discriminant),*);
287                type Response = #response_ty;
288                fn into_request(self) -> (Self::Discriminant, crate::request::ApiRequest) {
289                    let path = format!(#path_fmt_str, #(#fmt_val),*);
290                    #[allow(unused_parens)]
291                    (
292                        (#(#discriminant_val),*),
293                        crate::request::ApiRequest {
294                            path,
295                            parameters: std::iter::empty()
296                                #(#convert_field)*
297                                .collect(),
298                        }
299                    )
300                }
301            }
302        })
303    }
304
305    pub fn codegen_scope_call(&self) -> Option<TokenStream> {
306        let mut extra_args = Vec::new();
307        let mut disc = Vec::new();
308
309        let snake_name = self.name.to_snake_case();
310
311        let request_name = format_ident!("{}Request", self.name);
312        let builder_name = format_ident!("{}RequestBuilder", self.name);
313        let builder_mod_name = format_ident!("{}_request_builder", snake_name);
314        let request_mod_name = format_ident!("{snake_name}");
315
316        let request_path = quote! { crate::request::models::#request_name };
317        let builder_path = quote! { crate::request::models::#builder_name };
318        let builder_mod_path = quote! { crate::request::models::#builder_mod_name };
319
320        let tail = snake_name
321            .split_once('_')
322            .map_or_else(|| "for_selections".to_owned(), |(_, tail)| tail.to_owned());
323
324        let fn_name = format_ident!("{tail}");
325
326        for param in &self.parameters {
327            let (param, is_inline) = match param {
328                PathParameter::Inline(param) => (param, true),
329                PathParameter::Component(param) => (param, false),
330            };
331
332            if param.location == ParameterLocation::Path {
333                let ty = match &param.r#type {
334                    ParameterType::I32 { .. } | ParameterType::Enum { .. } => {
335                        let ty_name = format_ident!("{}", param.name);
336
337                        if is_inline {
338                            quote! {
339                                crate::request::models::#request_mod_name::#ty_name
340                            }
341                        } else {
342                            quote! {
343                                crate::parameters::#ty_name
344                            }
345                        }
346                    }
347                    ParameterType::String => quote! { String },
348                    ParameterType::Boolean => quote! { bool },
349                    ParameterType::Schema { type_name } => {
350                        let ty_name = format_ident!("{}", type_name);
351
352                        quote! {
353                            crate::models::#ty_name
354                        }
355                    }
356                    ParameterType::Array { .. } => {
357                        let ty_name = param.r#type.codegen_type_name(&param.name);
358
359                        quote! {
360                            crate::request::models::#request_mod_name::#ty_name
361                        }
362                    }
363                };
364
365                let arg_name = format_ident!("{}", param.value.to_snake_case());
366
367                extra_args.push(quote! { #arg_name: #ty, });
368                disc.push(arg_name);
369            }
370        }
371
372        let response_ty = match &self.response {
373            PathResponse::Component { name } => {
374                let name = format_ident!("{name}");
375                quote! {
376                    crate::models::#name
377                }
378            }
379            PathResponse::ArbitraryUnion(union) => {
380                let name = format_ident!("{}", union.name);
381                quote! {
382                    crate::request::models::#request_mod_name::#name
383                }
384            }
385        };
386
387        let doc = match (&self.summary, &self.description) {
388            (Some(summary), Some(description)) => {
389                Some(format!("{summary}\n\n# Description\n{description}"))
390            }
391            (Some(summary), None) => Some(summary.clone()),
392            (None, Some(description)) => Some(format!("# Description\n{description}")),
393            (None, None) => None,
394        };
395
396        let doc = doc.map(|d| {
397            quote! {
398                #[doc = #d]
399            }
400        });
401
402        Some(quote! {
403            #doc
404            pub async fn #fn_name<S>(
405                self,
406                #(#extra_args)*
407                builder: impl FnOnce(
408                    #builder_path<#builder_mod_path::Empty>
409                ) -> #builder_path<S>,
410            ) -> Result<#response_ty, E::Error>
411            where
412                S: #builder_mod_path::IsComplete,
413            {
414                let r = builder(#request_path::builder(#(#disc),*)).build();
415
416                self.0.fetch(r).await
417            }
418        })
419    }
420
421    pub fn codegen_bulk_scope_call(&self) -> Option<TokenStream> {
422        let mut disc = Vec::new();
423        let mut disc_ty = Vec::new();
424
425        let snake_name = self.name.to_snake_case();
426
427        let request_name = format_ident!("{}Request", self.name);
428        let builder_name = format_ident!("{}RequestBuilder", self.name);
429        let builder_mod_name = format_ident!("{}_request_builder", snake_name);
430        let request_mod_name = format_ident!("{snake_name}");
431
432        let request_path = quote! { crate::request::models::#request_name };
433        let builder_path = quote! { crate::request::models::#builder_name };
434        let builder_mod_path = quote! { crate::request::models::#builder_mod_name };
435
436        let tail = snake_name
437            .split_once('_')
438            .map_or_else(|| "for_selections".to_owned(), |(_, tail)| tail.to_owned());
439
440        let fn_name = format_ident!("{tail}");
441
442        for param in &self.parameters {
443            let (param, is_inline) = match param {
444                PathParameter::Inline(param) => (param, true),
445                PathParameter::Component(param) => (param, false),
446            };
447
448            if param.location == ParameterLocation::Path {
449                let ty = match &param.r#type {
450                    ParameterType::I32 { .. } | ParameterType::Enum { .. } => {
451                        let ty_name = format_ident!("{}", param.name);
452
453                        if is_inline {
454                            quote! {
455                                crate::request::models::#request_mod_name::#ty_name
456                            }
457                        } else {
458                            quote! {
459                                crate::parameters::#ty_name
460                            }
461                        }
462                    }
463                    ParameterType::String => quote! { String },
464                    ParameterType::Boolean => quote! { bool },
465                    ParameterType::Schema { type_name } => {
466                        let ty_name = format_ident!("{}", type_name);
467
468                        quote! {
469                            crate::models::#ty_name
470                        }
471                    }
472                    ParameterType::Array { .. } => {
473                        let name = param.r#type.codegen_type_name(&param.name);
474                        quote! {
475                            crate::request::models::#request_mod_name::#name
476                        }
477                    }
478                };
479
480                let arg_name = format_ident!("{}", param.value.to_snake_case());
481
482                disc_ty.push(ty);
483                disc.push(arg_name);
484            }
485        }
486
487        if disc.is_empty() {
488            return None;
489        }
490
491        let response_ty = match &self.response {
492            PathResponse::Component { name } => {
493                let name = format_ident!("{name}");
494                quote! {
495                    crate::models::#name
496                }
497            }
498            PathResponse::ArbitraryUnion(union) => {
499                let name = format_ident!("{}", union.name);
500                quote! {
501                    crate::request::models::#request_mod_name::#name
502                }
503            }
504        };
505
506        let disc = if disc.len() > 1 {
507            quote! { (#(#disc),*) }
508        } else {
509            quote! { #(#disc),* }
510        };
511
512        let disc_ty = if disc_ty.len() > 1 {
513            quote! { (#(#disc_ty),*) }
514        } else {
515            quote! { #(#disc_ty),* }
516        };
517
518        let doc = match (&self.summary, &self.description) {
519            (Some(summary), Some(description)) => {
520                Some(format!("{summary}\n\n# Description\n{description}"))
521            }
522            (Some(summary), None) => Some(summary.clone()),
523            (None, Some(description)) => Some(format!("# Description\n{description}")),
524            (None, None) => None,
525        };
526
527        let doc = doc.map(|d| {
528            quote! {
529                #[doc = #d]
530            }
531        });
532
533        Some(quote! {
534            #doc
535            pub fn #fn_name<S, I, B>(
536                self,
537                ids: I,
538                builder: B
539            ) -> impl futures::Stream<Item = (#disc_ty, Result<#response_ty, E::Error>)>
540            where
541                I: IntoIterator<Item = #disc_ty>,
542                S: #builder_mod_path::IsComplete,
543                B: Fn(
544                    #builder_path<#builder_mod_path::Empty>
545                ) -> #builder_path<S>,
546            {
547                let requests = ids.into_iter()
548                    .map(move |#disc| builder(#request_path::builder(#disc)).build());
549
550                let executor = self.executor;
551                executor.fetch_many(requests)
552            }
553        })
554    }
555}
556
557pub struct PathNamespace<'r> {
558    path: &'r Path,
559    ident: Option<Ident>,
560    elements: Vec<TokenStream>,
561}
562
563impl PathNamespace<'_> {
564    pub fn get_ident(&mut self) -> Ident {
565        self.ident
566            .get_or_insert_with(|| {
567                let name = self.path.name.to_snake_case();
568                format_ident!("{name}")
569            })
570            .clone()
571    }
572
573    pub fn push_element(&mut self, el: TokenStream) {
574        self.elements.push(el);
575    }
576
577    pub fn codegen(mut self) -> Option<TokenStream> {
578        if self.elements.is_empty() {
579            None
580        } else {
581            let ident = self.get_ident();
582            let elements = self.elements;
583            Some(quote! {
584                pub mod #ident {
585                    #(#elements)*
586                }
587            })
588        }
589    }
590}
591
592#[cfg(test)]
593mod test {
594    use super::*;
595
596    use crate::openapi::schema::test::get_schema;
597
598    #[test]
599    fn resolve_paths() {
600        let schema = get_schema();
601
602        let mut paths = 0;
603        let mut unresolved = vec![];
604
605        for (name, desc) in &schema.paths {
606            paths += 1;
607            if Path::from_schema(name, desc, &schema.components.parameters).is_none() {
608                unresolved.push(name);
609            }
610        }
611
612        if !unresolved.is_empty() {
613            panic!(
614                "Failed to resolve {}/{} paths. Could not resolve [{}]",
615                unresolved.len(),
616                paths,
617                unresolved
618                    .into_iter()
619                    .map(|u| format!("`{u}`"))
620                    .collect::<Vec<_>>()
621                    .join(", ")
622            )
623        }
624    }
625
626    #[test]
627    fn codegen_paths() {
628        let schema = get_schema();
629        let resolved = ResolvedSchema::from_open_api(&schema);
630
631        let mut paths = 0;
632        let mut unresolved = vec![];
633
634        for (name, desc) in &schema.paths {
635            paths += 1;
636            let Some(path) = Path::from_schema(name, desc, &schema.components.parameters) else {
637                unresolved.push(name);
638                continue;
639            };
640
641            if path.codegen_scope_call().is_none() || path.codegen_request(&resolved).is_none() {
642                unresolved.push(name);
643            }
644        }
645
646        if !unresolved.is_empty() {
647            panic!(
648                "Failed to codegen {}/{} paths. Could not resolve [{}]",
649                unresolved.len(),
650                paths,
651                unresolved
652                    .into_iter()
653                    .map(|u| format!("`{u}`"))
654                    .collect::<Vec<_>>()
655                    .join(", ")
656            )
657        }
658    }
659}