torn_api_codegen/model/
path.rs

1use std::{fmt::Write, ops::Deref};
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: 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.deref().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 = 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                        quote! {
166                            crate::request::models::#path::#ty_name
167                        }
168                    } else {
169                        quote! {
170                            crate::parameters::#ty_name
171                        }
172                    }
173                }
174                ParameterType::String => quote! { String },
175                ParameterType::Boolean => quote! { bool },
176                ParameterType::Schema { type_name } => {
177                    let ty_name = format_ident!("{}", type_name);
178
179                    quote! {
180                        crate::models::#ty_name
181                    }
182                }
183                ParameterType::Array { .. } => {
184                    ns.push_element(param.codegen()?);
185                    let ty_name = param.r#type.codegen_type_name(&param.name);
186                    let path = ns.get_ident();
187                    quote! {
188                        crate::request::models::#path::#ty_name
189                    }
190                }
191            };
192
193            let name = format_ident!("{}", param.name.to_snake_case());
194            let query_val = &param.value;
195
196            if param.location == ParameterLocation::Path {
197                discriminant.push(ty.clone());
198                discriminant_val.push(quote! { self.#name });
199                let path_name = format_ident!("{}", param.value);
200                start_fields.push(quote! {
201                    #[builder(start_fn)]
202                    pub #name: #ty
203                });
204                fmt_val.push(quote! {
205                    #path_name=self.#name
206                });
207            } else {
208                let ty = if param.required {
209                    convert_field.push(quote! {
210                        .chain(std::iter::once(&self.#name).map(|v| (#query_val, v.to_string())))
211                    });
212                    ty
213                } else {
214                    convert_field.push(quote! {
215                        .chain(self.#name.as_ref().into_iter().map(|v| (#query_val, v.to_string())))
216                    });
217                    quote! { Option<#ty>}
218                };
219
220                fields.push(quote! {
221                    pub #name: #ty
222                });
223            }
224        }
225
226        let response_ty = match &self.response {
227            PathResponse::Component { name } => {
228                let name = format_ident!("{name}");
229                quote! {
230                    crate::models::#name
231                }
232            }
233            PathResponse::ArbitraryUnion(union) => {
234                let path = ns.get_ident();
235                let ty_name = format_ident!("{}", union.name);
236
237                quote! {
238                    crate::request::models::#path::#ty_name
239                }
240            }
241        };
242
243        let mut path_fmt_str = String::new();
244        for seg in &self.segments {
245            match seg {
246                PathSegment::Constant(val) => _ = write!(path_fmt_str, "/{}", val),
247                PathSegment::Parameter { name } => _ = write!(path_fmt_str, "/{{{}}}", name),
248            }
249        }
250
251        if let PathResponse::ArbitraryUnion(union) = &self.response {
252            ns.push_element(union.codegen()?);
253        }
254
255        let ns = ns.codegen();
256
257        start_fields.extend(fields);
258
259        Some(quote! {
260            #ns
261
262            #[derive(Debug, Clone, bon::Builder)]
263            #[builder(state_mod(vis = "pub(crate)"))]
264            pub struct #name {
265                #(#start_fields),*
266            }
267
268            impl crate::request::IntoRequest for #name {
269                #[allow(unused_parens)]
270                type Discriminant = (#(#discriminant),*);
271                type Response = #response_ty;
272                fn into_request(self) -> crate::request::ApiRequest<Self::Discriminant> {
273                    #[allow(unused_parens)]
274                    crate::request::ApiRequest {
275                        path: format!(#path_fmt_str, #(#fmt_val),*),
276                        parameters: std::iter::empty()
277                            #(#convert_field)*
278                            .collect(),
279                        disriminant:  (#(#discriminant_val),*),
280                    }
281                }
282            }
283        })
284    }
285
286    pub fn codegen_scope_call(&self) -> Option<TokenStream> {
287        let mut extra_args = Vec::new();
288        let mut disc = Vec::new();
289
290        let snake_name = self.name.to_snake_case();
291
292        let request_name = format_ident!("{}Request", self.name);
293        let builder_name = format_ident!("{}RequestBuilder", self.name);
294        let builder_mod_name = format_ident!("{}_request_builder", snake_name);
295        let request_mod_name = format_ident!("{snake_name}");
296
297        let request_path = quote! { crate::request::models::#request_name };
298        let builder_path = quote! { crate::request::models::#builder_name };
299        let builder_mod_path = quote! { crate::request::models::#builder_mod_name };
300
301        let tail = snake_name
302            .split_once('_')
303            .map_or_else(|| "for_selections".to_owned(), |(_, tail)| tail.to_owned());
304
305        let fn_name = format_ident!("{tail}");
306
307        for param in &self.parameters {
308            let (param, is_inline) = match param {
309                PathParameter::Inline(param) => (param, true),
310                PathParameter::Component(param) => (param, false),
311            };
312
313            if param.location == ParameterLocation::Path {
314                let ty = match &param.r#type {
315                    ParameterType::I32 { .. } | ParameterType::Enum { .. } => {
316                        let ty_name = format_ident!("{}", param.name);
317
318                        if is_inline {
319                            quote! {
320                                crate::request::models::#request_mod_name::#ty_name
321                            }
322                        } else {
323                            quote! {
324                                crate::parameters::#ty_name
325                            }
326                        }
327                    }
328                    ParameterType::String => quote! { String },
329                    ParameterType::Boolean => quote! { bool },
330                    ParameterType::Schema { type_name } => {
331                        let ty_name = format_ident!("{}", type_name);
332
333                        quote! {
334                            crate::models::#ty_name
335                        }
336                    }
337                    ParameterType::Array { .. } => param.r#type.codegen_type_name(&param.name),
338                };
339
340                let arg_name = format_ident!("{}", param.value.to_snake_case());
341
342                extra_args.push(quote! { #arg_name: #ty, });
343                disc.push(arg_name);
344            }
345        }
346
347        let response_ty = match &self.response {
348            PathResponse::Component { name } => {
349                let name = format_ident!("{name}");
350                quote! {
351                    crate::models::#name
352                }
353            }
354            PathResponse::ArbitraryUnion(union) => {
355                let name = format_ident!("{}", union.name);
356                quote! {
357                    crate::request::models::#request_mod_name::#name
358                }
359            }
360        };
361
362        Some(quote! {
363            pub async fn #fn_name<S>(
364                &self,
365                #(#extra_args)*
366                builder: impl FnOnce(
367                    #builder_path<#builder_mod_path::Empty>
368                ) -> #builder_path<S>,
369            ) -> Result<#response_ty, E::Error>
370            where
371                S: #builder_mod_path::IsComplete,
372            {
373                let r = builder(#request_path::builder(#(#disc),*)).build();
374
375                self.0.fetch(r).await
376            }
377        })
378    }
379}
380
381pub struct PathNamespace<'r> {
382    path: &'r Path,
383    ident: Option<Ident>,
384    elements: Vec<TokenStream>,
385}
386
387impl PathNamespace<'_> {
388    pub fn get_ident(&mut self) -> Ident {
389        self.ident
390            .get_or_insert_with(|| {
391                let name = self.path.name.to_snake_case();
392                format_ident!("{name}")
393            })
394            .clone()
395    }
396
397    pub fn push_element(&mut self, el: TokenStream) {
398        self.elements.push(el);
399    }
400
401    pub fn codegen(mut self) -> Option<TokenStream> {
402        if self.elements.is_empty() {
403            None
404        } else {
405            let ident = self.get_ident();
406            let elements = self.elements;
407            Some(quote! {
408                pub mod #ident {
409                    #(#elements)*
410                }
411            })
412        }
413    }
414}
415
416#[cfg(test)]
417mod test {
418    use super::*;
419
420    use crate::openapi::schema::OpenApiSchema;
421
422    #[test]
423    fn resolve_paths() {
424        let schema = OpenApiSchema::read().unwrap();
425
426        let mut paths = 0;
427        let mut unresolved = vec![];
428
429        for (name, desc) in &schema.paths {
430            paths += 1;
431            if Path::from_schema(name, desc, &schema.components.parameters).is_none() {
432                unresolved.push(name);
433            }
434        }
435
436        if !unresolved.is_empty() {
437            panic!(
438                "Failed to resolve {}/{} paths. Could not resolve [{}]",
439                unresolved.len(),
440                paths,
441                unresolved
442                    .into_iter()
443                    .map(|u| format!("`{u}`"))
444                    .collect::<Vec<_>>()
445                    .join(", ")
446            )
447        }
448    }
449
450    #[test]
451    fn codegen_paths() {
452        let schema = OpenApiSchema::read().unwrap();
453
454        let mut paths = 0;
455        let mut unresolved = vec![];
456
457        for (name, desc) in &schema.paths {
458            paths += 1;
459            let Some(path) = Path::from_schema(name, desc, &schema.components.parameters) else {
460                unresolved.push(name);
461                continue;
462            };
463
464            if path.codegen_scope_call().is_none() || path.codegen_request().is_none() {
465                unresolved.push(name);
466            }
467        }
468
469        if !unresolved.is_empty() {
470            panic!(
471                "Failed to codegen {}/{} paths. Could not resolve [{}]",
472                unresolved.len(),
473                paths,
474                unresolved
475                    .into_iter()
476                    .map(|u| format!("`{u}`"))
477                    .collect::<Vec<_>>()
478                    .join(", ")
479            )
480        }
481    }
482}