Skip to main content

ploidy_codegen_rust/
resource.rs

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