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