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