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                #[doc = " GET /customers"]
189                pub async fn list_customers(
190                    &self,
191                ) -> Result<::std::vec::Vec<crate::types::Customer>, crate::error::Error> {
192                    let url = {
193                        let mut url = self.base_url.clone();
194                        let _ = url
195                            .path_segments_mut()
196                            .map(|mut segments| {
197                                segments.pop_if_empty()
198                                    .push("customers");
199                            });
200                        url
201                    };
202                    let response = self
203                        .client
204                        .get(url)
205                        .headers(self.headers.clone())
206                        .send()
207                        .await?
208                        .error_for_status()?;
209                    let body = response.bytes().await?;
210                    let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
211                    let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
212                        .map_err(crate::error::JsonError::from)?;
213                    Ok(result)
214                }
215            }
216        };
217        assert_eq!(actual, expected);
218    }
219
220    #[test]
221    fn test_operation_method_with_named_deps_has_cfg() {
222        let doc = Document::from_yaml(indoc::indoc! {"
223            openapi: 3.0.0
224            info:
225              title: Test
226              version: 1.0.0
227            paths:
228              /orders:
229                get:
230                  operationId: listOrders
231                  x-resource-name: orders
232                  responses:
233                    '200':
234                      description: OK
235                      content:
236                        application/json:
237                          schema:
238                            type: array
239                            items:
240                              $ref: '#/components/schemas/Order'
241            components:
242              schemas:
243                Order:
244                  type: object
245                  properties:
246                    customer:
247                      $ref: '#/components/schemas/Customer'
248                Customer:
249                  type: object
250                  x-resourceId: customer
251                  properties:
252                    id:
253                      type: string
254        "})
255        .unwrap();
256
257        let arena = Arena::new();
258        let spec = Spec::from_doc(&arena, &doc).unwrap();
259        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
260
261        let ops = graph.operations().collect_vec();
262        let [op] = &*ops else {
263            panic!("expected one operation; got `{ops:?}`");
264        };
265        let resource =
266            CodegenResource::new(&graph, graph.resource_for(op), std::slice::from_ref(op));
267
268        // `#[cfg(feature = "customer")]` because `Order` depends on
269        // `Customer`, which has `x-resourceId: customer`.
270        let actual: syn::File = parse_quote!(#resource);
271        let expected: syn::File = parse_quote! {
272            impl crate::client::Client {
273                #[cfg(feature = "customer")]
274                #[doc = " GET /orders"]
275                pub async fn list_orders(
276                    &self,
277                ) -> Result<::std::vec::Vec<crate::types::Order>, crate::error::Error> {
278                    let url = {
279                        let mut url = self.base_url.clone();
280                        let _ = url
281                            .path_segments_mut()
282                            .map(|mut segments| {
283                                segments.pop_if_empty()
284                                    .push("orders");
285                            });
286                        url
287                    };
288                    let response = self
289                        .client
290                        .get(url)
291                        .headers(self.headers.clone())
292                        .send()
293                        .await?
294                        .error_for_status()?;
295                    let body = response.bytes().await?;
296                    let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
297                    let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
298                        .map_err(crate::error::JsonError::from)?;
299                    Ok(result)
300                }
301            }
302        };
303        assert_eq!(actual, expected);
304    }
305
306    // MARK: Parameters module
307
308    #[test]
309    fn test_resource_emits_parameters_module() {
310        let doc = Document::from_yaml(indoc::indoc! {"
311            openapi: 3.0.0
312            info:
313              title: Test
314              version: 1.0.0
315            paths:
316              /customers:
317                get:
318                  operationId: listCustomers
319                  x-resource-name: customer
320                  parameters:
321                    - name: limit
322                      in: query
323                      schema:
324                        type: integer
325                        format: int32
326                  responses:
327                    '200':
328                      description: OK
329        "})
330        .unwrap();
331
332        let arena = Arena::new();
333        let spec = Spec::from_doc(&arena, &doc).unwrap();
334        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
335
336        let ops = graph.operations().collect_vec();
337        let [op] = &*ops else {
338            panic!("expected one operation; got `{ops:?}`");
339        };
340        let resource =
341            CodegenResource::new(&graph, graph.resource_for(op), std::slice::from_ref(op));
342
343        let actual: syn::File = parse_quote!(#resource);
344        let expected: syn::File = parse_quote! {
345            impl crate::client::Client {
346                #[doc = " GET /customers"]
347                pub async fn list_customers(
348                    &self,
349                    query: &parameters::ListCustomersQuery
350                ) -> Result<(), crate::error::Error> {
351                    let url = {
352                        let mut url = self.base_url.clone();
353                        let _ = url
354                            .path_segments_mut()
355                            .map(|mut segments| {
356                                segments.pop_if_empty()
357                                    .push("customers");
358                            });
359                        url
360                    };
361                    let url = ::ploidy_util::serde::Serialize::serialize(
362                        query,
363                        ::ploidy_util::QuerySerializer::new(
364                            url,
365                            parameters::ListCustomersQuery::STYLES,
366                        ),
367                    )?;
368                    let response = self
369                        .client
370                        .get(url)
371                        .headers(self.headers.clone())
372                        .send()
373                        .await?
374                        .error_for_status()?;
375                    let _ = response;
376                    Ok(())
377                }
378            }
379            pub mod parameters {
380                mod list_customers_query {
381                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
382                    #[serde(crate = "::ploidy_util::serde")]
383                    pub struct ListCustomersQuery {
384                        #[serde(default, skip_serializing_if = "Option::is_none")]
385                        pub limit: ::std::option::Option<i32>,
386                    }
387                    impl ListCustomersQuery {
388                        pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
389                    }
390                }
391                pub use list_customers_query::*;
392            }
393        };
394        assert_eq!(actual, expected);
395    }
396
397    #[test]
398    fn test_resource_with_multiple_query_ops_shares_parameters_module() {
399        let doc = Document::from_yaml(indoc::indoc! {"
400            openapi: 3.0.0
401            info:
402              title: Test
403              version: 1.0.0
404            paths:
405              /customers:
406                get:
407                  operationId: listCustomers
408                  x-resource-name: customer
409                  parameters:
410                    - name: limit
411                      in: query
412                      schema:
413                        type: integer
414                        format: int32
415                  responses:
416                    '200':
417                      description: OK
418              /customers/search:
419                get:
420                  operationId: searchCustomers
421                  x-resource-name: customer
422                  parameters:
423                    - name: email
424                      in: query
425                      required: true
426                      schema:
427                        type: string
428                  responses:
429                    '200':
430                      description: OK
431        "})
432        .unwrap();
433
434        let arena = Arena::new();
435        let spec = Spec::from_doc(&arena, &doc).unwrap();
436        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
437
438        let ops = graph.operations().collect_vec();
439        let resource = ops
440            .iter()
441            .map(|op| graph.resource_for(op))
442            .all_equal_value()
443            .unwrap();
444        let resource = CodegenResource::new(&graph, resource, &ops);
445
446        let actual: syn::File = parse_quote!(#resource);
447        let expected: syn::File = parse_quote! {
448            impl crate::client::Client {
449                #[doc = " GET /customers"]
450                pub async fn list_customers(
451                    &self,
452                    query: &parameters::ListCustomersQuery
453                ) -> Result<(), crate::error::Error> {
454                    let url = {
455                        let mut url = self.base_url.clone();
456                        let _ = url
457                            .path_segments_mut()
458                            .map(|mut segments| {
459                                segments.pop_if_empty()
460                                    .push("customers");
461                            });
462                        url
463                    };
464                    let url = ::ploidy_util::serde::Serialize::serialize(
465                        query,
466                        ::ploidy_util::QuerySerializer::new(
467                            url,
468                            parameters::ListCustomersQuery::STYLES,
469                        ),
470                    )?;
471                    let response = self
472                        .client
473                        .get(url)
474                        .headers(self.headers.clone())
475                        .send()
476                        .await?
477                        .error_for_status()?;
478                    let _ = response;
479                    Ok(())
480                }
481                #[doc = " GET /customers/search"]
482                pub async fn search_customers(
483                    &self,
484                    query: &parameters::SearchCustomersQuery
485                ) -> Result<(), crate::error::Error> {
486                    let url = {
487                        let mut url = self.base_url.clone();
488                        let _ = url
489                            .path_segments_mut()
490                            .map(|mut segments| {
491                                segments.pop_if_empty()
492                                    .extend(&["customers", "search"]);
493                            });
494                        url
495                    };
496                    let url = ::ploidy_util::serde::Serialize::serialize(
497                        query,
498                        ::ploidy_util::QuerySerializer::new(
499                            url,
500                            parameters::SearchCustomersQuery::STYLES,
501                        ),
502                    )?;
503                    let response = self
504                        .client
505                        .get(url)
506                        .headers(self.headers.clone())
507                        .send()
508                        .await?
509                        .error_for_status()?;
510                    let _ = response;
511                    Ok(())
512                }
513            }
514            pub mod parameters {
515                mod list_customers_query {
516                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
517                    #[serde(crate = "::ploidy_util::serde")]
518                    pub struct ListCustomersQuery {
519                        #[serde(default, skip_serializing_if = "Option::is_none")]
520                        pub limit: ::std::option::Option<i32>,
521                    }
522                    impl ListCustomersQuery {
523                        pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
524                    }
525                }
526                pub use list_customers_query::*;
527                mod search_customers_query {
528                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
529                    #[serde(crate = "::ploidy_util::serde")]
530                    pub struct SearchCustomersQuery {
531                        pub email: ::std::string::String,
532                    }
533                    impl SearchCustomersQuery {
534                        pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
535                    }
536                }
537                pub use search_customers_query::*;
538            }
539        };
540        assert_eq!(actual, expected);
541    }
542
543    #[test]
544    fn test_resource_omits_parameters_module_when_no_query_params() {
545        let doc = Document::from_yaml(indoc::indoc! {"
546            openapi: 3.0.0
547            info:
548              title: Test
549              version: 1.0.0
550            paths:
551              /customers:
552                get:
553                  operationId: listCustomers
554                  x-resource-name: customer
555                  responses:
556                    '200':
557                      description: OK
558        "})
559        .unwrap();
560
561        let arena = Arena::new();
562        let spec = Spec::from_doc(&arena, &doc).unwrap();
563        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
564
565        let ops = graph.operations().collect_vec();
566        let [op] = &*ops else {
567            panic!("expected one operation; got `{ops:?}`");
568        };
569        let resource =
570            CodegenResource::new(&graph, graph.resource_for(op), std::slice::from_ref(op));
571
572        let actual: syn::File = parse_quote!(#resource);
573        let expected: syn::File = parse_quote! {
574            impl crate::client::Client {
575                #[doc = " GET /customers"]
576                pub async fn list_customers(
577                    &self,
578                ) -> Result<(), crate::error::Error> {
579                    let url = {
580                        let mut url = self.base_url.clone();
581                        let _ = url
582                            .path_segments_mut()
583                            .map(|mut segments| {
584                                segments.pop_if_empty()
585                                    .push("customers");
586                            });
587                        url
588                    };
589                    let response = self
590                        .client
591                        .get(url)
592                        .headers(self.headers.clone())
593                        .send()
594                        .await?
595                        .error_for_status()?;
596                    let _ = response;
597                    Ok(())
598                }
599            }
600        };
601        assert_eq!(actual, expected);
602    }
603}