Skip to main content

ploidy_codegen_rust/
operation.rs

1use itertools::Itertools;
2use ploidy_core::{
3    ir::{OperationView, RequestView, ResponseView},
4    parse::{
5        Method,
6        path::{PathFragment, PathRun},
7    },
8};
9use proc_macro2::{Span, TokenStream};
10use quote::{ToTokens, TokenStreamExt, format_ident, quote};
11use syn::Ident;
12
13use super::{
14    doc_attrs,
15    graph::{CodegenGraph, IdentMapping},
16    naming::CodegenIdentUsage,
17    ref_::CodegenRef,
18};
19
20/// Generates a single client method for an API operation.
21pub struct CodegenOperation<'a> {
22    graph: &'a CodegenGraph<'a>,
23    op: &'a OperationView<'a, 'a>,
24}
25
26impl<'a> CodegenOperation<'a> {
27    pub fn new(graph: &'a CodegenGraph<'a>, op: &'a OperationView<'a, 'a>) -> Self {
28        Self { graph, op }
29    }
30
31    /// Generates code to build and interpolate path and query parameters
32    /// into the request URL.
33    fn url(&self) -> TokenStream {
34        // Path parameters and literal segments from the path template.
35        let segments = self.op.path().runs().map(|run| match run {
36            PathRun::Literals(literals) => match &*literals {
37                [one] => quote! { .push(#one) },
38                many => quote! { .extend(&[#(#many),*]) },
39            },
40            PathRun::Templated([PathFragment::Param(name)]) => {
41                let param = CodegenIdentUsage::Param(
42                    self.graph.ident(IdentMapping::Path(self.op.id(), name)),
43                );
44                quote! { .push(#param) }
45            }
46            PathRun::Templated(fragments) => {
47                // Build a format string, with placeholders for parameter fragments.
48                let format = fragments.iter().fold(String::new(), |mut f, fragment| {
49                    match fragment {
50                        PathFragment::Literal(text) => {
51                            f.push_str(&text.replace('{', "{{").replace('}', "}}"))
52                        }
53                        PathFragment::Param(_) => f.push_str("{}"),
54                    }
55                    f
56                });
57                let args = fragments
58                    .iter()
59                    .filter_map(|fragment| match fragment {
60                        PathFragment::Param(name) => Some(name),
61                        PathFragment::Literal(_) => None,
62                    })
63                    .map(|name| {
64                        // `url::PathSegmentsMut::push` percent-encodes the
65                        // full segment, so we can interpolate fragments
66                        // directly.
67                        let param = CodegenIdentUsage::Param(
68                            self.graph.ident(IdentMapping::Path(self.op.id(), name)),
69                        );
70                        quote!(#param)
71                    });
72                quote! { .push(&format!(#format, #(#args),*)) }
73            }
74        });
75
76        // Literal query pairs from the path template.
77        let pairs = self
78            .op
79            .path()
80            .query()
81            .map(|param| {
82                let name = param.name;
83                let value = param.value;
84                quote! { .append_pair(#name, #value) }
85            })
86            .reduce(|a, b| quote!(#a #b))
87            .map(|pairs| {
88                quote! {
89                    url.query_pairs_mut()
90                        #pairs;
91                }
92            });
93
94        // Operation query parameters.
95        let query = self.op.query().next().is_some().then(|| {
96            let query_name = format_ident!(
97                "{}Query",
98                CodegenIdentUsage::Type(self.graph.ident(self.op.id()))
99            );
100            quote! {
101                let url = ::ploidy_util::serde::Serialize::serialize(
102                    query,
103                    ::ploidy_util::QuerySerializer::new(
104                        url,
105                        parameters::#query_name::STYLES,
106                    ),
107                )?;
108            }
109        });
110
111        quote! {
112            let url = {
113                let mut url = self.base_url.clone();
114                url.path_segments_mut()
115                    .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
116                    .pop_if_empty()
117                    #(#segments)*;
118                #pairs
119                #query
120                #[cfg(feature = "tracing")]
121                {
122                    ::tracing::record_all!(::tracing::Span::current(),
123                        server.address = url.host_str(),
124                        server.port = url.port_or_known_default(),
125                        // We intentionally include the full URL,
126                        // without redaction.
127                        url.full = url.as_str(),
128                    );
129                }
130                url
131            };
132        }
133    }
134}
135
136impl ToTokens for CodegenOperation<'_> {
137    fn to_tokens(&self, tokens: &mut TokenStream) {
138        let mut params = vec![];
139
140        let paths = self.op.path().params().collect_vec();
141        for param in &paths {
142            let param = CodegenIdentUsage::Param(
143                self.graph
144                    .ident(IdentMapping::Path(self.op.id(), param.name())),
145            );
146            params.push(quote! { #param: &str });
147        }
148
149        if self.op.query().next().is_some() {
150            // Include the `query` argument if we have
151            // at least one query parameter.
152            let query_type_name = format_ident!(
153                "{}Query",
154                CodegenIdentUsage::Type(self.graph.ident(self.op.id()))
155            );
156            params.push(quote! { query: &parameters::#query_type_name });
157        }
158
159        if let Some(request) = self.op.request() {
160            match request {
161                RequestView::Json(view) => {
162                    let param_type = CodegenRef::new(self.graph, &view);
163                    params.push(quote! { request: impl Into<#param_type> });
164                }
165                RequestView::Multipart => {
166                    params.push(quote! { form: crate::util::reqwest::multipart::Form });
167                }
168            }
169        }
170
171        let return_type = match self.op.response() {
172            Some(response) => match response {
173                ResponseView::Json(view) => CodegenRef::new(self.graph, &view).into_token_stream(),
174            },
175            None => quote! { () },
176        };
177
178        let url = self.url();
179
180        let request = {
181            let method = CodegenMethod(self.op.method());
182            let builder = match self.op.request() {
183                Some(RequestView::Json(_)) => quote! {
184                    let request = self.client
185                        .#method(url)
186                        .headers(self.headers.clone())
187                        .json(&request.into());
188                },
189                Some(RequestView::Multipart) => quote! {
190                    let request = self.client
191                        .#method(url)
192                        .headers(self.headers.clone())
193                        .multipart(form);
194                },
195                None => quote! {
196                    let request = self.client
197                        .#method(url)
198                        .headers(self.headers.clone());
199                },
200            };
201            quote! {
202                let request = {
203                    #builder
204                    #[cfg(feature = "trace-context")]
205                    let request = ::ploidy_util::trace::propagate(
206                        ::tracing::Span::current(),
207                        request,
208                    );
209                    request
210                };
211                let response = request.send().await?;
212                #[cfg(feature = "tracing")]
213                {
214                    ::tracing::record_all!(::tracing::Span::current(),
215                        http.response.status_code = response.status().as_u16()
216                    );
217                }
218                let response = response.error_for_status()?;
219            }
220        };
221
222        let response = if self.op.response().is_some() {
223            quote! {
224                let body = response.bytes().await?;
225                let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
226                let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
227                Ok(result)
228            }
229        } else {
230            quote! {
231                let _ = response;
232                Ok(())
233            }
234        };
235
236        let method_name = CodegenIdentUsage::Method(self.graph.ident(self.op.id()));
237
238        let instrument = {
239            let name = format!("{} {}", self.op.method().as_str(), self.op.path());
240            let template = self.op.path().to_string();
241            let method = self.op.method().as_str();
242            let mut fields = vec![
243                quote!(otel.name = #name),
244                quote!(otel.kind = "client"),
245                quote!(url.template = #template),
246                quote!(http.request.method = #method),
247                quote!(server.address, server.port, url.full, http.response.status_code, error.type),
248            ];
249            fields.extend(paths.iter().map(|param| {
250                let param = CodegenIdentUsage::Param(
251                    self.graph
252                        .ident(IdentMapping::Path(self.op.id(), param.name())),
253                );
254                quote!(#param = %#param)
255            }));
256            quote! {
257                #[cfg_attr(feature = "tracing", ::tracing::instrument(
258                    skip_all,
259                    fields(#(#fields),*)
260                ))]
261            }
262        };
263
264        let doc = {
265            let url = format!(" {} {}", self.op.method().as_str(), self.op.path());
266            match self.op.description() {
267                Some(description) => {
268                    let attrs = doc_attrs(description);
269                    quote! {
270                        #attrs
271                        #[doc = ""]
272                        #[doc = #url]
273                    }
274                }
275                None => {
276                    quote!(#[doc = #url])
277                }
278            }
279        };
280
281        tokens.append_all(quote! {
282            #doc
283            #instrument
284            pub async fn #method_name(
285                &self,
286                #(#params),*
287            ) -> Result<#return_type, crate::error::Error> {
288                let result: Result<_, crate::error::Error> = async move {
289                    #url
290                    #request
291                    #response
292                }.await;
293                #[cfg(feature = "tracing")]
294                if let Err(err) = &result {
295                    ::tracing::record_all!(::tracing::Span::current(),
296                        error.type = %err.category(),
297                    );
298                }
299                result
300            }
301        });
302    }
303}
304
305#[derive(Clone, Copy, Debug)]
306pub struct CodegenMethod(pub Method);
307
308impl ToTokens for CodegenMethod {
309    fn to_tokens(&self, tokens: &mut TokenStream) {
310        tokens.append(match self.0 {
311            Method::Get => Ident::new("get", Span::call_site()),
312            Method::Post => Ident::new("post", Span::call_site()),
313            Method::Put => Ident::new("put", Span::call_site()),
314            Method::Patch => Ident::new("patch", Span::call_site()),
315            Method::Delete => Ident::new("delete", Span::call_site()),
316        });
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    use ploidy_core::{
325        arena::Arena,
326        ir::{RawGraph, Spec},
327        parse::Document,
328    };
329    use pretty_assertions::assert_eq;
330    use syn::parse_quote;
331
332    use crate::CodegenGraph;
333
334    // MARK: With query params
335
336    #[test]
337    fn test_operation_with_path_and_query_params() {
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/{item_id}:
345                get:
346                  operationId: getItem
347                  description: Gets an item.
348                  parameters:
349                    - name: item_id
350                      in: path
351                      required: true
352                      schema:
353                        type: string
354                    - name: expand
355                      in: query
356                      schema:
357                        type: boolean
358                  responses:
359                    '200':
360                      description: OK
361        "})
362        .unwrap();
363
364        let arena = Arena::new();
365        let spec = Spec::from_doc(&arena, &doc).unwrap();
366        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
367
368        let op = graph.operations().next().unwrap();
369        let codegen = CodegenOperation::new(&graph, &op);
370
371        let actual: syn::ImplItemFn = parse_quote!(#codegen);
372        let expected: syn::ImplItemFn = parse_quote! {
373            #[doc = " Gets an item."]
374            #[doc = ""]
375            #[doc = " GET /items/{item_id}"]
376            #[cfg_attr(
377                feature = "tracing",
378                ::tracing::instrument(
379                    skip_all,
380                    fields(
381                        otel.name = "GET /items/{item_id}",
382                        otel.kind = "client",
383                        url.template = "/items/{item_id}",
384                        http.request.method = "GET",
385                        server.address,
386                        server.port,
387                        url.full,
388                        http.response.status_code,
389                        error.type,
390                        item_id = %item_id
391                    )
392                )
393            )]
394            pub async fn get_item(
395                &self,
396                item_id: &str,
397                query: &parameters::GetItemQuery
398            ) -> Result<(), crate::error::Error> {
399                let result: Result<_, crate::error::Error> = async move {
400                    let url = {
401                        let mut url = self.base_url.clone();
402                        url.path_segments_mut()
403                            .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
404                            .pop_if_empty()
405                            .push("items")
406                            .push(item_id);
407                        let url = ::ploidy_util::serde::Serialize::serialize(
408                            query,
409                            ::ploidy_util::QuerySerializer::new(
410                                url,
411                                parameters::GetItemQuery::STYLES,
412                            ),
413                        )?;
414                        #[cfg(feature = "tracing")]
415                        {
416                            ::tracing::record_all!(::tracing::Span::current(),
417                                server.address = url.host_str(),
418                                server.port = url.port_or_known_default(),
419                                url.full = url.as_str(),
420                            );
421                        }
422                        url
423                    };
424                    let request = {
425                        let request = self
426                            .client
427                            .get(url)
428                            .headers(self.headers.clone());
429                        #[cfg(feature = "trace-context")]
430                        let request = ::ploidy_util::trace::propagate(
431                            ::tracing::Span::current(),
432                            request,
433                        );
434                        request
435                    };
436                    let response = request
437                        .send()
438                        .await?;
439                    #[cfg(feature = "tracing")]
440                    {
441                        ::tracing::record_all!(::tracing::Span::current(),
442                            http.response.status_code = response.status().as_u16()
443                        );
444                    }
445                    let response = response.error_for_status()?;
446                    let _ = response;
447                    Ok(())
448                }.await;
449                #[cfg(feature = "tracing")]
450                if let Err(err) = &result {
451                    ::tracing::record_all!(::tracing::Span::current(),
452                        error.type = %err.category(),
453                    );
454                }
455                result
456            }
457        };
458        assert_eq!(actual, expected);
459    }
460
461    #[test]
462    fn test_operation_with_query_params_only() {
463        let doc = Document::from_yaml(indoc::indoc! {"
464            openapi: 3.0.0
465            info:
466              title: Test API
467              version: 1.0.0
468            paths:
469              /items:
470                get:
471                  operationId: getItems
472                  parameters:
473                    - name: limit
474                      in: query
475                      schema:
476                        type: integer
477                        format: int32
478                  responses:
479                    '200':
480                      description: OK
481        "})
482        .unwrap();
483
484        let arena = Arena::new();
485        let spec = Spec::from_doc(&arena, &doc).unwrap();
486        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
487
488        let op = graph.operations().next().unwrap();
489        let codegen = CodegenOperation::new(&graph, &op);
490
491        let actual: syn::ImplItemFn = parse_quote!(#codegen);
492        let expected: syn::ImplItemFn = parse_quote! {
493            #[doc = " GET /items"]
494            #[cfg_attr(
495                feature = "tracing",
496                ::tracing::instrument(
497                    skip_all,
498                    fields(
499                        otel.name = "GET /items",
500                        otel.kind = "client",
501                        url.template = "/items",
502                        http.request.method = "GET",
503                        server.address,
504                        server.port,
505                        url.full,
506                        http.response.status_code,
507                        error.type
508                    )
509                )
510            )]
511            pub async fn get_items(
512                &self,
513                query: &parameters::GetItemsQuery
514            ) -> Result<(), crate::error::Error> {
515                let result: Result<_, crate::error::Error> = async move {
516                    let url = {
517                        let mut url = self.base_url.clone();
518                        url.path_segments_mut()
519                            .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
520                            .pop_if_empty()
521                            .push("items");
522                        let url = ::ploidy_util::serde::Serialize::serialize(
523                            query,
524                            ::ploidy_util::QuerySerializer::new(
525                                url,
526                                parameters::GetItemsQuery::STYLES,
527                            ),
528                        )?;
529                        #[cfg(feature = "tracing")]
530                        {
531                            ::tracing::record_all!(::tracing::Span::current(),
532                                server.address = url.host_str(),
533                                server.port = url.port_or_known_default(),
534                                url.full = url.as_str(),
535                            );
536                        }
537                        url
538                    };
539                    let request = {
540                        let request = self
541                            .client
542                            .get(url)
543                            .headers(self.headers.clone());
544                        #[cfg(feature = "trace-context")]
545                        let request = ::ploidy_util::trace::propagate(
546                            ::tracing::Span::current(),
547                            request,
548                        );
549                        request
550                    };
551                    let response = request
552                        .send()
553                        .await?;
554                    #[cfg(feature = "tracing")]
555                    {
556                        ::tracing::record_all!(::tracing::Span::current(),
557                            http.response.status_code = response.status().as_u16()
558                        );
559                    }
560                    let response = response.error_for_status()?;
561                    let _ = response;
562                    Ok(())
563                }.await;
564                #[cfg(feature = "tracing")]
565                if let Err(err) = &result {
566                    ::tracing::record_all!(::tracing::Span::current(),
567                        error.type = %err.category(),
568                    );
569                }
570                result
571            }
572        };
573        assert_eq!(actual, expected);
574    }
575
576    #[test]
577    fn test_path_param_named_query_does_not_shadow() {
578        let doc = Document::from_yaml(indoc::indoc! {"
579            openapi: 3.0.0
580            info:
581              title: Test API
582              version: 1.0.0
583            paths:
584              /search/{query}:
585                get:
586                  operationId: search
587                  parameters:
588                    - name: query
589                      in: path
590                      required: true
591                      schema:
592                        type: string
593                    - name: limit
594                      in: query
595                      schema:
596                        type: integer
597                        format: int32
598                  responses:
599                    '200':
600                      description: OK
601        "})
602        .unwrap();
603
604        let arena = Arena::new();
605        let spec = Spec::from_doc(&arena, &doc).unwrap();
606        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
607
608        let op = graph.operations().next().unwrap();
609        let codegen = CodegenOperation::new(&graph, &op);
610
611        let actual: syn::ImplItemFn = parse_quote!(#codegen);
612        let expected: syn::ImplItemFn = parse_quote! {
613            #[doc = " GET /search/{query}"]
614            #[cfg_attr(
615                feature = "tracing",
616                ::tracing::instrument(
617                    skip_all,
618                    fields(
619                        otel.name = "GET /search/{query}",
620                        otel.kind = "client",
621                        url.template = "/search/{query}",
622                        http.request.method = "GET",
623                        server.address,
624                        server.port,
625                        url.full,
626                        http.response.status_code,
627                        error.type,
628                        query_2 = %query_2
629                    )
630                )
631            )]
632            pub async fn search(
633                &self,
634                query_2: &str,
635                query: &parameters::SearchQuery
636            ) -> Result<(), crate::error::Error> {
637                let result: Result<_, crate::error::Error> = async move {
638                    let url = {
639                        let mut url = self.base_url.clone();
640                        url.path_segments_mut()
641                            .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
642                            .pop_if_empty()
643                            .push("search")
644                            .push(query_2);
645                        let url = ::ploidy_util::serde::Serialize::serialize(
646                            query,
647                            ::ploidy_util::QuerySerializer::new(
648                                url,
649                                parameters::SearchQuery::STYLES,
650                            ),
651                        )?;
652                        #[cfg(feature = "tracing")]
653                        {
654                            ::tracing::record_all!(::tracing::Span::current(),
655                                server.address = url.host_str(),
656                                server.port = url.port_or_known_default(),
657                                url.full = url.as_str(),
658                            );
659                        }
660                        url
661                    };
662                    let request = {
663                        let request = self
664                            .client
665                            .get(url)
666                            .headers(self.headers.clone());
667                        #[cfg(feature = "trace-context")]
668                        let request = ::ploidy_util::trace::propagate(
669                            ::tracing::Span::current(),
670                            request,
671                        );
672                        request
673                    };
674                    let response = request
675                        .send()
676                        .await?;
677                    #[cfg(feature = "tracing")]
678                    {
679                        ::tracing::record_all!(::tracing::Span::current(),
680                            http.response.status_code = response.status().as_u16()
681                        );
682                    }
683                    let response = response.error_for_status()?;
684                    let _ = response;
685                    Ok(())
686                }.await;
687                #[cfg(feature = "tracing")]
688                if let Err(err) = &result {
689                    ::tracing::record_all!(::tracing::Span::current(),
690                        error.type = %err.category(),
691                    );
692                }
693                result
694            }
695        };
696        assert_eq!(actual, expected);
697    }
698
699    // MARK: With query params and request body
700
701    #[test]
702    fn test_operation_with_query_params_and_request_body() {
703        let doc = Document::from_yaml(indoc::indoc! {"
704            openapi: 3.0.0
705            info:
706              title: Test API
707              version: 1.0.0
708            paths:
709              /items/{item_id}:
710                put:
711                  operationId: updateItem
712                  parameters:
713                    - name: item_id
714                      in: path
715                      required: true
716                      schema:
717                        type: string
718                    - name: dry_run
719                      in: query
720                      schema:
721                        type: boolean
722                  requestBody:
723                    content:
724                      application/json:
725                        schema:
726                          $ref: '#/components/schemas/Item'
727                  responses:
728                    '200':
729                      description: OK
730                      content:
731                        application/json:
732                          schema:
733                            $ref: '#/components/schemas/Item'
734            components:
735              schemas:
736                Item:
737                  type: object
738                  properties:
739                    name:
740                      type: string
741        "})
742        .unwrap();
743
744        let arena = Arena::new();
745        let spec = Spec::from_doc(&arena, &doc).unwrap();
746        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
747
748        let op = graph.operations().next().unwrap();
749        let codegen = CodegenOperation::new(&graph, &op);
750
751        let actual: syn::ImplItemFn = parse_quote!(#codegen);
752        let expected: syn::ImplItemFn = parse_quote! {
753            #[doc = " PUT /items/{item_id}"]
754            #[cfg_attr(
755                feature = "tracing",
756                ::tracing::instrument(
757                    skip_all,
758                    fields(
759                        otel.name = "PUT /items/{item_id}",
760                        otel.kind = "client",
761                        url.template = "/items/{item_id}",
762                        http.request.method = "PUT",
763                        server.address,
764                        server.port,
765                        url.full,
766                        http.response.status_code,
767                        error.type,
768                        item_id = %item_id
769                    )
770                )
771            )]
772            pub async fn update_item(
773                &self,
774                item_id: &str,
775                query: &parameters::UpdateItemQuery,
776                request: impl Into<crate::types::Item>
777            ) -> Result<crate::types::Item, crate::error::Error> {
778                let result: Result<_, crate::error::Error> = async move {
779                    let url = {
780                        let mut url = self.base_url.clone();
781                        url.path_segments_mut()
782                            .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
783                            .pop_if_empty()
784                            .push("items")
785                            .push(item_id);
786                        let url = ::ploidy_util::serde::Serialize::serialize(
787                            query,
788                            ::ploidy_util::QuerySerializer::new(
789                                url,
790                                parameters::UpdateItemQuery::STYLES,
791                            ),
792                        )?;
793                        #[cfg(feature = "tracing")]
794                        {
795                            ::tracing::record_all!(::tracing::Span::current(),
796                                server.address = url.host_str(),
797                                server.port = url.port_or_known_default(),
798                                url.full = url.as_str(),
799                            );
800                        }
801                        url
802                    };
803                    let request = {
804                        let request = self
805                            .client
806                            .put(url)
807                            .headers(self.headers.clone())
808                            .json(&request.into());
809                        #[cfg(feature = "trace-context")]
810                        let request = ::ploidy_util::trace::propagate(
811                            ::tracing::Span::current(),
812                            request,
813                        );
814                        request
815                    };
816                    let response = request
817                        .send()
818                        .await?;
819                    #[cfg(feature = "tracing")]
820                    {
821                        ::tracing::record_all!(::tracing::Span::current(),
822                            http.response.status_code = response.status().as_u16()
823                        );
824                    }
825                    let response = response.error_for_status()?;
826                    let body = response.bytes().await?;
827                    let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
828                    let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
829                    Ok(result)
830                }.await;
831                #[cfg(feature = "tracing")]
832                if let Err(err) = &result {
833                    ::tracing::record_all!(::tracing::Span::current(),
834                        error.type = %err.category(),
835                    );
836                }
837                result
838            }
839        };
840        assert_eq!(actual, expected);
841    }
842
843    // MARK: Without query params
844
845    #[test]
846    fn test_operation_without_query_params() {
847        let doc = Document::from_yaml(indoc::indoc! {"
848            openapi: 3.0.0
849            info:
850              title: Test API
851              version: 1.0.0
852            paths:
853              /items/{item_id}:
854                get:
855                  operationId: getItem
856                  parameters:
857                    - name: item_id
858                      in: path
859                      required: true
860                      schema:
861                        type: string
862                  responses:
863                    '200':
864                      description: OK
865        "})
866        .unwrap();
867
868        let arena = Arena::new();
869        let spec = Spec::from_doc(&arena, &doc).unwrap();
870        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
871
872        let op = graph.operations().next().unwrap();
873        let codegen = CodegenOperation::new(&graph, &op);
874
875        let actual: syn::ImplItemFn = parse_quote!(#codegen);
876        let expected: syn::ImplItemFn = parse_quote! {
877            #[doc = " GET /items/{item_id}"]
878            #[cfg_attr(
879                feature = "tracing",
880                ::tracing::instrument(
881                    skip_all,
882                    fields(
883                        otel.name = "GET /items/{item_id}",
884                        otel.kind = "client",
885                        url.template = "/items/{item_id}",
886                        http.request.method = "GET",
887                        server.address,
888                        server.port,
889                        url.full,
890                        http.response.status_code,
891                        error.type,
892                        item_id = %item_id
893                    )
894                )
895            )]
896            pub async fn get_item(
897                &self,
898                item_id: &str
899            ) -> Result<(), crate::error::Error> {
900                let result: Result<_, crate::error::Error> = async move {
901                    let url = {
902                        let mut url = self.base_url.clone();
903                        url.path_segments_mut()
904                            .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
905                            .pop_if_empty()
906                            .push("items")
907                            .push(item_id);
908                        #[cfg(feature = "tracing")]
909                        {
910                            ::tracing::record_all!(::tracing::Span::current(),
911                                server.address = url.host_str(),
912                                server.port = url.port_or_known_default(),
913                                url.full = url.as_str(),
914                            );
915                        }
916                        url
917                    };
918                    let request = {
919                        let request = self
920                            .client
921                            .get(url)
922                            .headers(self.headers.clone());
923                        #[cfg(feature = "trace-context")]
924                        let request = ::ploidy_util::trace::propagate(
925                            ::tracing::Span::current(),
926                            request,
927                        );
928                        request
929                    };
930                    let response = request
931                        .send()
932                        .await?;
933                    #[cfg(feature = "tracing")]
934                    {
935                        ::tracing::record_all!(::tracing::Span::current(),
936                            http.response.status_code = response.status().as_u16()
937                        );
938                    }
939                    let response = response.error_for_status()?;
940                    let _ = response;
941                    Ok(())
942                }.await;
943                #[cfg(feature = "tracing")]
944                if let Err(err) = &result {
945                    ::tracing::record_all!(::tracing::Span::current(),
946                        error.type = %err.category(),
947                    );
948                }
949                result
950            }
951        };
952        assert_eq!(actual, expected);
953    }
954
955    // MARK: Synthesized path params
956
957    #[test]
958    fn test_operation_with_synthesized_path_param() {
959        let doc = Document::from_yaml(indoc::indoc! {"
960            openapi: 3.0.0
961            info:
962              title: Test API
963              version: 1.0.0
964            paths:
965              /items/{item_id}:
966                get:
967                  operationId: getItem
968                  responses:
969                    '200':
970                      description: OK
971        "})
972        .unwrap();
973
974        let arena = Arena::new();
975        let spec = Spec::from_doc(&arena, &doc).unwrap();
976        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
977
978        let op = graph.operations().next().unwrap();
979        let codegen = CodegenOperation::new(&graph, &op);
980
981        let actual: syn::ImplItemFn = parse_quote!(#codegen);
982        let expected: syn::ImplItemFn = parse_quote! {
983            #[doc = " GET /items/{item_id}"]
984            #[cfg_attr(
985                feature = "tracing",
986                ::tracing::instrument(
987                    skip_all,
988                    fields(
989                        otel.name = "GET /items/{item_id}",
990                        otel.kind = "client",
991                        url.template = "/items/{item_id}",
992                        http.request.method = "GET",
993                        server.address,
994                        server.port,
995                        url.full,
996                        http.response.status_code,
997                        error.type,
998                        item_id = %item_id
999                    )
1000                )
1001            )]
1002            pub async fn get_item(
1003                &self,
1004                item_id: &str
1005            ) -> Result<(), crate::error::Error> {
1006                let result: Result<_, crate::error::Error> = async move {
1007                    let url = {
1008                        let mut url = self.base_url.clone();
1009                        url.path_segments_mut()
1010                            .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
1011                            .pop_if_empty()
1012                            .push("items")
1013                            .push(item_id);
1014                        #[cfg(feature = "tracing")]
1015                        {
1016                            ::tracing::record_all!(::tracing::Span::current(),
1017                                server.address = url.host_str(),
1018                                server.port = url.port_or_known_default(),
1019                                url.full = url.as_str(),
1020                            );
1021                        }
1022                        url
1023                    };
1024                    let request = {
1025                        let request = self
1026                            .client
1027                            .get(url)
1028                            .headers(self.headers.clone());
1029                        #[cfg(feature = "trace-context")]
1030                        let request = ::ploidy_util::trace::propagate(
1031                            ::tracing::Span::current(),
1032                            request,
1033                        );
1034                        request
1035                    };
1036                    let response = request
1037                        .send()
1038                        .await?;
1039                    #[cfg(feature = "tracing")]
1040                    {
1041                        ::tracing::record_all!(::tracing::Span::current(),
1042                            http.response.status_code = response.status().as_u16()
1043                        );
1044                    }
1045                    let response = response.error_for_status()?;
1046                    let _ = response;
1047                    Ok(())
1048                }.await;
1049                #[cfg(feature = "tracing")]
1050                if let Err(err) = &result {
1051                    ::tracing::record_all!(::tracing::Span::current(),
1052                        error.type = %err.category(),
1053                    );
1054                }
1055                result
1056            }
1057        };
1058        assert_eq!(actual, expected);
1059    }
1060
1061    // MARK: Literal query params in path
1062
1063    #[test]
1064    fn test_operation_with_literal_query_params() {
1065        let doc = Document::from_yaml(indoc::indoc! {"
1066            openapi: 3.0.0
1067            info:
1068              title: Test API
1069              version: 1.0.0
1070            paths:
1071              /v1/messages?beta=true&expand:
1072                post:
1073                  operationId: betaCreateMessage
1074                  requestBody:
1075                    content:
1076                      application/json:
1077                        schema:
1078                          $ref: '#/components/schemas/Message'
1079                  responses:
1080                    '200':
1081                      description: OK
1082                      content:
1083                        application/json:
1084                          schema:
1085                            $ref: '#/components/schemas/Message'
1086            components:
1087              schemas:
1088                Message:
1089                  type: object
1090                  properties:
1091                    content:
1092                      type: string
1093        "})
1094        .unwrap();
1095
1096        let arena = Arena::new();
1097        let spec = Spec::from_doc(&arena, &doc).unwrap();
1098        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1099
1100        let op = graph.operations().next().unwrap();
1101        let codegen = CodegenOperation::new(&graph, &op);
1102
1103        let actual: syn::ImplItemFn = parse_quote!(#codegen);
1104        let expected: syn::ImplItemFn = parse_quote! {
1105            #[doc = " POST /v1/messages?beta=true&expand="]
1106            #[cfg_attr(
1107                feature = "tracing",
1108                ::tracing::instrument(
1109                    skip_all,
1110                    fields(
1111                        otel.name = "POST /v1/messages?beta=true&expand=",
1112                        otel.kind = "client",
1113                        url.template = "/v1/messages?beta=true&expand=",
1114                        http.request.method = "POST",
1115                        server.address,
1116                        server.port,
1117                        url.full,
1118                        http.response.status_code,
1119                        error.type
1120                    )
1121                )
1122            )]
1123            pub async fn beta_create_message(
1124                &self,
1125                request: impl Into<crate::types::Message>
1126            ) -> Result<crate::types::Message, crate::error::Error> {
1127                let result: Result<_, crate::error::Error> = async move {
1128                    let url = {
1129                        let mut url = self.base_url.clone();
1130                        url.path_segments_mut()
1131                            .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
1132                            .pop_if_empty()
1133                            .extend(&["v1", "messages"]);
1134                        url.query_pairs_mut()
1135                            .append_pair("beta", "true")
1136                            .append_pair("expand", "");
1137                        #[cfg(feature = "tracing")]
1138                        {
1139                            ::tracing::record_all!(::tracing::Span::current(),
1140                                server.address = url.host_str(),
1141                                server.port = url.port_or_known_default(),
1142                                url.full = url.as_str(),
1143                            );
1144                        }
1145                        url
1146                    };
1147                    let request = {
1148                        let request = self
1149                            .client
1150                            .post(url)
1151                            .headers(self.headers.clone())
1152                            .json(&request.into());
1153                        #[cfg(feature = "trace-context")]
1154                        let request = ::ploidy_util::trace::propagate(
1155                            ::tracing::Span::current(),
1156                            request,
1157                        );
1158                        request
1159                    };
1160                    let response = request
1161                        .send()
1162                        .await?;
1163                    #[cfg(feature = "tracing")]
1164                    {
1165                        ::tracing::record_all!(::tracing::Span::current(),
1166                            http.response.status_code = response.status().as_u16()
1167                        );
1168                    }
1169                    let response = response.error_for_status()?;
1170                    let body = response.bytes().await?;
1171                    let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
1172                    let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
1173                    Ok(result)
1174                }.await;
1175                #[cfg(feature = "tracing")]
1176                if let Err(err) = &result {
1177                    ::tracing::record_all!(::tracing::Span::current(),
1178                        error.type = %err.category(),
1179                    );
1180                }
1181                result
1182            }
1183        };
1184        assert_eq!(actual, expected);
1185    }
1186
1187    #[test]
1188    fn test_operation_with_literal_and_declared_query_params() {
1189        let doc = Document::from_yaml(indoc::indoc! {"
1190            openapi: 3.0.0
1191            info:
1192              title: Test API
1193              version: 1.0.0
1194            paths:
1195              /v1/messages?beta=true:
1196                post:
1197                  operationId: betaCreateMessage
1198                  parameters:
1199                    - name: limit
1200                      in: query
1201                      schema:
1202                        type: integer
1203                        format: int32
1204                  requestBody:
1205                    content:
1206                      application/json:
1207                        schema:
1208                          $ref: '#/components/schemas/Message'
1209                  responses:
1210                    '200':
1211                      description: OK
1212                      content:
1213                        application/json:
1214                          schema:
1215                            $ref: '#/components/schemas/Message'
1216            components:
1217              schemas:
1218                Message:
1219                  type: object
1220                  properties:
1221                    content:
1222                      type: string
1223        "})
1224        .unwrap();
1225
1226        let arena = Arena::new();
1227        let spec = Spec::from_doc(&arena, &doc).unwrap();
1228        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1229
1230        let op = graph.operations().next().unwrap();
1231        let codegen = CodegenOperation::new(&graph, &op);
1232
1233        let actual: syn::ImplItemFn = parse_quote!(#codegen);
1234        let expected: syn::ImplItemFn = parse_quote! {
1235            #[doc = " POST /v1/messages?beta=true"]
1236            #[cfg_attr(
1237                feature = "tracing",
1238                ::tracing::instrument(
1239                    skip_all,
1240                    fields(
1241                        otel.name = "POST /v1/messages?beta=true",
1242                        otel.kind = "client",
1243                        url.template = "/v1/messages?beta=true",
1244                        http.request.method = "POST",
1245                        server.address,
1246                        server.port,
1247                        url.full,
1248                        http.response.status_code,
1249                        error.type
1250                    )
1251                )
1252            )]
1253            pub async fn beta_create_message(
1254                &self,
1255                query: &parameters::BetaCreateMessageQuery,
1256                request: impl Into<crate::types::Message>
1257            ) -> Result<crate::types::Message, crate::error::Error> {
1258                let result: Result<_, crate::error::Error> = async move {
1259                    let url = {
1260                        let mut url = self.base_url.clone();
1261                        url.path_segments_mut()
1262                            .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
1263                            .pop_if_empty()
1264                            .extend(&["v1", "messages"]);
1265                        url.query_pairs_mut()
1266                            .append_pair("beta", "true");
1267                        let url = ::ploidy_util::serde::Serialize::serialize(
1268                            query,
1269                            ::ploidy_util::QuerySerializer::new(
1270                                url,
1271                                parameters::BetaCreateMessageQuery::STYLES,
1272                            ),
1273                        )?;
1274                        #[cfg(feature = "tracing")]
1275                        {
1276                            ::tracing::record_all!(::tracing::Span::current(),
1277                                server.address = url.host_str(),
1278                                server.port = url.port_or_known_default(),
1279                                url.full = url.as_str(),
1280                            );
1281                        }
1282                        url
1283                    };
1284                    let request = {
1285                        let request = self
1286                            .client
1287                            .post(url)
1288                            .headers(self.headers.clone())
1289                            .json(&request.into());
1290                        #[cfg(feature = "trace-context")]
1291                        let request = ::ploidy_util::trace::propagate(
1292                            ::tracing::Span::current(),
1293                            request,
1294                        );
1295                        request
1296                    };
1297                    let response = request
1298                        .send()
1299                        .await?;
1300                    #[cfg(feature = "tracing")]
1301                    {
1302                        ::tracing::record_all!(::tracing::Span::current(),
1303                            http.response.status_code = response.status().as_u16()
1304                        );
1305                    }
1306                    let response = response.error_for_status()?;
1307                    let body = response.bytes().await?;
1308                    let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
1309                    let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
1310                    Ok(result)
1311                }.await;
1312                #[cfg(feature = "tracing")]
1313                if let Err(err) = &result {
1314                    ::tracing::record_all!(::tracing::Span::current(),
1315                        error.type = %err.category(),
1316                    );
1317                }
1318                result
1319            }
1320        };
1321        assert_eq!(actual, expected);
1322    }
1323
1324    #[test]
1325    fn test_operation_with_path_params_and_literal_and_declared_query_params() {
1326        let doc = Document::from_yaml(indoc::indoc! {"
1327            openapi: 3.0.0
1328            info:
1329              title: Test API
1330              version: 1.0.0
1331            paths:
1332              /v1/models/{model_id}?beta=true:
1333                get:
1334                  operationId: betaGetModel
1335                  parameters:
1336                    - name: model_id
1337                      in: path
1338                      required: true
1339                      schema:
1340                        type: string
1341                    - name: expand
1342                      in: query
1343                      schema:
1344                        type: boolean
1345                  responses:
1346                    '200':
1347                      description: OK
1348                      content:
1349                        application/json:
1350                          schema:
1351                            $ref: '#/components/schemas/Model'
1352            components:
1353              schemas:
1354                Model:
1355                  type: object
1356                  properties:
1357                    id:
1358                      type: string
1359        "})
1360        .unwrap();
1361
1362        let arena = Arena::new();
1363        let spec = Spec::from_doc(&arena, &doc).unwrap();
1364        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
1365
1366        let op = graph.operations().next().unwrap();
1367        let codegen = CodegenOperation::new(&graph, &op);
1368
1369        let actual: syn::ImplItemFn = parse_quote!(#codegen);
1370        let expected: syn::ImplItemFn = parse_quote! {
1371            #[doc = " GET /v1/models/{model_id}?beta=true"]
1372            #[cfg_attr(
1373                feature = "tracing",
1374                ::tracing::instrument(
1375                    skip_all,
1376                    fields(
1377                        otel.name = "GET /v1/models/{model_id}?beta=true",
1378                        otel.kind = "client",
1379                        url.template = "/v1/models/{model_id}?beta=true",
1380                        http.request.method = "GET",
1381                        server.address,
1382                        server.port,
1383                        url.full,
1384                        http.response.status_code,
1385                        error.type,
1386                        model_id = %model_id
1387                    )
1388                )
1389            )]
1390            pub async fn beta_get_model(
1391                &self,
1392                model_id: &str,
1393                query: &parameters::BetaGetModelQuery
1394            ) -> Result<crate::types::Model, crate::error::Error> {
1395                let result: Result<_, crate::error::Error> = async move {
1396                    let url = {
1397                        let mut url = self.base_url.clone();
1398                        url.path_segments_mut()
1399                            .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
1400                            .pop_if_empty()
1401                            .extend(&["v1", "models"])
1402                            .push(model_id);
1403                        url.query_pairs_mut()
1404                            .append_pair("beta", "true");
1405                        let url = ::ploidy_util::serde::Serialize::serialize(
1406                            query,
1407                            ::ploidy_util::QuerySerializer::new(
1408                                url,
1409                                parameters::BetaGetModelQuery::STYLES,
1410                            ),
1411                        )?;
1412                        #[cfg(feature = "tracing")]
1413                        {
1414                            ::tracing::record_all!(::tracing::Span::current(),
1415                                server.address = url.host_str(),
1416                                server.port = url.port_or_known_default(),
1417                                url.full = url.as_str(),
1418                            );
1419                        }
1420                        url
1421                    };
1422                    let request = {
1423                        let request = self
1424                            .client
1425                            .get(url)
1426                            .headers(self.headers.clone());
1427                        #[cfg(feature = "trace-context")]
1428                        let request = ::ploidy_util::trace::propagate(
1429                            ::tracing::Span::current(),
1430                            request,
1431                        );
1432                        request
1433                    };
1434                    let response = request
1435                        .send()
1436                        .await?;
1437                    #[cfg(feature = "tracing")]
1438                    {
1439                        ::tracing::record_all!(::tracing::Span::current(),
1440                            http.response.status_code = response.status().as_u16()
1441                        );
1442                    }
1443                    let response = response.error_for_status()?;
1444                    let body = response.bytes().await?;
1445                    let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
1446                    let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
1447                    Ok(result)
1448                }.await;
1449                #[cfg(feature = "tracing")]
1450                if let Err(err) = &result {
1451                    ::tracing::record_all!(::tracing::Span::current(),
1452                        error.type = %err.category(),
1453                    );
1454                }
1455                result
1456            }
1457        };
1458        assert_eq!(actual, expected);
1459    }
1460}