Skip to main content

ploidy_codegen_rust/
resource.rs

1use ploidy_core::{codegen::IntoCode, ir::OperationView};
2use proc_macro2::TokenStream;
3use quote::{ToTokens, TokenStreamExt, format_ident, quote};
4
5use super::{
6    cfg::CfgFeature,
7    inlines::CodegenInlines,
8    naming::{CargoFeature, CodegenIdent, CodegenIdentUsage},
9    operation::CodegenOperation,
10    query::CodegenQueryParameters,
11};
12
13/// Generates an `impl Client` block for a feature-gated resource,
14/// with all its operations and inline types.
15pub struct CodegenResource<'a> {
16    feature: &'a CargoFeature,
17    ops: &'a [OperationView<'a>],
18}
19
20impl<'a> CodegenResource<'a> {
21    pub fn new(feature: &'a CargoFeature, ops: &'a [OperationView<'a>]) -> Self {
22        Self { feature, ops }
23    }
24}
25
26impl ToTokens for CodegenResource<'_> {
27    #[allow(
28        clippy::filter_map_bool_then,
29        reason = "`filter_map` + `then` reads cleaner here"
30    )]
31    fn to_tokens(&self, tokens: &mut TokenStream) {
32        // Each method gets its own `#[cfg(...)]` attribute.
33        let methods = self.ops.iter().map(|view| {
34            let cfg = CfgFeature::for_operation(view);
35            let method = CodegenOperation::new(view).into_token_stream();
36            quote! {
37                #cfg
38                #method
39            }
40        });
41        let inlines = CodegenInlines::Resource(self.ops);
42
43        let params = self
44            .ops
45            .iter()
46            .filter_map(|op| {
47                // Collect query parameter structs for operations
48                // that have at least one query parameter.
49                op.query().next().is_some().then(|| {
50                    let cfg = CfgFeature::for_operation(op);
51                    let query = CodegenQueryParameters::new(op);
52                    let op_ident = CodegenIdent::new(op.id());
53                    let mod_name = format_ident!("{}_query", CodegenIdentUsage::Module(&op_ident));
54                    quote! {
55                        #cfg
56                        mod #mod_name {
57                            #query
58                        }
59                        #cfg
60                        pub use #mod_name::*;
61                    }
62                })
63            })
64            .reduce(|a, b| quote!(#a #b))
65            .map(|params| {
66                quote! {
67                    pub mod parameters {
68                        #params
69                    }
70                }
71            });
72
73        tokens.append_all(quote! {
74            impl crate::client::Client {
75                #(#methods)*
76            }
77            #params
78            #inlines
79        });
80    }
81}
82
83impl IntoCode for CodegenResource<'_> {
84    type Code = (String, TokenStream);
85
86    fn into_code(self) -> Self::Code {
87        (
88            format!(
89                "src/client/{}.rs",
90                CodegenIdentUsage::Module(self.feature.as_ident()).display()
91            ),
92            self.into_token_stream(),
93        )
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    use itertools::Itertools;
102    use ploidy_core::{
103        arena::Arena,
104        ir::{RawGraph, Spec},
105        parse::Document,
106    };
107    use pretty_assertions::assert_eq;
108    use syn::parse_quote;
109
110    use crate::{graph::CodegenGraph, naming::CargoFeature};
111
112    // MARK: Feature gating
113
114    #[test]
115    fn test_operation_method_with_only_unnamed_deps_has_no_cfg() {
116        let doc = Document::from_yaml(indoc::indoc! {"
117            openapi: 3.0.0
118            info:
119              title: Test
120              version: 1.0.0
121            paths:
122              /customers:
123                get:
124                  operationId: listCustomers
125                  x-resource-name: customer
126                  responses:
127                    '200':
128                      description: OK
129                      content:
130                        application/json:
131                          schema:
132                            type: array
133                            items:
134                              $ref: '#/components/schemas/Customer'
135            components:
136              schemas:
137                Customer:
138                  type: object
139                  properties:
140                    address:
141                      $ref: '#/components/schemas/Address'
142                Address:
143                  type: object
144                  properties:
145                    street:
146                      type: string
147        "})
148        .unwrap();
149
150        let arena = Arena::new();
151        let spec = Spec::from_doc(&arena, &doc).unwrap();
152        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
153
154        let ops = graph.operations().collect_vec();
155        let feature = CargoFeature::from_name("customer");
156        let resource = CodegenResource::new(&feature, &ops);
157
158        // No `#[cfg(...)]` on the method because none of its
159        // dependencies have an `x-resourceId`.
160        let actual: syn::File = parse_quote!(#resource);
161        let expected: syn::File = parse_quote! {
162            impl crate::client::Client {
163                pub async fn list_customers(
164                    &self,
165                ) -> Result<::std::vec::Vec<crate::types::Customer>, crate::error::Error> {
166                    let url = {
167                        let mut url = self.base_url.clone();
168                        let _ = url
169                            .path_segments_mut()
170                            .map(|mut segments| {
171                                segments.pop_if_empty()
172                                    .push("customers");
173                            });
174                        url
175                    };
176                    let response = self
177                        .client
178                        .get(url)
179                        .headers(self.headers.clone())
180                        .send()
181                        .await?
182                        .error_for_status()?;
183                    let body = response.bytes().await?;
184                    let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
185                    let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
186                        .map_err(crate::error::JsonError::from)?;
187                    Ok(result)
188                }
189            }
190        };
191        assert_eq!(actual, expected);
192    }
193
194    #[test]
195    fn test_operation_method_with_named_deps_has_cfg() {
196        let doc = Document::from_yaml(indoc::indoc! {"
197            openapi: 3.0.0
198            info:
199              title: Test
200              version: 1.0.0
201            paths:
202              /orders:
203                get:
204                  operationId: listOrders
205                  x-resource-name: orders
206                  responses:
207                    '200':
208                      description: OK
209                      content:
210                        application/json:
211                          schema:
212                            type: array
213                            items:
214                              $ref: '#/components/schemas/Order'
215            components:
216              schemas:
217                Order:
218                  type: object
219                  properties:
220                    customer:
221                      $ref: '#/components/schemas/Customer'
222                Customer:
223                  type: object
224                  x-resourceId: customer
225                  properties:
226                    id:
227                      type: string
228        "})
229        .unwrap();
230
231        let arena = Arena::new();
232        let spec = Spec::from_doc(&arena, &doc).unwrap();
233        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
234
235        let ops = graph.operations().collect_vec();
236        let feature = CargoFeature::from_name("orders");
237        let resource = CodegenResource::new(&feature, &ops);
238
239        // `#[cfg(feature = "customer")]` because `Order` depends on
240        // `Customer`, which has `x-resourceId: customer`.
241        let actual: syn::File = parse_quote!(#resource);
242        let expected: syn::File = parse_quote! {
243            impl crate::client::Client {
244                #[cfg(feature = "customer")]
245                pub async fn list_orders(
246                    &self,
247                ) -> Result<::std::vec::Vec<crate::types::Order>, crate::error::Error> {
248                    let url = {
249                        let mut url = self.base_url.clone();
250                        let _ = url
251                            .path_segments_mut()
252                            .map(|mut segments| {
253                                segments.pop_if_empty()
254                                    .push("orders");
255                            });
256                        url
257                    };
258                    let response = self
259                        .client
260                        .get(url)
261                        .headers(self.headers.clone())
262                        .send()
263                        .await?
264                        .error_for_status()?;
265                    let body = response.bytes().await?;
266                    let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
267                    let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
268                        .map_err(crate::error::JsonError::from)?;
269                    Ok(result)
270                }
271            }
272        };
273        assert_eq!(actual, expected);
274    }
275
276    // MARK: Parameters module
277
278    #[test]
279    fn test_resource_emits_parameters_module() {
280        let doc = Document::from_yaml(indoc::indoc! {"
281            openapi: 3.0.0
282            info:
283              title: Test
284              version: 1.0.0
285            paths:
286              /customers:
287                get:
288                  operationId: listCustomers
289                  x-resource-name: customer
290                  parameters:
291                    - name: limit
292                      in: query
293                      schema:
294                        type: integer
295                        format: int32
296                  responses:
297                    '200':
298                      description: OK
299        "})
300        .unwrap();
301
302        let arena = Arena::new();
303        let spec = Spec::from_doc(&arena, &doc).unwrap();
304        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
305
306        let ops = graph.operations().collect_vec();
307        let feature = CargoFeature::from_name("customer");
308        let resource = CodegenResource::new(&feature, &ops);
309
310        let actual: syn::File = parse_quote!(#resource);
311        let expected: syn::File = parse_quote! {
312            impl crate::client::Client {
313                pub async fn list_customers(
314                    &self,
315                    query: &parameters::ListCustomersQuery
316                ) -> Result<(), crate::error::Error> {
317                    let url = {
318                        let mut url = self.base_url.clone();
319                        let _ = url
320                            .path_segments_mut()
321                            .map(|mut segments| {
322                                segments.pop_if_empty()
323                                    .push("customers");
324                            });
325                        url
326                    };
327                    let url = ::ploidy_util::serde::Serialize::serialize(
328                        query,
329                        ::ploidy_util::QuerySerializer::new(
330                            url,
331                            parameters::ListCustomersQuery::STYLES,
332                        ),
333                    )?;
334                    let response = self
335                        .client
336                        .get(url)
337                        .headers(self.headers.clone())
338                        .send()
339                        .await?
340                        .error_for_status()?;
341                    let _ = response;
342                    Ok(())
343                }
344            }
345            pub mod parameters {
346                mod list_customers_query {
347                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
348                    #[serde(crate = "::ploidy_util::serde")]
349                    pub struct ListCustomersQuery {
350                        #[serde(default, skip_serializing_if = "Option::is_none")]
351                        pub limit: ::std::option::Option<i32>,
352                    }
353                    impl ListCustomersQuery {
354                        pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
355                    }
356                }
357                pub use list_customers_query::*;
358            }
359        };
360        assert_eq!(actual, expected);
361    }
362
363    #[test]
364    fn test_resource_with_multiple_query_ops_shares_parameters_module() {
365        let doc = Document::from_yaml(indoc::indoc! {"
366            openapi: 3.0.0
367            info:
368              title: Test
369              version: 1.0.0
370            paths:
371              /customers:
372                get:
373                  operationId: listCustomers
374                  x-resource-name: customer
375                  parameters:
376                    - name: limit
377                      in: query
378                      schema:
379                        type: integer
380                        format: int32
381                  responses:
382                    '200':
383                      description: OK
384              /customers/search:
385                get:
386                  operationId: searchCustomers
387                  x-resource-name: customer
388                  parameters:
389                    - name: email
390                      in: query
391                      required: true
392                      schema:
393                        type: string
394                  responses:
395                    '200':
396                      description: OK
397        "})
398        .unwrap();
399
400        let arena = Arena::new();
401        let spec = Spec::from_doc(&arena, &doc).unwrap();
402        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
403
404        let ops = graph.operations().collect_vec();
405        let feature = CargoFeature::from_name("customer");
406        let resource = CodegenResource::new(&feature, &ops);
407
408        let actual: syn::File = parse_quote!(#resource);
409        let expected: syn::File = parse_quote! {
410            impl crate::client::Client {
411                pub async fn list_customers(
412                    &self,
413                    query: &parameters::ListCustomersQuery
414                ) -> Result<(), crate::error::Error> {
415                    let url = {
416                        let mut url = self.base_url.clone();
417                        let _ = url
418                            .path_segments_mut()
419                            .map(|mut segments| {
420                                segments.pop_if_empty()
421                                    .push("customers");
422                            });
423                        url
424                    };
425                    let url = ::ploidy_util::serde::Serialize::serialize(
426                        query,
427                        ::ploidy_util::QuerySerializer::new(
428                            url,
429                            parameters::ListCustomersQuery::STYLES,
430                        ),
431                    )?;
432                    let response = self
433                        .client
434                        .get(url)
435                        .headers(self.headers.clone())
436                        .send()
437                        .await?
438                        .error_for_status()?;
439                    let _ = response;
440                    Ok(())
441                }
442                pub async fn search_customers(
443                    &self,
444                    query: &parameters::SearchCustomersQuery
445                ) -> Result<(), crate::error::Error> {
446                    let url = {
447                        let mut url = self.base_url.clone();
448                        let _ = url
449                            .path_segments_mut()
450                            .map(|mut segments| {
451                                segments.pop_if_empty()
452                                    .push("customers")
453                                    .push("search");
454                            });
455                        url
456                    };
457                    let url = ::ploidy_util::serde::Serialize::serialize(
458                        query,
459                        ::ploidy_util::QuerySerializer::new(
460                            url,
461                            parameters::SearchCustomersQuery::STYLES,
462                        ),
463                    )?;
464                    let response = self
465                        .client
466                        .get(url)
467                        .headers(self.headers.clone())
468                        .send()
469                        .await?
470                        .error_for_status()?;
471                    let _ = response;
472                    Ok(())
473                }
474            }
475            pub mod parameters {
476                mod list_customers_query {
477                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
478                    #[serde(crate = "::ploidy_util::serde")]
479                    pub struct ListCustomersQuery {
480                        #[serde(default, skip_serializing_if = "Option::is_none")]
481                        pub limit: ::std::option::Option<i32>,
482                    }
483                    impl ListCustomersQuery {
484                        pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
485                    }
486                }
487                pub use list_customers_query::*;
488                mod search_customers_query {
489                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
490                    #[serde(crate = "::ploidy_util::serde")]
491                    pub struct SearchCustomersQuery {
492                        pub email: ::std::string::String,
493                    }
494                    impl SearchCustomersQuery {
495                        pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
496                    }
497                }
498                pub use search_customers_query::*;
499            }
500        };
501        assert_eq!(actual, expected);
502    }
503
504    #[test]
505    fn test_resource_omits_parameters_module_when_no_query_params() {
506        let doc = Document::from_yaml(indoc::indoc! {"
507            openapi: 3.0.0
508            info:
509              title: Test
510              version: 1.0.0
511            paths:
512              /customers:
513                get:
514                  operationId: listCustomers
515                  x-resource-name: customer
516                  responses:
517                    '200':
518                      description: OK
519        "})
520        .unwrap();
521
522        let arena = Arena::new();
523        let spec = Spec::from_doc(&arena, &doc).unwrap();
524        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
525
526        let ops = graph.operations().collect_vec();
527        let feature = CargoFeature::from_name("customer");
528        let resource = CodegenResource::new(&feature, &ops);
529
530        let actual: syn::File = parse_quote!(#resource);
531        let expected: syn::File = parse_quote! {
532            impl crate::client::Client {
533                pub async fn list_customers(
534                    &self,
535                ) -> Result<(), crate::error::Error> {
536                    let url = {
537                        let mut url = self.base_url.clone();
538                        let _ = url
539                            .path_segments_mut()
540                            .map(|mut segments| {
541                                segments.pop_if_empty()
542                                    .push("customers");
543                            });
544                        url
545                    };
546                    let response = self
547                        .client
548                        .get(url)
549                        .headers(self.headers.clone())
550                        .send()
551                        .await?
552                        .error_for_status()?;
553                    let _ = response;
554                    Ok(())
555                }
556            }
557        };
558        assert_eq!(actual, expected);
559    }
560}