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