Skip to main content

ploidy_codegen_rust/
operation.rs

1use itertools::Itertools;
2use ploidy_core::{
3    codegen::UniqueNames,
4    ir::{OperationView, ParameterView, PathParameter, RequestView, ResponseView},
5    parse::{Method, path::PathFragment},
6};
7use proc_macro2::{Span, TokenStream};
8use quote::{ToTokens, TokenStreamExt, format_ident, quote};
9use syn::Ident;
10
11use super::{
12    doc_attrs,
13    naming::{CodegenIdent, CodegenIdentScope, CodegenIdentUsage},
14    ref_::CodegenRef,
15};
16
17/// Generates a single client method for an API operation.
18pub struct CodegenOperation<'a> {
19    op: &'a OperationView<'a, 'a>,
20}
21
22impl<'a> CodegenOperation<'a> {
23    pub fn new(op: &'a OperationView<'a, 'a>) -> Self {
24        Self { op }
25    }
26
27    /// Generates code to build and interpolate path parameters into
28    /// the request URL.
29    fn url(
30        &self,
31        params: &[(CodegenIdent, ParameterView<'_, '_, '_, PathParameter>)],
32    ) -> TokenStream {
33        let segments = self
34            .op
35            .path()
36            .segments()
37            .map(|segment| match segment.fragments() {
38                [] => quote! { "" },
39                [PathFragment::Literal(text)] => quote! { #text },
40                [PathFragment::Param(name)] => {
41                    let (ident, _) = params
42                        .iter()
43                        .find(|(_, param)| param.name() == *name)
44                        .unwrap();
45                    let usage = CodegenIdentUsage::Param(ident);
46                    quote!(#usage)
47                }
48                fragments => {
49                    // Build a format string, with placeholders for parameter fragments.
50                    let format = fragments.iter().fold(String::new(), |mut f, fragment| {
51                        match fragment {
52                            PathFragment::Literal(text) => {
53                                f.push_str(&text.replace('{', "{{").replace('}', "}}"))
54                            }
55                            PathFragment::Param(_) => f.push_str("{}"),
56                        }
57                        f
58                    });
59                    let args = fragments
60                        .iter()
61                        .filter_map(|fragment| match fragment {
62                            PathFragment::Param(name) => Some(name),
63                            PathFragment::Literal(_) => None,
64                        })
65                        .map(|name| {
66                            // `url::PathSegmentsMut::push` percent-encodes the
67                            // full segment, so we can interpolate fragments
68                            // directly.
69                            let (ident, _) = params
70                                .iter()
71                                .find(|(_, param)| param.name() == *name)
72                                .unwrap();
73                            CodegenIdentUsage::Param(ident)
74                        });
75                    quote! { &format!(#format, #(#args),*) }
76                }
77            });
78        let query = self
79            .op
80            .path()
81            .query()
82            .map(|param| {
83                let name = param.name;
84                let value = param.value;
85                quote! { .append_pair(#name, #value) }
86            })
87            .reduce(|a, b| quote!(#a #b))
88            .map(|pairs| {
89                quote! {
90                    url.query_pairs_mut()
91                        #pairs;
92                }
93            });
94
95        quote! {
96            let url = {
97                let mut url = self.base_url.clone();
98                let _ = url
99                    .path_segments_mut()
100                    .map(|mut segments| {
101                        segments.pop_if_empty()
102                            #(.push(#segments))*;
103                    });
104                #query
105                url
106            };
107        }
108    }
109
110    /// Generates code to serialize query parameters into the URL.
111    fn query(&self) -> Option<TokenStream> {
112        self.op.query().next().is_some().then(|| {
113            let op_ident = CodegenIdent::new(self.op.id());
114            let query_name = format_ident!("{}Query", CodegenIdentUsage::Type(&op_ident));
115            quote! {
116                let url = ::ploidy_util::serde::Serialize::serialize(
117                    query,
118                    ::ploidy_util::QuerySerializer::new(
119                        url,
120                        parameters::#query_name::STYLES,
121                    ),
122                )?;
123            }
124        })
125    }
126}
127
128impl ToTokens for CodegenOperation<'_> {
129    fn to_tokens(&self, tokens: &mut TokenStream) {
130        let operation_id = CodegenIdent::new(self.op.id());
131        let method_name = CodegenIdentUsage::Method(&operation_id);
132
133        let unique = UniqueNames::new();
134        let mut scope = CodegenIdentScope::with_reserved(
135            &unique,
136            // `query`, `request`, and `form` are argument names;
137            // `url` and `response` are local variables.
138            &["query", "request", "form", "url", "response"],
139        );
140        let mut params = vec![];
141
142        let paths = self
143            .op
144            .path()
145            .params()
146            .map(|param| (scope.uniquify(param.name()), param))
147            .collect_vec();
148        for (ident, _) in &paths {
149            let usage = CodegenIdentUsage::Param(ident);
150            params.push(quote! { #usage: &str });
151        }
152
153        if self.op.query().next().is_some() {
154            // Include the `query` argument if we have
155            // at least one query parameter.
156            let op_ident = CodegenIdent::new(self.op.id());
157            let query_type_name = format_ident!("{}Query", CodegenIdentUsage::Type(&op_ident));
158            params.push(quote! { query: &parameters::#query_type_name });
159        }
160
161        if let Some(request) = self.op.request() {
162            match request {
163                RequestView::Json(view) => {
164                    let param_type = CodegenRef::new(&view);
165                    params.push(quote! { request: impl Into<#param_type> });
166                }
167                RequestView::Multipart => {
168                    params.push(quote! { form: crate::util::reqwest::multipart::Form });
169                }
170            }
171        }
172
173        let return_type = match self.op.response() {
174            Some(response) => match response {
175                ResponseView::Json(view) => CodegenRef::new(&view).into_token_stream(),
176            },
177            None => quote! { () },
178        };
179
180        let build_url = self.url(&paths);
181
182        let build_query = self.query();
183
184        let http_method = CodegenMethod(self.op.method());
185
186        let build_request = match self.op.request() {
187            Some(RequestView::Json(_)) => quote! {
188                let response = self.client
189                    .#http_method(url)
190                    .headers(self.headers.clone())
191                    .json(&request.into())
192                    .send()
193                    .await?
194                    .error_for_status()?;
195            },
196            Some(RequestView::Multipart) => quote! {
197                let response = self.client
198                    .#http_method(url)
199                    .headers(self.headers.clone())
200                    .multipart(form)
201                    .send()
202                    .await?
203                    .error_for_status()?;
204            },
205            None => quote! {
206                let response = self.client
207                    .#http_method(url)
208                    .headers(self.headers.clone())
209                    .send()
210                    .await?
211                    .error_for_status()?;
212            },
213        };
214
215        let parse_response = if self.op.response().is_some() {
216            quote! {
217                let body = response.bytes().await?;
218                let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
219                let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
220                    .map_err(crate::error::JsonError::from)?;
221                Ok(result)
222            }
223        } else {
224            quote! {
225                let _ = response;
226                Ok(())
227            }
228        };
229
230        let doc = self.op.description().map(doc_attrs);
231
232        tokens.append_all(quote! {
233            #doc
234            pub async fn #method_name(
235                &self,
236                #(#params),*
237            ) -> Result<#return_type, crate::error::Error> {
238                #build_url
239                #build_query
240                #build_request
241                #parse_response
242            }
243        });
244    }
245}
246
247#[derive(Clone, Copy, Debug)]
248pub struct CodegenMethod(pub Method);
249
250impl ToTokens for CodegenMethod {
251    fn to_tokens(&self, tokens: &mut TokenStream) {
252        tokens.append(match self.0 {
253            Method::Get => Ident::new("get", Span::call_site()),
254            Method::Post => Ident::new("post", Span::call_site()),
255            Method::Put => Ident::new("put", Span::call_site()),
256            Method::Patch => Ident::new("patch", Span::call_site()),
257            Method::Delete => Ident::new("delete", Span::call_site()),
258        });
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    use ploidy_core::{
267        arena::Arena,
268        ir::{RawGraph, Spec},
269        parse::Document,
270    };
271    use pretty_assertions::assert_eq;
272    use syn::parse_quote;
273
274    use crate::CodegenGraph;
275
276    // MARK: With query params
277
278    #[test]
279    fn test_operation_with_path_and_query_params() {
280        let doc = Document::from_yaml(indoc::indoc! {"
281            openapi: 3.0.0
282            info:
283              title: Test API
284              version: 1.0.0
285            paths:
286              /items/{item_id}:
287                get:
288                  operationId: getItem
289                  parameters:
290                    - name: item_id
291                      in: path
292                      required: true
293                      schema:
294                        type: string
295                    - name: expand
296                      in: query
297                      schema:
298                        type: boolean
299                  responses:
300                    '200':
301                      description: OK
302        "})
303        .unwrap();
304
305        let arena = Arena::new();
306        let spec = Spec::from_doc(&arena, &doc).unwrap();
307        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
308
309        let op = graph.operations().next().unwrap();
310        let codegen = CodegenOperation::new(&op);
311
312        let actual: syn::ImplItemFn = parse_quote!(#codegen);
313        let expected: syn::ImplItemFn = parse_quote! {
314            pub async fn get_item(
315                &self,
316                item_id: &str,
317                query: &parameters::GetItemQuery
318            ) -> Result<(), crate::error::Error> {
319                let url = {
320                    let mut url = self.base_url.clone();
321                    let _ = url
322                        .path_segments_mut()
323                        .map(|mut segments| {
324                            segments.pop_if_empty()
325                                .push("items")
326                                .push(item_id);
327                        });
328                    url
329                };
330                let url = ::ploidy_util::serde::Serialize::serialize(
331                    query,
332                    ::ploidy_util::QuerySerializer::new(
333                        url,
334                        parameters::GetItemQuery::STYLES,
335                    ),
336                )?;
337                let response = self
338                    .client
339                    .get(url)
340                    .headers(self.headers.clone())
341                    .send()
342                    .await?
343                    .error_for_status()?;
344                let _ = response;
345                Ok(())
346            }
347        };
348        assert_eq!(actual, expected);
349    }
350
351    #[test]
352    fn test_operation_with_query_params_only() {
353        let doc = Document::from_yaml(indoc::indoc! {"
354            openapi: 3.0.0
355            info:
356              title: Test API
357              version: 1.0.0
358            paths:
359              /items:
360                get:
361                  operationId: getItems
362                  parameters:
363                    - name: limit
364                      in: query
365                      schema:
366                        type: integer
367                        format: int32
368                  responses:
369                    '200':
370                      description: OK
371        "})
372        .unwrap();
373
374        let arena = Arena::new();
375        let spec = Spec::from_doc(&arena, &doc).unwrap();
376        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
377
378        let op = graph.operations().next().unwrap();
379        let codegen = CodegenOperation::new(&op);
380
381        let actual: syn::ImplItemFn = parse_quote!(#codegen);
382        let expected: syn::ImplItemFn = parse_quote! {
383            pub async fn get_items(
384                &self,
385                query: &parameters::GetItemsQuery
386            ) -> Result<(), crate::error::Error> {
387                let url = {
388                    let mut url = self.base_url.clone();
389                    let _ = url
390                        .path_segments_mut()
391                        .map(|mut segments| {
392                            segments.pop_if_empty()
393                                .push("items");
394                        });
395                    url
396                };
397                let url = ::ploidy_util::serde::Serialize::serialize(
398                    query,
399                    ::ploidy_util::QuerySerializer::new(
400                        url,
401                        parameters::GetItemsQuery::STYLES,
402                    ),
403                )?;
404                let response = self
405                    .client
406                    .get(url)
407                    .headers(self.headers.clone())
408                    .send()
409                    .await?
410                    .error_for_status()?;
411                let _ = response;
412                Ok(())
413            }
414        };
415        assert_eq!(actual, expected);
416    }
417
418    #[test]
419    fn test_path_param_named_query_does_not_shadow() {
420        let doc = Document::from_yaml(indoc::indoc! {"
421            openapi: 3.0.0
422            info:
423              title: Test API
424              version: 1.0.0
425            paths:
426              /search/{query}:
427                get:
428                  operationId: search
429                  parameters:
430                    - name: query
431                      in: path
432                      required: true
433                      schema:
434                        type: string
435                    - name: limit
436                      in: query
437                      schema:
438                        type: integer
439                        format: int32
440                  responses:
441                    '200':
442                      description: OK
443        "})
444        .unwrap();
445
446        let arena = Arena::new();
447        let spec = Spec::from_doc(&arena, &doc).unwrap();
448        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
449
450        let op = graph.operations().next().unwrap();
451        let codegen = CodegenOperation::new(&op);
452
453        let actual: syn::ImplItemFn = parse_quote!(#codegen);
454        let expected: syn::ImplItemFn = parse_quote! {
455            pub async fn search(
456                &self,
457                query2: &str,
458                query: &parameters::SearchQuery
459            ) -> Result<(), crate::error::Error> {
460                let url = {
461                    let mut url = self.base_url.clone();
462                    let _ = url
463                        .path_segments_mut()
464                        .map(|mut segments| {
465                            segments.pop_if_empty()
466                                .push("search")
467                                .push(query2);
468                        });
469                    url
470                };
471                let url = ::ploidy_util::serde::Serialize::serialize(
472                    query,
473                    ::ploidy_util::QuerySerializer::new(
474                        url,
475                        parameters::SearchQuery::STYLES,
476                    ),
477                )?;
478                let response = self
479                    .client
480                    .get(url)
481                    .headers(self.headers.clone())
482                    .send()
483                    .await?
484                    .error_for_status()?;
485                let _ = response;
486                Ok(())
487            }
488        };
489        assert_eq!(actual, expected);
490    }
491
492    // MARK: With query params and request body
493
494    #[test]
495    fn test_operation_with_query_params_and_request_body() {
496        let doc = Document::from_yaml(indoc::indoc! {"
497            openapi: 3.0.0
498            info:
499              title: Test API
500              version: 1.0.0
501            paths:
502              /items/{item_id}:
503                put:
504                  operationId: updateItem
505                  parameters:
506                    - name: item_id
507                      in: path
508                      required: true
509                      schema:
510                        type: string
511                    - name: dry_run
512                      in: query
513                      schema:
514                        type: boolean
515                  requestBody:
516                    content:
517                      application/json:
518                        schema:
519                          $ref: '#/components/schemas/Item'
520                  responses:
521                    '200':
522                      description: OK
523                      content:
524                        application/json:
525                          schema:
526                            $ref: '#/components/schemas/Item'
527            components:
528              schemas:
529                Item:
530                  type: object
531                  properties:
532                    name:
533                      type: string
534        "})
535        .unwrap();
536
537        let arena = Arena::new();
538        let spec = Spec::from_doc(&arena, &doc).unwrap();
539        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
540
541        let op = graph.operations().next().unwrap();
542        let codegen = CodegenOperation::new(&op);
543
544        let actual: syn::ImplItemFn = parse_quote!(#codegen);
545        let expected: syn::ImplItemFn = parse_quote! {
546            pub async fn update_item(
547                &self,
548                item_id: &str,
549                query: &parameters::UpdateItemQuery,
550                request: impl Into<crate::types::Item>
551            ) -> Result<crate::types::Item, crate::error::Error> {
552                let url = {
553                    let mut url = self.base_url.clone();
554                    let _ = url
555                        .path_segments_mut()
556                        .map(|mut segments| {
557                            segments.pop_if_empty()
558                                .push("items")
559                                .push(item_id);
560                        });
561                    url
562                };
563                let url = ::ploidy_util::serde::Serialize::serialize(
564                    query,
565                    ::ploidy_util::QuerySerializer::new(
566                        url,
567                        parameters::UpdateItemQuery::STYLES,
568                    ),
569                )?;
570                let response = self
571                    .client
572                    .put(url)
573                    .headers(self.headers.clone())
574                    .json(&request.into())
575                    .send()
576                    .await?
577                    .error_for_status()?;
578                let body = response.bytes().await?;
579                let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
580                let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
581                    .map_err(crate::error::JsonError::from)?;
582                Ok(result)
583            }
584        };
585        assert_eq!(actual, expected);
586    }
587
588    // MARK: Without query params
589
590    #[test]
591    fn test_operation_without_query_params() {
592        let doc = Document::from_yaml(indoc::indoc! {"
593            openapi: 3.0.0
594            info:
595              title: Test API
596              version: 1.0.0
597            paths:
598              /items/{item_id}:
599                get:
600                  operationId: getItem
601                  parameters:
602                    - name: item_id
603                      in: path
604                      required: true
605                      schema:
606                        type: string
607                  responses:
608                    '200':
609                      description: OK
610        "})
611        .unwrap();
612
613        let arena = Arena::new();
614        let spec = Spec::from_doc(&arena, &doc).unwrap();
615        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
616
617        let op = graph.operations().next().unwrap();
618        let codegen = CodegenOperation::new(&op);
619
620        let actual: syn::ImplItemFn = parse_quote!(#codegen);
621        let expected: syn::ImplItemFn = parse_quote! {
622            pub async fn get_item(
623                &self,
624                item_id: &str
625            ) -> Result<(), crate::error::Error> {
626                let url = {
627                    let mut url = self.base_url.clone();
628                    let _ = url
629                        .path_segments_mut()
630                        .map(|mut segments| {
631                            segments.pop_if_empty()
632                                .push("items")
633                                .push(item_id);
634                        });
635                    url
636                };
637                let response = self
638                    .client
639                    .get(url)
640                    .headers(self.headers.clone())
641                    .send()
642                    .await?
643                    .error_for_status()?;
644                let _ = response;
645                Ok(())
646            }
647        };
648        assert_eq!(actual, expected);
649    }
650
651    // MARK: Synthesized path params
652
653    #[test]
654    fn test_operation_with_synthesized_path_param() {
655        let doc = Document::from_yaml(indoc::indoc! {"
656            openapi: 3.0.0
657            info:
658              title: Test API
659              version: 1.0.0
660            paths:
661              /items/{item_id}:
662                get:
663                  operationId: getItem
664                  responses:
665                    '200':
666                      description: OK
667        "})
668        .unwrap();
669
670        let arena = Arena::new();
671        let spec = Spec::from_doc(&arena, &doc).unwrap();
672        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
673
674        let op = graph.operations().next().unwrap();
675        let codegen = CodegenOperation::new(&op);
676
677        let actual: syn::ImplItemFn = parse_quote!(#codegen);
678        let expected: syn::ImplItemFn = parse_quote! {
679            pub async fn get_item(
680                &self,
681                item_id: &str
682            ) -> Result<(), crate::error::Error> {
683                let url = {
684                    let mut url = self.base_url.clone();
685                    let _ = url
686                        .path_segments_mut()
687                        .map(|mut segments| {
688                            segments.pop_if_empty()
689                                .push("items")
690                                .push(item_id);
691                        });
692                    url
693                };
694                let response = self
695                    .client
696                    .get(url)
697                    .headers(self.headers.clone())
698                    .send()
699                    .await?
700                    .error_for_status()?;
701                let _ = response;
702                Ok(())
703            }
704        };
705        assert_eq!(actual, expected);
706    }
707
708    // MARK: Literal query params in path
709
710    #[test]
711    fn test_operation_with_literal_query_params() {
712        let doc = Document::from_yaml(indoc::indoc! {"
713            openapi: 3.0.0
714            info:
715              title: Test API
716              version: 1.0.0
717            paths:
718              /v1/messages?beta=true&expand:
719                post:
720                  operationId: betaCreateMessage
721                  requestBody:
722                    content:
723                      application/json:
724                        schema:
725                          $ref: '#/components/schemas/Message'
726                  responses:
727                    '200':
728                      description: OK
729                      content:
730                        application/json:
731                          schema:
732                            $ref: '#/components/schemas/Message'
733            components:
734              schemas:
735                Message:
736                  type: object
737                  properties:
738                    content:
739                      type: string
740        "})
741        .unwrap();
742
743        let arena = Arena::new();
744        let spec = Spec::from_doc(&arena, &doc).unwrap();
745        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
746
747        let op = graph.operations().next().unwrap();
748        let codegen = CodegenOperation::new(&op);
749
750        let actual: syn::ImplItemFn = parse_quote!(#codegen);
751        let expected: syn::ImplItemFn = parse_quote! {
752            pub async fn beta_create_message(
753                &self,
754                request: impl Into<crate::types::Message>
755            ) -> Result<crate::types::Message, crate::error::Error> {
756                let url = {
757                    let mut url = self.base_url.clone();
758                    let _ = url
759                        .path_segments_mut()
760                        .map(|mut segments| {
761                            segments.pop_if_empty()
762                                .push("v1")
763                                .push("messages");
764                        });
765                    url.query_pairs_mut()
766                        .append_pair("beta", "true")
767                        .append_pair("expand", "");
768                    url
769                };
770                let response = self
771                    .client
772                    .post(url)
773                    .headers(self.headers.clone())
774                    .json(&request.into())
775                    .send()
776                    .await?
777                    .error_for_status()?;
778                let body = response.bytes().await?;
779                let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
780                let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
781                    .map_err(crate::error::JsonError::from)?;
782                Ok(result)
783            }
784        };
785        assert_eq!(actual, expected);
786    }
787
788    #[test]
789    fn test_operation_with_literal_and_declared_query_params() {
790        let doc = Document::from_yaml(indoc::indoc! {"
791            openapi: 3.0.0
792            info:
793              title: Test API
794              version: 1.0.0
795            paths:
796              /v1/messages?beta=true:
797                post:
798                  operationId: betaCreateMessage
799                  parameters:
800                    - name: limit
801                      in: query
802                      schema:
803                        type: integer
804                        format: int32
805                  requestBody:
806                    content:
807                      application/json:
808                        schema:
809                          $ref: '#/components/schemas/Message'
810                  responses:
811                    '200':
812                      description: OK
813                      content:
814                        application/json:
815                          schema:
816                            $ref: '#/components/schemas/Message'
817            components:
818              schemas:
819                Message:
820                  type: object
821                  properties:
822                    content:
823                      type: string
824        "})
825        .unwrap();
826
827        let arena = Arena::new();
828        let spec = Spec::from_doc(&arena, &doc).unwrap();
829        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
830
831        let op = graph.operations().next().unwrap();
832        let codegen = CodegenOperation::new(&op);
833
834        let actual: syn::ImplItemFn = parse_quote!(#codegen);
835        let expected: syn::ImplItemFn = parse_quote! {
836            pub async fn beta_create_message(
837                &self,
838                query: &parameters::BetaCreateMessageQuery,
839                request: impl Into<crate::types::Message>
840            ) -> Result<crate::types::Message, crate::error::Error> {
841                let url = {
842                    let mut url = self.base_url.clone();
843                    let _ = url
844                        .path_segments_mut()
845                        .map(|mut segments| {
846                            segments.pop_if_empty()
847                                .push("v1")
848                                .push("messages");
849                        });
850                    url.query_pairs_mut()
851                        .append_pair("beta", "true");
852                    url
853                };
854                let url = ::ploidy_util::serde::Serialize::serialize(
855                    query,
856                    ::ploidy_util::QuerySerializer::new(
857                        url,
858                        parameters::BetaCreateMessageQuery::STYLES,
859                    ),
860                )?;
861                let response = self
862                    .client
863                    .post(url)
864                    .headers(self.headers.clone())
865                    .json(&request.into())
866                    .send()
867                    .await?
868                    .error_for_status()?;
869                let body = response.bytes().await?;
870                let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
871                let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
872                    .map_err(crate::error::JsonError::from)?;
873                Ok(result)
874            }
875        };
876        assert_eq!(actual, expected);
877    }
878
879    #[test]
880    fn test_operation_with_path_params_and_literal_and_declared_query_params() {
881        let doc = Document::from_yaml(indoc::indoc! {"
882            openapi: 3.0.0
883            info:
884              title: Test API
885              version: 1.0.0
886            paths:
887              /v1/models/{model_id}?beta=true:
888                get:
889                  operationId: betaGetModel
890                  parameters:
891                    - name: model_id
892                      in: path
893                      required: true
894                      schema:
895                        type: string
896                    - name: expand
897                      in: query
898                      schema:
899                        type: boolean
900                  responses:
901                    '200':
902                      description: OK
903                      content:
904                        application/json:
905                          schema:
906                            $ref: '#/components/schemas/Model'
907            components:
908              schemas:
909                Model:
910                  type: object
911                  properties:
912                    id:
913                      type: string
914        "})
915        .unwrap();
916
917        let arena = Arena::new();
918        let spec = Spec::from_doc(&arena, &doc).unwrap();
919        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
920
921        let op = graph.operations().next().unwrap();
922        let codegen = CodegenOperation::new(&op);
923
924        let actual: syn::ImplItemFn = parse_quote!(#codegen);
925        let expected: syn::ImplItemFn = parse_quote! {
926            pub async fn beta_get_model(
927                &self,
928                model_id: &str,
929                query: &parameters::BetaGetModelQuery
930            ) -> Result<crate::types::Model, crate::error::Error> {
931                let url = {
932                    let mut url = self.base_url.clone();
933                    let _ = url
934                        .path_segments_mut()
935                        .map(|mut segments| {
936                            segments.pop_if_empty()
937                                .push("v1")
938                                .push("models")
939                                .push(model_id);
940                        });
941                    url.query_pairs_mut()
942                        .append_pair("beta", "true");
943                    url
944                };
945                let url = ::ploidy_util::serde::Serialize::serialize(
946                    query,
947                    ::ploidy_util::QuerySerializer::new(
948                        url,
949                        parameters::BetaGetModelQuery::STYLES,
950                    ),
951                )?;
952                let response = self
953                    .client
954                    .get(url)
955                    .headers(self.headers.clone())
956                    .send()
957                    .await?
958                    .error_for_status()?;
959                let body = response.bytes().await?;
960                let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
961                let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
962                    .map_err(crate::error::JsonError::from)?;
963                Ok(result)
964            }
965        };
966        assert_eq!(actual, expected);
967    }
968}