Skip to main content

ploidy_codegen_rust/
operation.rs

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