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                        parameters.push((#query_val, self.#name.to_string()));
262                    });
263                    ty
264                } else {
265                    convert_field.push(quote! {
266                        if let Some(value) = &self.#name {
267                            parameters.push((#query_val, value.to_string()));
268                        }
269                    });
270                    quote! { Option<#ty>}
271                };
272
273                fields.push(quote! {
274                    #builder_param
275                    pub #name: #ty
276                });
277            }
278        }
279
280        let response_ty = match &self.response {
281            PathResponse::Component { name } => {
282                let name = format_ident!("{name}");
283                quote! {
284                    crate::models::#name
285                }
286            }
287            PathResponse::ArbitraryUnion(union) => {
288                let path = ns.get_ident();
289                let ty_name = format_ident!("{}", union.name);
290
291                quote! {
292                    crate::request::models::#path::#ty_name
293                }
294            }
295        };
296
297        let mut path_fmt_str = String::new();
298        for seg in &self.segments {
299            match seg {
300                PathSegment::Constant(val) => _ = write!(path_fmt_str, "/{val}"),
301                PathSegment::Parameter { name } => _ = write!(path_fmt_str, "/{{{name}}}"),
302            }
303        }
304
305        if let PathResponse::ArbitraryUnion(union) = &self.response {
306            ns.push_element(union.codegen()?);
307        }
308
309        let ns = ns.codegen();
310
311        start_fields.extend(fields);
312
313        Some(quote! {
314            #ns
315
316            #[cfg_attr(feature = "builder", derive(bon::Builder))]
317            #[derive(Debug, Clone)]
318            #[cfg_attr(feature = "builder", builder(state_mod(vis = "pub(crate)"), on(String, into)))]
319            pub struct #name {
320                #(#start_fields),*
321            }
322
323            impl crate::request::IntoRequest for #name {
324                #[allow(unused_parens)]
325                type Discriminant = (#(#discriminant),*);
326                type Response = #response_ty;
327                fn into_request(self) -> (Self::Discriminant, crate::request::ApiRequest) {
328                    let path = format!(#path_fmt_str, #(#fmt_val),*);
329                    let mut parameters = Vec::new();
330                    #(#convert_field)*
331
332                    #[allow(unused_parens)]
333                    (
334                        (#(#discriminant_val),*),
335                        crate::request::ApiRequest {
336                            path,
337                            parameters,
338                        }
339                    )
340                }
341            }
342        })
343    }
344
345    pub fn codegen_scope_call(&self) -> Option<TokenStream> {
346        let mut extra_args = Vec::new();
347        let mut disc = Vec::new();
348
349        let snake_name = self.name.to_snake_case();
350
351        let request_name = format_ident!("{}Request", self.name);
352        let builder_name = format_ident!("{}RequestBuilder", self.name);
353        let builder_mod_name = format_ident!("{}_request_builder", snake_name);
354        let request_mod_name = format_ident!("{snake_name}");
355
356        let request_path = quote! { crate::request::models::#request_name };
357        let builder_path = quote! { crate::request::models::#builder_name };
358        let builder_mod_path = quote! { crate::request::models::#builder_mod_name };
359
360        let tail = snake_name
361            .split_once('_')
362            .map_or_else(|| "for_selections".to_owned(), |(_, tail)| tail.to_owned());
363
364        let fn_name = format_ident!("{tail}");
365
366        for param in &self.parameters {
367            let (param, is_inline) = match param {
368                PathParameter::Inline(param) => (param, true),
369                PathParameter::Component(param) => (param, false),
370            };
371
372            if param.location == ParameterLocation::Path
373                && self.segments.iter().any(|s| {
374                    if let PathSegment::Parameter { name } = s {
375                        name == &param.value
376                    } else {
377                        false
378                    }
379                })
380            {
381                let ty = match &param.r#type {
382                    ParameterType::I32 { .. } | ParameterType::Enum { .. } => {
383                        let ty_name = format_ident!("{}", param.name);
384
385                        if is_inline {
386                            quote! {
387                                crate::request::models::#request_mod_name::#ty_name
388                            }
389                        } else {
390                            quote! {
391                                crate::parameters::#ty_name
392                            }
393                        }
394                    }
395                    ParameterType::String => quote! { String },
396                    ParameterType::Boolean => quote! { bool },
397                    ParameterType::Schema { type_name } => {
398                        let ty_name = format_ident!("{}", type_name);
399
400                        quote! {
401                            crate::models::#ty_name
402                        }
403                    }
404                    ParameterType::Array { .. } => {
405                        let ty_name = param.r#type.codegen_type_name(&param.name);
406
407                        quote! {
408                            crate::request::models::#request_mod_name::#ty_name
409                        }
410                    }
411                };
412
413                let arg_name = format_ident!("{}", param.value.to_snake_case());
414
415                extra_args.push(quote! { #arg_name: #ty, });
416                disc.push(arg_name);
417            }
418        }
419
420        let response_ty = match &self.response {
421            PathResponse::Component { name } => {
422                let name = format_ident!("{name}");
423                quote! {
424                    crate::models::#name
425                }
426            }
427            PathResponse::ArbitraryUnion(union) => {
428                let name = format_ident!("{}", union.name);
429                quote! {
430                    crate::request::models::#request_mod_name::#name
431                }
432            }
433        };
434
435        let doc = match (&self.summary, &self.description) {
436            (Some(summary), Some(description)) => {
437                Some(format!("{summary}\n\n# Description\n{description}"))
438            }
439            (Some(summary), None) => Some(summary.clone()),
440            (None, Some(description)) => Some(format!("# Description\n{description}")),
441            (None, None) => None,
442        };
443
444        let doc = doc.map(|d| {
445            quote! {
446                #[doc = #d]
447            }
448        });
449
450        Some(quote! {
451            #doc
452            pub async fn #fn_name<S>(
453                self,
454                #(#extra_args)*
455                builder: impl FnOnce(
456                    #builder_path<#builder_mod_path::Empty>
457                ) -> #builder_path<S>,
458            ) -> Result<#response_ty, E::Error>
459            where
460                S: #builder_mod_path::IsComplete,
461            {
462                let r = builder(#request_path::builder(#(#disc),*)).build();
463
464                self.0.fetch(r).await
465            }
466        })
467    }
468
469    pub fn codegen_bulk_scope_call(&self) -> Option<TokenStream> {
470        let mut disc = Vec::new();
471        let mut disc_ty = Vec::new();
472
473        let snake_name = self.name.to_snake_case();
474
475        let request_name = format_ident!("{}Request", self.name);
476        let builder_name = format_ident!("{}RequestBuilder", self.name);
477        let builder_mod_name = format_ident!("{}_request_builder", snake_name);
478        let request_mod_name = format_ident!("{snake_name}");
479
480        let request_path = quote! { crate::request::models::#request_name };
481        let builder_path = quote! { crate::request::models::#builder_name };
482        let builder_mod_path = quote! { crate::request::models::#builder_mod_name };
483
484        let tail = snake_name
485            .split_once('_')
486            .map_or_else(|| "for_selections".to_owned(), |(_, tail)| tail.to_owned());
487
488        let fn_name = format_ident!("{tail}");
489
490        for param in &self.parameters {
491            let (param, is_inline) = match param {
492                PathParameter::Inline(param) => (param, true),
493                PathParameter::Component(param) => (param, false),
494            };
495            if param.location == ParameterLocation::Path
496                && self.segments.iter().any(|s| {
497                    if let PathSegment::Parameter { name } = s {
498                        name == &param.value
499                    } else {
500                        false
501                    }
502                })
503            {
504                let ty = match &param.r#type {
505                    ParameterType::I32 { .. } | ParameterType::Enum { .. } => {
506                        let ty_name = format_ident!("{}", param.name);
507
508                        if is_inline {
509                            quote! {
510                                crate::request::models::#request_mod_name::#ty_name
511                            }
512                        } else {
513                            quote! {
514                                crate::parameters::#ty_name
515                            }
516                        }
517                    }
518                    ParameterType::String => quote! { String },
519                    ParameterType::Boolean => quote! { bool },
520                    ParameterType::Schema { type_name } => {
521                        let ty_name = format_ident!("{}", type_name);
522
523                        quote! {
524                            crate::models::#ty_name
525                        }
526                    }
527                    ParameterType::Array { .. } => {
528                        let name = param.r#type.codegen_type_name(&param.name);
529                        quote! {
530                            crate::request::models::#request_mod_name::#name
531                        }
532                    }
533                };
534
535                let arg_name = format_ident!("{}", param.value.to_snake_case());
536
537                disc_ty.push(ty);
538                disc.push(arg_name);
539            }
540        }
541
542        if disc.is_empty() {
543            return None;
544        }
545
546        let response_ty = match &self.response {
547            PathResponse::Component { name } => {
548                let name = format_ident!("{name}");
549                quote! {
550                    crate::models::#name
551                }
552            }
553            PathResponse::ArbitraryUnion(union) => {
554                let name = format_ident!("{}", union.name);
555                quote! {
556                    crate::request::models::#request_mod_name::#name
557                }
558            }
559        };
560
561        let disc = if disc.len() > 1 {
562            quote! { (#(#disc),*) }
563        } else {
564            quote! { #(#disc),* }
565        };
566
567        let disc_ty = if disc_ty.len() > 1 {
568            quote! { (#(#disc_ty),*) }
569        } else {
570            quote! { #(#disc_ty),* }
571        };
572
573        let doc = match (&self.summary, &self.description) {
574            (Some(summary), Some(description)) => {
575                Some(format!("{summary}\n\n# Description\n{description}"))
576            }
577            (Some(summary), None) => Some(summary.clone()),
578            (None, Some(description)) => Some(format!("# Description\n{description}")),
579            (None, None) => None,
580        };
581
582        let doc = doc.map(|d| {
583            quote! {
584                #[doc = #d]
585            }
586        });
587
588        Some(quote! {
589            #doc
590            pub fn #fn_name<S, I, B>(
591                self,
592                ids: I,
593                builder: B
594            ) -> impl futures::Stream<Item = (#disc_ty, Result<#response_ty, E::Error>)>
595            where
596                I: IntoIterator<Item = #disc_ty>,
597                S: #builder_mod_path::IsComplete,
598                B: Fn(
599                    #builder_path<#builder_mod_path::Empty>
600                ) -> #builder_path<S>,
601            {
602                let requests = ids.into_iter()
603                    .map(move |#disc| builder(#request_path::builder(#disc)).build());
604
605                let executor = self.executor;
606                executor.fetch_many(requests)
607            }
608        })
609    }
610}
611
612pub struct PathNamespace<'r> {
613    path: &'r Path,
614    ident: Option<Ident>,
615    elements: Vec<TokenStream>,
616}
617
618impl PathNamespace<'_> {
619    pub fn get_ident(&mut self) -> Ident {
620        self.ident
621            .get_or_insert_with(|| {
622                let name = self.path.name.to_snake_case();
623                format_ident!("{name}")
624            })
625            .clone()
626    }
627
628    pub fn push_element(&mut self, el: TokenStream) {
629        self.elements.push(el);
630    }
631
632    pub fn codegen(mut self) -> Option<TokenStream> {
633        if self.elements.is_empty() {
634            None
635        } else {
636            let ident = self.get_ident();
637            let elements = self.elements;
638            Some(quote! {
639                pub mod #ident {
640                    #(#elements)*
641                }
642            })
643        }
644    }
645}
646
647#[cfg(test)]
648mod test {
649    use super::*;
650
651    use crate::openapi::schema::test::get_schema;
652
653    #[test]
654    fn resolve_paths() {
655        let schema = get_schema();
656
657        let mut paths = 0;
658        let mut unresolved = vec![];
659
660        for (name, desc) in &schema.paths {
661            paths += 1;
662            if Path::from_schema(
663                name,
664                desc,
665                &schema.components.parameters,
666                WarningReporter::new(),
667            )
668            .is_none()
669            {
670                unresolved.push(name);
671            }
672        }
673
674        if !unresolved.is_empty() {
675            panic!(
676                "Failed to resolve {}/{} paths. Could not resolve [{}]",
677                unresolved.len(),
678                paths,
679                unresolved
680                    .into_iter()
681                    .map(|u| format!("`{u}`"))
682                    .collect::<Vec<_>>()
683                    .join(", ")
684            )
685        }
686    }
687
688    #[test]
689    fn codegen_paths() {
690        let schema = get_schema();
691        let resolved = ResolvedSchema::from_open_api(&schema);
692        let reporter = WarningReporter::new();
693
694        let mut paths = 0;
695        let mut unresolved = vec![];
696
697        for (name, desc) in &schema.paths {
698            paths += 1;
699            let Some(path) =
700                Path::from_schema(name, desc, &schema.components.parameters, reporter.clone())
701            else {
702                unresolved.push(name);
703                continue;
704            };
705
706            if path.codegen_scope_call().is_none()
707                || path.codegen_request(&resolved, reporter.clone()).is_none()
708            {
709                unresolved.push(name);
710            }
711        }
712
713        if !unresolved.is_empty() {
714            panic!(
715                "Failed to codegen {}/{} paths. Could not resolve [{}]",
716                unresolved.len(),
717                paths,
718                unresolved
719                    .into_iter()
720                    .map(|u| format!("`{u}`"))
721                    .collect::<Vec<_>>()
722                    .join(", ")
723            )
724        }
725    }
726}