Skip to main content

ploidy_codegen_rust/
query.rs

1use ploidy_core::ir::{OperationView, ParameterStyle, ParameterView, QueryParameter, View};
2use proc_macro2::TokenStream;
3use quote::{ToTokens, TokenStreamExt, format_ident, quote};
4
5use super::{
6    derives::ExtraDerive,
7    ext::ParameterViewExt,
8    graph::{CodegenGraph, IdentMapping},
9    naming::CodegenIdentUsage,
10    ref_::CodegenRef,
11};
12
13/// Generates a query parameter struct for an API operation.
14///
15/// The generated struct is named `{OperationId}Query`.
16/// It bundles all query parameters for that operation,
17/// derives `Serialize`, and has an associated `STYLES` table
18/// with per-parameter serialization style overrides.
19#[derive(Debug)]
20pub struct CodegenQueryParameters<'a> {
21    graph: &'a CodegenGraph<'a>,
22    op: &'a OperationView<'a, 'a>,
23}
24
25impl<'a> CodegenQueryParameters<'a> {
26    /// Creates a new query parameter struct for the given operation.
27    #[inline]
28    pub fn new(graph: &'a CodegenGraph<'a>, op: &'a OperationView<'a, 'a>) -> Self {
29        Self { graph, op }
30    }
31}
32
33impl ToTokens for CodegenQueryParameters<'_> {
34    fn to_tokens(&self, tokens: &mut TokenStream) {
35        let query_name = format_ident!(
36            "{}Query",
37            CodegenIdentUsage::Type(self.graph.ident(self.op.id()))
38        );
39
40        let mut extra_derives = vec![];
41
42        // Derive `Eq` and `Hash` if all parameter types, and their
43        // transitively referenced types, are hashable.
44        if self.op.query().all(|param| param.hashable()) {
45            extra_derives.push(ExtraDerive::Eq);
46            extra_derives.push(ExtraDerive::Hash);
47        }
48
49        // Derive `Default` if all required parameters, and their
50        // transitively referenced types, are defaultable.
51        // Optional parameters become `Option<T>`, which is `Default`.
52        if self
53            .op
54            .query()
55            .all(|param| !param.required() || param.defaultable())
56        {
57            extra_derives.push(ExtraDerive::Default);
58        }
59
60        let fields = self.op.query().map(|param| {
61            let ident = self
62                .graph
63                .ident(IdentMapping::Query(self.op.id(), param.name()));
64            let field_name = CodegenIdentUsage::Field(ident);
65            let serde_attr = SerdeQueryFieldAttr::new(field_name, &param);
66
67            let ty = if param.optional() {
68                let view = param.ty();
69                let path = CodegenRef::new(self.graph, &view);
70                quote! { ::std::option::Option<#path> }
71            } else {
72                let view = param.ty();
73                let path = CodegenRef::new(self.graph, &view);
74                quote!(#path)
75            };
76
77            quote! {
78                #serde_attr
79                pub #field_name: #ty,
80            }
81        });
82
83        let styles = self
84            .op
85            .query()
86            .filter_map(|param| Some((param.name(), param.style()?)))
87            .map(|(name, style)| {
88                let style = match style {
89                    ParameterStyle::DeepObject => {
90                        quote!(::ploidy_util::QueryStyle::DeepObject)
91                    }
92                    ParameterStyle::SpaceDelimited => {
93                        quote!(::ploidy_util::QueryStyle::SpaceDelimited)
94                    }
95                    ParameterStyle::PipeDelimited => {
96                        quote!(::ploidy_util::QueryStyle::PipeDelimited)
97                    }
98                    ParameterStyle::Form { exploded } => {
99                        quote!(::ploidy_util::QueryStyle::Form { exploded: #exploded })
100                    }
101                };
102                quote!((#name, #style))
103            });
104
105        tokens.append_all(quote! {
106            #[derive(Debug, Clone, PartialEq, #(#extra_derives,)* ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
107            #[serde(crate = "::ploidy_util::serde")]
108            pub struct #query_name {
109                #(#fields)*
110            }
111
112            impl #query_name {
113                pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[#(#styles,)*];
114            }
115        });
116    }
117}
118
119/// Generates a `#[serde(...)]` attribute for a query parameter struct field.
120#[derive(Debug)]
121struct SerdeQueryFieldAttr<'param, 'a> {
122    field_name: CodegenIdentUsage<'param>,
123    param: &'param ParameterView<'param, 'a, 'a, QueryParameter>,
124}
125
126impl<'param, 'a> SerdeQueryFieldAttr<'param, 'a> {
127    fn new(
128        field_name: CodegenIdentUsage<'param>,
129        param: &'param ParameterView<'param, 'a, 'a, QueryParameter>,
130    ) -> Self {
131        Self { field_name, param }
132    }
133}
134
135impl ToTokens for SerdeQueryFieldAttr<'_, '_> {
136    fn to_tokens(&self, tokens: &mut TokenStream) {
137        let mut attrs = vec![];
138
139        let param_name = self.param.name();
140        if self.field_name.display().to_string() != param_name {
141            attrs.push(quote! { rename = #param_name });
142        }
143
144        if self.param.optional() {
145            attrs.push(quote! { default });
146            attrs.push(quote! { skip_serializing_if = "Option::is_none" });
147        }
148
149        if !attrs.is_empty() {
150            tokens.append_all(quote! { #[serde(#(#attrs),*)] });
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    use ploidy_core::{
160        arena::Arena,
161        ir::{RawGraph, Spec},
162        parse::Document,
163    };
164    use pretty_assertions::assert_eq;
165    use syn::parse_quote;
166
167    use crate::CodegenGraph;
168
169    #[test]
170    fn test_all_optional_query_params() {
171        let doc = Document::from_yaml(indoc::indoc! {"
172            openapi: 3.0.0
173            info:
174              title: Test API
175              version: 1.0.0
176            paths:
177              /charts/{chart_id}:
178                get:
179                  operationId: fetchChart
180                  parameters:
181                    - name: chart_id
182                      in: path
183                      required: true
184                      schema:
185                        type: string
186                    - name: refresh
187                      in: query
188                      schema:
189                        type: boolean
190                    - name: client_job_id
191                      in: query
192                      schema:
193                        type: string
194                    - name: partition_idx
195                      in: query
196                      schema:
197                        type: integer
198                        format: int32
199                  responses:
200                    '200':
201                      description: OK
202        "})
203        .unwrap();
204
205        let arena = Arena::new();
206        let spec = Spec::from_doc(&arena, &doc).unwrap();
207        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
208
209        let op = graph.operations().next().unwrap();
210        let codegen = CodegenQueryParameters::new(&graph, &op);
211
212        let actual: syn::File = parse_quote!(#codegen);
213        let expected: syn::File = parse_quote! {
214            #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
215            #[serde(crate = "::ploidy_util::serde")]
216            pub struct FetchChartQuery {
217                #[serde(default, skip_serializing_if = "Option::is_none")]
218                pub refresh: ::std::option::Option<bool>,
219                #[serde(default, skip_serializing_if = "Option::is_none")]
220                pub client_job_id: ::std::option::Option<::std::string::String>,
221                #[serde(default, skip_serializing_if = "Option::is_none")]
222                pub partition_idx: ::std::option::Option<i32>,
223            }
224
225            impl FetchChartQuery {
226                pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
227            }
228        };
229        assert_eq!(actual, expected);
230    }
231
232    #[test]
233    fn test_required_and_optional_query_params() {
234        let doc = Document::from_yaml(indoc::indoc! {"
235            openapi: 3.0.0
236            info:
237              title: Test API
238              version: 1.0.0
239            paths:
240              /items:
241                get:
242                  operationId: listItems
243                  parameters:
244                    - name: page
245                      in: query
246                      required: true
247                      schema:
248                        type: integer
249                        format: int32
250                    - name: perPage
251                      in: query
252                      style: pipeDelimited
253                      schema:
254                        type: array
255                        items:
256                          type: integer
257                          format: int32
258                  responses:
259                    '200':
260                      description: OK
261        "})
262        .unwrap();
263
264        let arena = Arena::new();
265        let spec = Spec::from_doc(&arena, &doc).unwrap();
266        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
267
268        let op = graph.operations().next().unwrap();
269        let codegen = CodegenQueryParameters::new(&graph, &op);
270
271        let actual: syn::File = parse_quote!(#codegen);
272        let expected: syn::File = parse_quote! {
273            #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
274            #[serde(crate = "::ploidy_util::serde")]
275            pub struct ListItemsQuery {
276                pub page: i32,
277                #[serde(rename = "perPage", default, skip_serializing_if = "Option::is_none")]
278                pub per_page: ::std::option::Option<::std::vec::Vec<i32>>,
279            }
280
281            impl ListItemsQuery {
282                pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[
283                    ("perPage", ::ploidy_util::QueryStyle::PipeDelimited),
284                ];
285            }
286        };
287        assert_eq!(actual, expected);
288    }
289
290    #[test]
291    fn test_excludes_eq_hash_for_float_params() {
292        let doc = Document::from_yaml(indoc::indoc! {"
293            openapi: 3.0.0
294            info:
295              title: Test API
296              version: 1.0.0
297            paths:
298              /items:
299                get:
300                  operationId: getItems
301                  parameters:
302                    - name: threshold
303                      in: query
304                      schema:
305                        type: number
306                        format: double
307                  responses:
308                    '200':
309                      description: OK
310        "})
311        .unwrap();
312
313        let arena = Arena::new();
314        let spec = Spec::from_doc(&arena, &doc).unwrap();
315        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
316
317        let op = graph.operations().next().unwrap();
318        let codegen = CodegenQueryParameters::new(&graph, &op);
319
320        let actual: syn::File = parse_quote!(#codegen);
321        let expected: syn::File = parse_quote! {
322            #[derive(Debug, Clone, PartialEq, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
323            #[serde(crate = "::ploidy_util::serde")]
324            pub struct GetItemsQuery {
325                #[serde(default, skip_serializing_if = "Option::is_none")]
326                pub threshold: ::std::option::Option<f64>,
327            }
328
329            impl GetItemsQuery {
330                pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
331            }
332        };
333        assert_eq!(actual, expected);
334    }
335
336    #[test]
337    fn test_excludes_default_for_non_defaultable_required_param() {
338        let doc = Document::from_yaml(indoc::indoc! {"
339            openapi: 3.0.0
340            info:
341              title: Test API
342              version: 1.0.0
343            paths:
344              /items:
345                get:
346                  operationId: getItems
347                  parameters:
348                    - name: callback
349                      in: query
350                      required: true
351                      schema:
352                        type: string
353                        format: uri
354                  responses:
355                    '200':
356                      description: OK
357        "})
358        .unwrap();
359
360        let arena = Arena::new();
361        let spec = Spec::from_doc(&arena, &doc).unwrap();
362        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
363
364        let op = graph.operations().next().unwrap();
365        let codegen = CodegenQueryParameters::new(&graph, &op);
366
367        let actual: syn::File = parse_quote!(#codegen);
368        let expected: syn::File = parse_quote! {
369            #[derive(Debug, Clone, PartialEq, Eq, Hash, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
370            #[serde(crate = "::ploidy_util::serde")]
371            pub struct GetItemsQuery {
372                pub callback: ::ploidy_util::url::Url,
373            }
374
375            impl GetItemsQuery {
376                pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
377            }
378        };
379        assert_eq!(actual, expected);
380    }
381
382    #[test]
383    fn test_query_parameter_styles() {
384        let doc = Document::from_yaml(indoc::indoc! {"
385            openapi: 3.0.0
386            info:
387              title: Test API
388              version: 1.0.0
389            paths:
390              /items:
391                get:
392                  operationId: listItems
393                  parameters:
394                    - name: filter
395                      in: query
396                      style: deepObject
397                      schema:
398                        type: object
399                        properties:
400                          status:
401                            type: string
402                    - name: tags
403                      in: query
404                      style: pipeDelimited
405                      schema:
406                        type: array
407                        items:
408                          type: string
409                    - name: ids
410                      in: query
411                      style: spaceDelimited
412                      schema:
413                        type: array
414                        items:
415                          type: integer
416                          format: int32
417                    - name: colors
418                      in: query
419                      style: form
420                      explode: false
421                      schema:
422                        type: array
423                        items:
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 op = graph.operations().next().unwrap();
436        let codegen = CodegenQueryParameters::new(&graph, &op);
437
438        let actual: syn::File = parse_quote!(#codegen);
439        let expected: syn::File = parse_quote! {
440            #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
441            #[serde(crate = "::ploidy_util::serde")]
442            pub struct ListItemsQuery {
443                #[serde(default, skip_serializing_if = "Option::is_none")]
444                pub filter: ::std::option::Option<crate::client::default::types::ListItemsQueryFilter>,
445                #[serde(default, skip_serializing_if = "Option::is_none")]
446                pub tags: ::std::option::Option<::std::vec::Vec<::std::string::String>>,
447                #[serde(default, skip_serializing_if = "Option::is_none")]
448                pub ids: ::std::option::Option<::std::vec::Vec<i32>>,
449                #[serde(default, skip_serializing_if = "Option::is_none")]
450                pub colors: ::std::option::Option<::std::vec::Vec<::std::string::String>>,
451            }
452
453            impl ListItemsQuery {
454                pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[
455                    ("filter", ::ploidy_util::QueryStyle::DeepObject),
456                    ("tags", ::ploidy_util::QueryStyle::PipeDelimited),
457                    ("ids", ::ploidy_util::QueryStyle::SpaceDelimited),
458                    ("colors", ::ploidy_util::QueryStyle::Form { exploded: false }),
459                ];
460            }
461        };
462        assert_eq!(actual, expected);
463    }
464
465    #[test]
466    fn test_ref_query_parameter() {
467        let doc = Document::from_yaml(indoc::indoc! {"
468            openapi: 3.0.0
469            info:
470              title: Test API
471              version: 1.0.0
472            paths:
473              /items:
474                get:
475                  operationId: listItems
476                  parameters:
477                    - name: sort
478                      in: query
479                      schema:
480                        $ref: '#/components/schemas/SortOrder'
481                  responses:
482                    '200':
483                      description: OK
484            components:
485              schemas:
486                SortOrder:
487                  type: string
488                  enum:
489                    - asc
490                    - desc
491        "})
492        .unwrap();
493
494        let arena = Arena::new();
495        let spec = Spec::from_doc(&arena, &doc).unwrap();
496        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
497
498        let op = graph.operations().next().unwrap();
499        let codegen = CodegenQueryParameters::new(&graph, &op);
500
501        let actual: syn::File = parse_quote!(#codegen);
502        let expected: syn::File = parse_quote! {
503            #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
504            #[serde(crate = "::ploidy_util::serde")]
505            pub struct ListItemsQuery {
506                #[serde(default, skip_serializing_if = "Option::is_none")]
507                pub sort: ::std::option::Option<crate::types::SortOrder>,
508            }
509
510            impl ListItemsQuery {
511                pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
512            }
513        };
514        assert_eq!(actual, expected);
515    }
516}