1use ploidy_core::{
2 codegen::IntoCode,
3 ir::{OperationView, View},
4};
5use proc_macro2::TokenStream;
6use quote::{ToTokens, TokenStreamExt, format_ident, quote};
7
8use super::{
9 cfg::CfgFeature,
10 graph::CodegenGraph,
11 inlines::CodegenInlines,
12 naming::{CodegenIdentUsage, ResourceGroup},
13 operation::CodegenOperation,
14 query::CodegenQueryParameters,
15};
16
17pub struct CodegenResource<'a> {
20 graph: &'a CodegenGraph<'a>,
21 resource: ResourceGroup<'a>,
22 ops: &'a [OperationView<'a, 'a>],
23}
24
25impl<'a> CodegenResource<'a> {
26 pub fn new(
27 graph: &'a CodegenGraph<'a>,
28 resource: ResourceGroup<'a>,
29 ops: &'a [OperationView<'a, 'a>],
30 ) -> Self {
31 Self {
32 graph,
33 resource,
34 ops,
35 }
36 }
37}
38
39impl ToTokens for CodegenResource<'_> {
40 #[allow(
41 clippy::filter_map_bool_then,
42 reason = "`filter_map` + `then` reads cleaner here"
43 )]
44 fn to_tokens(&self, tokens: &mut TokenStream) {
45 let methods = self.ops.iter().map(|op| {
46 let cfg = CfgFeature::for_operation(self.graph, op);
48 let method = CodegenOperation::new(self.graph, op);
49 quote! {
50 #cfg
51 #method
52 }
53 });
54
55 let inlines = CodegenInlines::for_resource_inlines(
56 self.graph,
57 self.ops.iter().flat_map(|op| op.inlines()).collect(),
58 );
59
60 let params = self
61 .ops
62 .iter()
63 .filter_map(|op| {
64 op.query().next().is_some().then(|| {
67 let cfg = CfgFeature::for_operation(self.graph, op);
68 let query = CodegenQueryParameters::new(self.graph, op);
69 let mod_name = format_ident!(
70 "{}_query",
71 CodegenIdentUsage::Module(self.graph.ident(op.id()))
72 );
73 quote! {
74 #cfg
75 mod #mod_name {
76 #query
77 }
78 #cfg
79 pub use #mod_name::*;
80 }
81 })
82 })
83 .reduce(|a, b| quote!(#a #b))
84 .map(|params| {
85 quote! {
86 pub mod parameters {
87 #params
88 }
89 }
90 });
91
92 tokens.append_all(quote! {
93 impl crate::client::Client {
94 #(#methods)*
95 }
96 #params
97 #inlines
98 });
99 }
100}
101
102impl IntoCode for CodegenResource<'_> {
103 type Code = (String, TokenStream);
104
105 fn into_code(self) -> Self::Code {
106 (
107 match self.resource {
108 ResourceGroup::Named(name) => format!(
109 "src/client/{}.rs",
110 CodegenIdentUsage::Module(name).display()
111 ),
112 ResourceGroup::Default => "src/client/default.rs".to_owned(),
113 },
114 self.into_token_stream(),
115 )
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 use itertools::Itertools;
124 use ploidy_core::{
125 arena::Arena,
126 ir::{RawGraph, Spec},
127 parse::Document,
128 };
129 use pretty_assertions::assert_eq;
130 use syn::parse_quote;
131
132 use crate::graph::CodegenGraph;
133
134 #[test]
137 fn test_operation_method_with_only_unnamed_deps_has_no_cfg() {
138 let doc = Document::from_yaml(indoc::indoc! {"
139 openapi: 3.0.0
140 info:
141 title: Test
142 version: 1.0.0
143 paths:
144 /customers:
145 get:
146 operationId: listCustomers
147 x-resource-name: customer
148 responses:
149 '200':
150 description: OK
151 content:
152 application/json:
153 schema:
154 type: array
155 items:
156 $ref: '#/components/schemas/Customer'
157 components:
158 schemas:
159 Customer:
160 type: object
161 properties:
162 address:
163 $ref: '#/components/schemas/Address'
164 Address:
165 type: object
166 properties:
167 street:
168 type: string
169 "})
170 .unwrap();
171
172 let arena = Arena::new();
173 let spec = Spec::from_doc(&arena, &doc).unwrap();
174 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
175
176 let ops = graph.operations().collect_vec();
177 let [op] = &*ops else {
178 panic!("expected one operation; got `{ops:?}`");
179 };
180 let resource =
181 CodegenResource::new(&graph, graph.resource_for(op), std::slice::from_ref(op));
182
183 let actual: syn::File = parse_quote!(#resource);
186 let expected: syn::File = parse_quote! {
187 impl crate::client::Client {
188 #[doc = " GET /customers"]
189 #[cfg_attr(
190 feature = "tracing",
191 ::tracing::instrument(
192 skip_all,
193 fields(
194 otel.name = "GET /customers",
195 otel.kind = "client",
196 url.template = "/customers",
197 http.request.method = "GET",
198 server.address,
199 server.port,
200 url.full,
201 http.response.status_code,
202 error.type
203 )
204 )
205 )]
206 pub async fn list_customers(
207 &self,
208 ) -> Result<::std::vec::Vec<crate::types::Customer>, crate::error::Error> {
209 let result: Result<_, crate::error::Error> = async move {
210 let url = {
211 let mut url = self.base_url.clone();
212 url.path_segments_mut()
213 .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
214 .pop_if_empty()
215 .push("customers");
216 #[cfg(feature = "tracing")]
217 {
218 ::tracing::record_all!(::tracing::Span::current(),
219 server.address = url.host_str(),
220 server.port = url.port_or_known_default(),
221 url.full = url.as_str(),
222 );
223 }
224 url
225 };
226 let request = {
227 let request = self
228 .client
229 .get(url)
230 .headers(self.headers.clone());
231 #[cfg(feature = "trace-context")]
232 let request = ::ploidy_util::trace::propagate(
233 ::tracing::Span::current(),
234 request,
235 );
236 request
237 };
238 let response = request
239 .send()
240 .await?;
241 #[cfg(feature = "tracing")]
242 {
243 ::tracing::record_all!(::tracing::Span::current(),
244 http.response.status_code = response.status().as_u16()
245 );
246 }
247 let response = response.error_for_status()?;
248 let body = response.bytes().await?;
249 let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
250 let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
251 Ok(result)
252 }.await;
253 #[cfg(feature = "tracing")]
254 if let Err(err) = &result {
255 ::tracing::record_all!(::tracing::Span::current(),
256 error.type = %err.category(),
257 );
258 }
259 result
260 }
261 }
262 };
263 assert_eq!(actual, expected);
264 }
265
266 #[test]
267 fn test_operation_method_with_named_deps_has_cfg() {
268 let doc = Document::from_yaml(indoc::indoc! {"
269 openapi: 3.0.0
270 info:
271 title: Test
272 version: 1.0.0
273 paths:
274 /orders:
275 get:
276 operationId: listOrders
277 x-resource-name: orders
278 responses:
279 '200':
280 description: OK
281 content:
282 application/json:
283 schema:
284 type: array
285 items:
286 $ref: '#/components/schemas/Order'
287 components:
288 schemas:
289 Order:
290 type: object
291 properties:
292 customer:
293 $ref: '#/components/schemas/Customer'
294 Customer:
295 type: object
296 x-resourceId: customer
297 properties:
298 id:
299 type: string
300 "})
301 .unwrap();
302
303 let arena = Arena::new();
304 let spec = Spec::from_doc(&arena, &doc).unwrap();
305 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
306
307 let ops = graph.operations().collect_vec();
308 let [op] = &*ops else {
309 panic!("expected one operation; got `{ops:?}`");
310 };
311 let resource =
312 CodegenResource::new(&graph, graph.resource_for(op), std::slice::from_ref(op));
313
314 let actual: syn::File = parse_quote!(#resource);
317 let expected: syn::File = parse_quote! {
318 impl crate::client::Client {
319 #[cfg(feature = "customer")]
320 #[doc = " GET /orders"]
321 #[cfg_attr(
322 feature = "tracing",
323 ::tracing::instrument(
324 skip_all,
325 fields(
326 otel.name = "GET /orders",
327 otel.kind = "client",
328 url.template = "/orders",
329 http.request.method = "GET",
330 server.address,
331 server.port,
332 url.full,
333 http.response.status_code,
334 error.type
335 )
336 )
337 )]
338 pub async fn list_orders(
339 &self,
340 ) -> Result<::std::vec::Vec<crate::types::Order>, crate::error::Error> {
341 let result: Result<_, crate::error::Error> = async move {
342 let url = {
343 let mut url = self.base_url.clone();
344 url.path_segments_mut()
345 .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
346 .pop_if_empty()
347 .push("orders");
348 #[cfg(feature = "tracing")]
349 {
350 ::tracing::record_all!(::tracing::Span::current(),
351 server.address = url.host_str(),
352 server.port = url.port_or_known_default(),
353 url.full = url.as_str(),
354 );
355 }
356 url
357 };
358 let request = {
359 let request = self
360 .client
361 .get(url)
362 .headers(self.headers.clone());
363 #[cfg(feature = "trace-context")]
364 let request = ::ploidy_util::trace::propagate(
365 ::tracing::Span::current(),
366 request,
367 );
368 request
369 };
370 let response = request
371 .send()
372 .await?;
373 #[cfg(feature = "tracing")]
374 {
375 ::tracing::record_all!(::tracing::Span::current(),
376 http.response.status_code = response.status().as_u16()
377 );
378 }
379 let response = response.error_for_status()?;
380 let body = response.bytes().await?;
381 let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
382 let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
383 Ok(result)
384 }.await;
385 #[cfg(feature = "tracing")]
386 if let Err(err) = &result {
387 ::tracing::record_all!(::tracing::Span::current(),
388 error.type = %err.category(),
389 );
390 }
391 result
392 }
393 }
394 };
395 assert_eq!(actual, expected);
396 }
397
398 #[test]
401 fn test_resource_emits_parameters_module() {
402 let doc = Document::from_yaml(indoc::indoc! {"
403 openapi: 3.0.0
404 info:
405 title: Test
406 version: 1.0.0
407 paths:
408 /customers:
409 get:
410 operationId: listCustomers
411 x-resource-name: customer
412 parameters:
413 - name: limit
414 in: query
415 schema:
416 type: integer
417 format: int32
418 responses:
419 '200':
420 description: OK
421 "})
422 .unwrap();
423
424 let arena = Arena::new();
425 let spec = Spec::from_doc(&arena, &doc).unwrap();
426 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
427
428 let ops = graph.operations().collect_vec();
429 let [op] = &*ops else {
430 panic!("expected one operation; got `{ops:?}`");
431 };
432 let resource =
433 CodegenResource::new(&graph, graph.resource_for(op), std::slice::from_ref(op));
434
435 let actual: syn::File = parse_quote!(#resource);
436 let expected: syn::File = parse_quote! {
437 impl crate::client::Client {
438 #[doc = " GET /customers"]
439 #[cfg_attr(
440 feature = "tracing",
441 ::tracing::instrument(
442 skip_all,
443 fields(
444 otel.name = "GET /customers",
445 otel.kind = "client",
446 url.template = "/customers",
447 http.request.method = "GET",
448 server.address,
449 server.port,
450 url.full,
451 http.response.status_code,
452 error.type
453 )
454 )
455 )]
456 pub async fn list_customers(
457 &self,
458 query: ¶meters::ListCustomersQuery
459 ) -> Result<(), crate::error::Error> {
460 let result: Result<_, crate::error::Error> = async move {
461 let url = {
462 let mut url = self.base_url.clone();
463 url.path_segments_mut()
464 .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
465 .pop_if_empty()
466 .push("customers");
467 let url = ::ploidy_util::serde::Serialize::serialize(
468 query,
469 ::ploidy_util::QuerySerializer::new(
470 url,
471 parameters::ListCustomersQuery::STYLES,
472 ),
473 )?;
474 #[cfg(feature = "tracing")]
475 {
476 ::tracing::record_all!(::tracing::Span::current(),
477 server.address = url.host_str(),
478 server.port = url.port_or_known_default(),
479 url.full = url.as_str(),
480 );
481 }
482 url
483 };
484 let request = {
485 let request = self
486 .client
487 .get(url)
488 .headers(self.headers.clone());
489 #[cfg(feature = "trace-context")]
490 let request = ::ploidy_util::trace::propagate(
491 ::tracing::Span::current(),
492 request,
493 );
494 request
495 };
496 let response = request
497 .send()
498 .await?;
499 #[cfg(feature = "tracing")]
500 {
501 ::tracing::record_all!(::tracing::Span::current(),
502 http.response.status_code = response.status().as_u16()
503 );
504 }
505 let response = response.error_for_status()?;
506 let _ = response;
507 Ok(())
508 }.await;
509 #[cfg(feature = "tracing")]
510 if let Err(err) = &result {
511 ::tracing::record_all!(::tracing::Span::current(),
512 error.type = %err.category(),
513 );
514 }
515 result
516 }
517 }
518 pub mod parameters {
519 mod list_customers_query {
520 #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
521 #[serde(crate = "::ploidy_util::serde")]
522 pub struct ListCustomersQuery {
523 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub limit: ::std::option::Option<i32>,
525 }
526 impl ListCustomersQuery {
527 pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
528 }
529 }
530 pub use list_customers_query::*;
531 }
532 };
533 assert_eq!(actual, expected);
534 }
535
536 #[test]
537 fn test_resource_with_multiple_query_ops_shares_parameters_module() {
538 let doc = Document::from_yaml(indoc::indoc! {"
539 openapi: 3.0.0
540 info:
541 title: Test
542 version: 1.0.0
543 paths:
544 /customers:
545 get:
546 operationId: listCustomers
547 x-resource-name: customer
548 parameters:
549 - name: limit
550 in: query
551 schema:
552 type: integer
553 format: int32
554 responses:
555 '200':
556 description: OK
557 /customers/search:
558 get:
559 operationId: searchCustomers
560 x-resource-name: customer
561 parameters:
562 - name: email
563 in: query
564 required: true
565 schema:
566 type: string
567 responses:
568 '200':
569 description: OK
570 "})
571 .unwrap();
572
573 let arena = Arena::new();
574 let spec = Spec::from_doc(&arena, &doc).unwrap();
575 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
576
577 let ops = graph.operations().collect_vec();
578 let resource = ops
579 .iter()
580 .map(|op| graph.resource_for(op))
581 .all_equal_value()
582 .unwrap();
583 let resource = CodegenResource::new(&graph, resource, &ops);
584
585 let actual: syn::File = parse_quote!(#resource);
586 let expected: syn::File = parse_quote! {
587 impl crate::client::Client {
588 #[doc = " GET /customers"]
589 #[cfg_attr(
590 feature = "tracing",
591 ::tracing::instrument(
592 skip_all,
593 fields(
594 otel.name = "GET /customers",
595 otel.kind = "client",
596 url.template = "/customers",
597 http.request.method = "GET",
598 server.address,
599 server.port,
600 url.full,
601 http.response.status_code,
602 error.type
603 )
604 )
605 )]
606 pub async fn list_customers(
607 &self,
608 query: ¶meters::ListCustomersQuery
609 ) -> Result<(), crate::error::Error> {
610 let result: Result<_, crate::error::Error> = async move {
611 let url = {
612 let mut url = self.base_url.clone();
613 url.path_segments_mut()
614 .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
615 .pop_if_empty()
616 .push("customers");
617 let url = ::ploidy_util::serde::Serialize::serialize(
618 query,
619 ::ploidy_util::QuerySerializer::new(
620 url,
621 parameters::ListCustomersQuery::STYLES,
622 ),
623 )?;
624 #[cfg(feature = "tracing")]
625 {
626 ::tracing::record_all!(::tracing::Span::current(),
627 server.address = url.host_str(),
628 server.port = url.port_or_known_default(),
629 url.full = url.as_str(),
630 );
631 }
632 url
633 };
634 let request = {
635 let request = self
636 .client
637 .get(url)
638 .headers(self.headers.clone());
639 #[cfg(feature = "trace-context")]
640 let request = ::ploidy_util::trace::propagate(
641 ::tracing::Span::current(),
642 request,
643 );
644 request
645 };
646 let response = request
647 .send()
648 .await?;
649 #[cfg(feature = "tracing")]
650 {
651 ::tracing::record_all!(::tracing::Span::current(),
652 http.response.status_code = response.status().as_u16()
653 );
654 }
655 let response = response.error_for_status()?;
656 let _ = response;
657 Ok(())
658 }.await;
659 #[cfg(feature = "tracing")]
660 if let Err(err) = &result {
661 ::tracing::record_all!(::tracing::Span::current(),
662 error.type = %err.category(),
663 );
664 }
665 result
666 }
667 #[doc = " GET /customers/search"]
668 #[cfg_attr(
669 feature = "tracing",
670 ::tracing::instrument(
671 skip_all,
672 fields(
673 otel.name = "GET /customers/search",
674 otel.kind = "client",
675 url.template = "/customers/search",
676 http.request.method = "GET",
677 server.address,
678 server.port,
679 url.full,
680 http.response.status_code,
681 error.type
682 )
683 )
684 )]
685 pub async fn search_customers(
686 &self,
687 query: ¶meters::SearchCustomersQuery
688 ) -> Result<(), crate::error::Error> {
689 let result: Result<_, crate::error::Error> = async move {
690 let url = {
691 let mut url = self.base_url.clone();
692 url.path_segments_mut()
693 .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
694 .pop_if_empty()
695 .extend(&["customers", "search"]);
696 let url = ::ploidy_util::serde::Serialize::serialize(
697 query,
698 ::ploidy_util::QuerySerializer::new(
699 url,
700 parameters::SearchCustomersQuery::STYLES,
701 ),
702 )?;
703 #[cfg(feature = "tracing")]
704 {
705 ::tracing::record_all!(::tracing::Span::current(),
706 server.address = url.host_str(),
707 server.port = url.port_or_known_default(),
708 url.full = url.as_str(),
709 );
710 }
711 url
712 };
713 let request = {
714 let request = self
715 .client
716 .get(url)
717 .headers(self.headers.clone());
718 #[cfg(feature = "trace-context")]
719 let request = ::ploidy_util::trace::propagate(
720 ::tracing::Span::current(),
721 request,
722 );
723 request
724 };
725 let response = request
726 .send()
727 .await?;
728 #[cfg(feature = "tracing")]
729 {
730 ::tracing::record_all!(::tracing::Span::current(),
731 http.response.status_code = response.status().as_u16()
732 );
733 }
734 let response = response.error_for_status()?;
735 let _ = response;
736 Ok(())
737 }.await;
738 #[cfg(feature = "tracing")]
739 if let Err(err) = &result {
740 ::tracing::record_all!(::tracing::Span::current(),
741 error.type = %err.category(),
742 );
743 }
744 result
745 }
746 }
747 pub mod parameters {
748 mod list_customers_query {
749 #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
750 #[serde(crate = "::ploidy_util::serde")]
751 pub struct ListCustomersQuery {
752 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub limit: ::std::option::Option<i32>,
754 }
755 impl ListCustomersQuery {
756 pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
757 }
758 }
759 pub use list_customers_query::*;
760 mod search_customers_query {
761 #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize)]
762 #[serde(crate = "::ploidy_util::serde")]
763 pub struct SearchCustomersQuery {
764 pub email: ::std::string::String,
765 }
766 impl SearchCustomersQuery {
767 pub const STYLES: &[(&str, ::ploidy_util::QueryStyle)] = &[];
768 }
769 }
770 pub use search_customers_query::*;
771 }
772 };
773 assert_eq!(actual, expected);
774 }
775
776 #[test]
777 fn test_resource_omits_parameters_module_when_no_query_params() {
778 let doc = Document::from_yaml(indoc::indoc! {"
779 openapi: 3.0.0
780 info:
781 title: Test
782 version: 1.0.0
783 paths:
784 /customers:
785 get:
786 operationId: listCustomers
787 x-resource-name: customer
788 responses:
789 '200':
790 description: OK
791 "})
792 .unwrap();
793
794 let arena = Arena::new();
795 let spec = Spec::from_doc(&arena, &doc).unwrap();
796 let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
797
798 let ops = graph.operations().collect_vec();
799 let [op] = &*ops else {
800 panic!("expected one operation; got `{ops:?}`");
801 };
802 let resource =
803 CodegenResource::new(&graph, graph.resource_for(op), std::slice::from_ref(op));
804
805 let actual: syn::File = parse_quote!(#resource);
806 let expected: syn::File = parse_quote! {
807 impl crate::client::Client {
808 #[doc = " GET /customers"]
809 #[cfg_attr(
810 feature = "tracing",
811 ::tracing::instrument(
812 skip_all,
813 fields(
814 otel.name = "GET /customers",
815 otel.kind = "client",
816 url.template = "/customers",
817 http.request.method = "GET",
818 server.address,
819 server.port,
820 url.full,
821 http.response.status_code,
822 error.type
823 )
824 )
825 )]
826 pub async fn list_customers(
827 &self,
828 ) -> Result<(), crate::error::Error> {
829 let result: Result<_, crate::error::Error> = async move {
830 let url = {
831 let mut url = self.base_url.clone();
832 url.path_segments_mut()
833 .map_err(|()| ::ploidy_util::url::PathAndQueryError::UrlCannotBeABase)?
834 .pop_if_empty()
835 .push("customers");
836 #[cfg(feature = "tracing")]
837 {
838 ::tracing::record_all!(::tracing::Span::current(),
839 server.address = url.host_str(),
840 server.port = url.port_or_known_default(),
841 url.full = url.as_str(),
842 );
843 }
844 url
845 };
846 let request = {
847 let request = self
848 .client
849 .get(url)
850 .headers(self.headers.clone());
851 #[cfg(feature = "trace-context")]
852 let request = ::ploidy_util::trace::propagate(
853 ::tracing::Span::current(),
854 request,
855 );
856 request
857 };
858 let response = request
859 .send()
860 .await?;
861 #[cfg(feature = "tracing")]
862 {
863 ::tracing::record_all!(::tracing::Span::current(),
864 http.response.status_code = response.status().as_u16()
865 );
866 }
867 let response = response.error_for_status()?;
868 let _ = response;
869 Ok(())
870 }.await;
871 #[cfg(feature = "tracing")]
872 if let Err(err) = &result {
873 ::tracing::record_all!(::tracing::Span::current(),
874 error.type = %err.category(),
875 );
876 }
877 result
878 }
879 }
880 };
881 assert_eq!(actual, expected);
882 }
883}