1use std::collections::HashMap;
12
13use anyhow::Result;
14use heck::ToSnakeCase;
15use heck::ToUpperCamelCase;
16use proc_macro2::TokenStream;
17use quote::format_ident;
18use quote::quote;
19
20use buffa_codegen::generated::descriptor::FileDescriptorProto;
21use buffa_codegen::generated::descriptor::MethodDescriptorProto;
22use buffa_codegen::generated::descriptor::ServiceDescriptorProto;
23use buffa_codegen::generated::descriptor::SourceCodeInfo;
24use buffa_codegen::generated::descriptor::method_options::IdempotencyLevel;
25use buffa_codegen::idents::make_field_ident;
26use buffa_codegen::idents::rust_path_to_tokens;
27
28pub use buffa_codegen::GeneratedFile;
29pub use buffa_codegen::generated::descriptor;
30
31use crate::plugin::CodeGeneratorRequest;
32use crate::plugin::CodeGeneratorResponse;
33use crate::plugin::CodeGeneratorResponseFile;
34
35#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub struct Options {
44 pub strict_utf8_mapping: bool,
48 pub generate_json: bool,
53 pub extern_paths: Vec<(String, String)>,
64}
65
66impl Default for Options {
67 fn default() -> Self {
68 Self {
69 strict_utf8_mapping: false,
70 generate_json: true,
71 extern_paths: Vec::new(),
72 }
73 }
74}
75
76impl Options {
77 fn to_buffa_config(&self) -> buffa_codegen::CodeGenConfig {
78 let mut config = buffa_codegen::CodeGenConfig::default();
79 config.generate_views = true;
80 config.generate_json = self.generate_json;
81 config.strict_utf8_mapping = self.strict_utf8_mapping;
82 config.extern_paths.clone_from(&self.extern_paths);
83 config
84 }
85}
86
87fn emit_service_files(
90 proto_file: &[FileDescriptorProto],
91 file_to_generate: &[String],
92 resolver: &TypeResolver<'_>,
93) -> Result<Vec<GeneratedFile>> {
94 let mut out = Vec::new();
95 for file_name in file_to_generate {
96 let file_desc = proto_file
97 .iter()
98 .find(|f| f.name.as_deref() == Some(file_name.as_str()));
99
100 if let Some(file) = file_desc
101 && !file.service.is_empty()
102 {
103 let service_tokens = generate_connect_services(file, resolver)?;
104 let service_code = format_token_stream(&service_tokens)?;
105 out.push(GeneratedFile {
106 name: buffa_codegen::proto_path_to_rust_module(file_name),
107 content: service_code,
108 });
109 }
110 }
111 Ok(out)
112}
113
114pub fn generate_files(
131 proto_file: &[FileDescriptorProto],
132 file_to_generate: &[String],
133 options: &Options,
134) -> Result<Vec<GeneratedFile>> {
135 let config = options.to_buffa_config();
136
137 let mut files = buffa_codegen::generate(proto_file, file_to_generate, &config)
138 .map_err(|e| anyhow::anyhow!("buffa-codegen failed: {e}"))?;
139
140 let resolver = TypeResolver::new(proto_file, file_to_generate, &config, false);
141 let service_files = emit_service_files(proto_file, file_to_generate, &resolver)?;
142
143 for svc in service_files {
145 if let Some(out) = files.iter_mut().find(|g| g.name == svc.name) {
146 out.content.push('\n');
147 out.content.push_str(&svc.content);
148 }
149 }
150
151 Ok(files)
152}
153
154pub fn generate_services(
171 proto_file: &[FileDescriptorProto],
172 file_to_generate: &[String],
173 options: &Options,
174) -> Result<Vec<GeneratedFile>> {
175 let config = options.to_buffa_config();
176 let resolver = TypeResolver::new(proto_file, file_to_generate, &config, true);
177 emit_service_files(proto_file, file_to_generate, &resolver)
178}
179
180pub fn generate(request: &CodeGeneratorRequest) -> Result<CodeGeneratorResponse> {
201 let mut options = Options::default();
202
203 if let Some(ref param) = request.parameter {
204 for opt in param.split(',').map(str::trim).filter(|s| !s.is_empty()) {
205 if let Some(value) = opt.strip_prefix("buffa_module=") {
206 let rust = value.trim();
207 if rust.is_empty() {
208 anyhow::bail!(
209 "buffa_module requires a non-empty path, \
210 e.g. buffa_module=crate::proto"
211 );
212 }
213 options.extern_paths.push((".".into(), rust.to_string()));
214 } else if let Some(value) = opt.strip_prefix("extern_path=") {
215 let (proto, rust) = value.split_once('=').ok_or_else(|| {
217 anyhow::anyhow!(
218 "invalid extern_path format {value:?}, expected \
219 extern_path=.proto.pkg=::rust::path"
220 )
221 })?;
222 let proto = proto.trim();
223 let rust = rust.trim();
224 if proto.is_empty() || rust.is_empty() {
225 anyhow::bail!(
226 "invalid extern_path format {value:?}, expected \
227 extern_path=.proto.pkg=::rust::path (both sides non-empty)"
228 );
229 }
230 let mut proto = proto.to_string();
231 if !proto.starts_with('.') {
232 proto.insert(0, '.');
233 }
234 options.extern_paths.push((proto, rust.to_string()));
235 } else {
236 match opt {
237 "strict_utf8_mapping" => options.strict_utf8_mapping = true,
238 "no_json" => options.generate_json = false,
239 _ => {
240 return Err(anyhow::anyhow!(
241 "unknown plugin option: {opt:?}. Supported: \
242 buffa_module=<rust_path>, extern_path=<proto>=<rust>, \
243 strict_utf8_mapping, no_json"
244 ));
245 }
246 }
247 }
248 }
249 }
250
251 let generated = generate_services(&request.proto_file, &request.file_to_generate, &options)?;
252
253 let files: Vec<CodeGeneratorResponseFile> = generated
254 .into_iter()
255 .map(|g| CodeGeneratorResponseFile {
256 name: Some(g.name),
257 content: Some(g.content),
258 ..Default::default()
259 })
260 .collect();
261
262 Ok(CodeGeneratorResponse {
263 supported_features: Some(feature_flags()),
264 minimum_edition: Some(EDITION_2023),
265 maximum_edition: Some(EDITION_2023),
266 file: files,
267 ..Default::default()
268 })
269}
270
271fn feature_flags() -> u64 {
274 const FEATURE_PROTO3_OPTIONAL: u64 = 1;
275 const FEATURE_SUPPORTS_EDITIONS: u64 = 2;
276 FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS
277}
278
279const EDITION_2023: i32 = 1000;
282
283fn format_token_stream(tokens: &TokenStream) -> Result<String> {
285 let file = syn::parse2::<syn::File>(tokens.clone())
286 .map_err(|e| anyhow::anyhow!("generated code failed to parse: {e}"))?;
287 Ok(prettyplease::unparse(&file))
288}
289
290fn doc_attrs(text: &str) -> TokenStream {
299 let lines: Vec<String> = text
300 .lines()
301 .map(|l| {
302 if l.is_empty() {
303 String::new()
304 } else {
305 format!(" {l}")
306 }
307 })
308 .collect();
309 quote! { #(#[doc = #lines])* }
310}
311
312struct TypeResolver<'a> {
325 ctx: buffa_codegen::context::CodeGenContext<'a>,
326 require_extern: bool,
332}
333
334impl<'a> TypeResolver<'a> {
335 fn new(
336 proto_file: &'a [FileDescriptorProto],
337 file_to_generate: &[String],
338 config: &'a buffa_codegen::CodeGenConfig,
339 require_extern: bool,
340 ) -> Self {
341 Self {
342 ctx: buffa_codegen::context::CodeGenContext::for_generate(
343 proto_file,
344 file_to_generate,
345 config,
346 ),
347 require_extern,
348 }
349 }
350
351 fn resolve_path(&self, proto_fqn: &str, current_package: &str) -> Result<String> {
358 match self.ctx.rust_type_relative(proto_fqn, current_package, 0) {
359 Some(path) => {
360 if self.require_extern && !path.starts_with("::") && !path.starts_with("crate::") {
361 anyhow::bail!(
362 "type {proto_fqn} is not covered by any extern_path mapping. \
363 Add extern_path=.=<your_buffa_module> (e.g. \
364 extern_path=.=crate::proto) to the plugin opts."
365 );
366 }
367 Ok(path)
368 }
369 None if self.require_extern => anyhow::bail!(
370 "type {proto_fqn} not found in descriptor set (missing proto import?)"
371 ),
372 None => Ok(bare_type_name(proto_fqn).to_string()),
373 }
374 }
375
376 fn rust_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
378 let path = self.resolve_path(proto_fqn, current_package)?;
379 Ok(rust_path_to_tokens(&path))
380 }
381
382 fn rust_view_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
385 let path = self.resolve_path(proto_fqn, current_package)?;
386 Ok(rust_path_to_tokens(&format!("{path}View")))
387 }
388}
389
390fn bare_type_name(proto_fqn: &str) -> &str {
393 proto_fqn
394 .strip_prefix('.')
395 .unwrap_or(proto_fqn)
396 .rsplit('.')
397 .next()
398 .unwrap_or(proto_fqn)
399}
400
401fn generate_connect_services(
407 file: &FileDescriptorProto,
408 resolver: &TypeResolver<'_>,
409) -> Result<TokenStream> {
410 let mut tokens = TokenStream::new();
411
412 let imports = quote! {
416 use std::future::Future;
417 use std::pin::Pin;
418 use std::sync::Arc;
419
420 use ::connectrpc::{Context, ConnectError, Router, Dispatcher, view_handler_fn, view_streaming_handler_fn, view_client_streaming_handler_fn, view_bidi_streaming_handler_fn};
421 use ::connectrpc::dispatcher::codegen as __crpc_codegen;
422 use ::connectrpc::CodecFormat as __CodecFormat;
423 use buffa::bytes::Bytes as __Bytes;
424 use ::connectrpc::client::{ClientConfig, ClientTransport, CallOptions, call_unary, call_server_stream, call_client_stream, call_bidi_stream};
425 use futures::Stream;
426 use buffa::Message;
427 use buffa::view::OwnedView;
428 };
429 tokens.extend(imports);
430
431 for service in &file.service {
432 tokens.extend(generate_service(file, service, resolver)?);
433 }
434
435 Ok(tokens)
436}
437
438fn check_method_collisions(service_name: &str, service: &ServiceDescriptorProto) -> Result<()> {
447 let mut seen: HashMap<String, String> = HashMap::new();
448 for m in &service.method {
449 let proto_name = m.name.as_deref().unwrap_or("");
450 let snake = proto_name.to_snake_case();
451 let with_opts = format!("{snake}_with_options");
452 for ident in [snake.as_str(), with_opts.as_str()] {
453 if let Some(prev) = seen.get(ident) {
454 anyhow::bail!(
455 "service {service_name}: RPC methods {prev:?} and {proto_name:?} \
456 both generate Rust identifier `{ident}`; rename one in the proto"
457 );
458 }
459 }
460 seen.insert(snake, proto_name.to_string());
461 seen.insert(with_opts, proto_name.to_string());
462 }
463 Ok(())
464}
465
466fn generate_service(
467 file: &FileDescriptorProto,
468 service: &ServiceDescriptorProto,
469 resolver: &TypeResolver<'_>,
470) -> Result<TokenStream> {
471 let package = file.package.as_deref().unwrap_or("");
472 let service_name = service.name.as_deref().unwrap_or("");
473 check_method_collisions(service_name, service)?;
474 let full_service_name = if package.is_empty() {
477 service_name.to_string()
478 } else {
479 format!("{package}.{service_name}")
480 };
481 let service_upper = service_name.to_upper_camel_case();
482 let trait_name = if service_upper == "Self" {
486 format_ident!("Self_")
487 } else {
488 format_ident!("{}", service_upper)
489 };
490 let ext_trait_name = format_ident!("{}Ext", service_upper);
491 let client_name = format_ident!("{}Client", service_upper);
492 let server_name = format_ident!("{}Server", service_upper);
493 let service_name_const = format_ident!(
494 "{}_SERVICE_NAME",
495 service_name.to_snake_case().to_uppercase()
496 );
497
498 let service_doc = get_service_comment(file, service).unwrap_or_default();
500 let base_doc = if service_doc.is_empty() {
501 format!("Server trait for {service_name}.")
502 } else {
503 service_doc
504 };
505 let full_doc = format!(
506 "{base_doc}\n\n\
507 # Implementing handlers\n\n\
508 Handlers receive requests as `OwnedView<FooView<'static>>`, which gives\n\
509 zero-copy borrowed access to fields (e.g. `request.name` is a `&str`\n\
510 into the decoded buffer). The view can be held across `.await` points.\n\n\
511 Implement methods with plain `async fn`; the returned future satisfies\n\
512 the `Send` bound automatically. See the\n\
513 [buffa user guide](https://github.com/anthropics/buffa/blob/main/docs/guide.md#ownedview-in-async-trait-implementations)\n\
514 for zero-copy access patterns and when `to_owned_message()` is needed."
515 );
516 let service_doc_tokens = doc_attrs(&full_doc);
517
518 let trait_methods: Vec<TokenStream> = service
520 .method
521 .iter()
522 .map(|m| generate_trait_method(file, service, m, resolver, package))
523 .collect::<Result<Vec<_>>>()?;
524
525 let route_registrations: Vec<TokenStream> = service
527 .method
528 .iter()
529 .map(|m| {
530 let method_name = m.name.as_deref().unwrap_or("");
531 let method_snake = make_field_ident(&method_name.to_snake_case());
532
533 let client_streaming = m.client_streaming.unwrap_or(false);
534 let server_streaming = m.server_streaming.unwrap_or(false);
535
536 if server_streaming && !client_streaming {
537 quote! {
539 .route_view_server_stream(
540 #service_name_const,
541 #method_name,
542 view_streaming_handler_fn({
543 let svc = Arc::clone(&self);
544 move |ctx, req| {
545 let svc = Arc::clone(&svc);
546 async move { svc.#method_snake(ctx, req).await }
547 }
548 }),
549 )
550 }
551 } else if client_streaming && !server_streaming {
552 quote! {
554 .route_view_client_stream(
555 #service_name_const,
556 #method_name,
557 view_client_streaming_handler_fn({
558 let svc = Arc::clone(&self);
559 move |ctx, req| {
560 let svc = Arc::clone(&svc);
561 async move { svc.#method_snake(ctx, req).await }
562 }
563 }),
564 )
565 }
566 } else if client_streaming && server_streaming {
567 quote! {
569 .route_view_bidi_stream(
570 #service_name_const,
571 #method_name,
572 view_bidi_streaming_handler_fn({
573 let svc = Arc::clone(&self);
574 move |ctx, req| {
575 let svc = Arc::clone(&svc);
576 async move { svc.#method_snake(ctx, req).await }
577 }
578 }),
579 )
580 }
581 } else {
582 let is_idempotent = m
584 .options
585 .idempotency_level
586 .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
587 .unwrap_or(false);
588
589 let route_method = if is_idempotent {
590 quote! { route_view_idempotent }
591 } else {
592 quote! { route_view }
593 };
594
595 quote! {
596 .#route_method(
597 #service_name_const,
598 #method_name,
599 {
600 let svc = Arc::clone(&self);
601 view_handler_fn(move |ctx, req| {
602 let svc = Arc::clone(&svc);
603 async move { svc.#method_snake(ctx, req).await }
604 })
605 },
606 )
607 }
608 }
609 })
610 .collect();
611
612 let client_methods: Vec<TokenStream> = service
614 .method
615 .iter()
616 .map(|m| generate_client_method(&full_service_name, m, resolver, package))
617 .collect::<Result<Vec<_>>>()?;
618
619 let service_server = generate_service_server(
621 &full_service_name,
622 &trait_name,
623 &server_name,
624 service,
625 resolver,
626 package,
627 )?;
628
629 let example_method = service
631 .method
632 .first()
633 .and_then(|m| m.name.as_deref())
634 .map(|n| make_field_ident(&n.to_snake_case()).to_string())
635 .unwrap_or_else(|| "method".to_string());
636
637 let client_name_str = client_name.to_string();
639 let client_doc = format!(
640 r#"Client for this service.
641
642Generic over `T: ClientTransport`. For **gRPC** (HTTP/2), use
643`Http2Connection` — it has honest `poll_ready` and composes with
644`tower::balance` for multi-connection load balancing. For **Connect
645over HTTP/1.1** (or unknown protocol), use `HttpClient`.
646
647# Example (gRPC / HTTP/2)
648
649```rust,ignore
650use connectrpc::client::{{Http2Connection, ClientConfig}};
651use connectrpc::Protocol;
652
653let uri: http::Uri = "http://localhost:8080".parse()?;
654let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
655let config = ClientConfig::new(uri).protocol(Protocol::Grpc);
656
657let client = {client_name_str}::new(conn, config);
658let response = client.{example_method}(request).await?;
659```
660
661# Example (Connect / HTTP/1.1 or ALPN)
662
663```rust,ignore
664use connectrpc::client::{{HttpClient, ClientConfig}};
665
666let http = HttpClient::plaintext(); // cleartext http:// only
667let config = ClientConfig::new("http://localhost:8080".parse()?);
668
669let client = {client_name_str}::new(http, config);
670let response = client.{example_method}(request).await?;
671```
672
673# Working with the response
674
675Unary calls return [`UnaryResponse<OwnedView<FooView>>`](::connectrpc::client::UnaryResponse).
676The `OwnedView` derefs to the view, so field access is zero-copy:
677
678```rust,ignore
679let resp = client.{example_method}(request).await?.into_view();
680let name: &str = resp.name; // borrow into the response buffer
681```
682
683If you need the owned struct (e.g. to store or pass by value), use
684[`into_owned()`](::connectrpc::client::UnaryResponse::into_owned):
685
686```rust,ignore
687let owned = client.{example_method}(request).await?.into_owned();
688```"#
689 );
690 let client_doc_tokens = doc_attrs(&client_doc);
691
692 Ok(quote! {
693 pub const #service_name_const: &str = #full_service_name;
699
700 #service_doc_tokens
701 #[allow(clippy::type_complexity)]
702 pub trait #trait_name: Send + Sync + 'static {
703 #(#trait_methods)*
704 }
705
706 pub trait #ext_trait_name: #trait_name {
719 fn register(self: Arc<Self>, router: Router) -> Router;
724 }
725
726 impl<S: #trait_name> #ext_trait_name for S {
727 fn register(self: Arc<Self>, router: Router) -> Router {
728 router
729 #(#route_registrations)*
730 }
731 }
732
733 #service_server
734
735 #client_doc_tokens
736 #[derive(Clone)]
737 pub struct #client_name<T> {
738 transport: T,
739 config: ClientConfig,
740 }
741
742 impl<T> #client_name<T>
743 where
744 T: ClientTransport,
745 <T::ResponseBody as http_body::Body>::Error: std::fmt::Display,
746 {
747 pub fn new(transport: T, config: ClientConfig) -> Self {
749 Self { transport, config }
750 }
751
752 pub fn config(&self) -> &ClientConfig {
754 &self.config
755 }
756
757 pub fn config_mut(&mut self) -> &mut ClientConfig {
759 &mut self.config
760 }
761
762 #(#client_methods)*
763 }
764 })
765}
766
767fn generate_service_server(
774 full_service_name: &str,
775 trait_name: &proc_macro2::Ident,
776 server_name: &proc_macro2::Ident,
777 service: &ServiceDescriptorProto,
778 resolver: &TypeResolver<'_>,
779 package: &str,
780) -> Result<TokenStream> {
781 let path_prefix = format!("{full_service_name}/");
783
784 let lookup_arms: Vec<TokenStream> = service
786 .method
787 .iter()
788 .map(|m| {
789 let method_name = m.name.as_deref().unwrap_or("");
790 let client_streaming = m.client_streaming.unwrap_or(false);
791 let server_streaming = m.server_streaming.unwrap_or(false);
792 let is_idempotent = m
793 .options
794 .idempotency_level
795 .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
796 .unwrap_or(false);
797
798 let desc = if client_streaming && server_streaming {
799 quote! { __crpc_codegen::MethodDescriptor::bidi_streaming() }
800 } else if client_streaming {
801 quote! { __crpc_codegen::MethodDescriptor::client_streaming() }
802 } else if server_streaming {
803 quote! { __crpc_codegen::MethodDescriptor::server_streaming() }
804 } else {
805 quote! { __crpc_codegen::MethodDescriptor::unary(#is_idempotent) }
806 };
807 quote! { #method_name => Some(#desc), }
808 })
809 .collect();
810
811 let mut call_unary_arms: Vec<TokenStream> = Vec::new();
816 let mut call_ss_arms: Vec<TokenStream> = Vec::new();
817 let mut call_cs_arms: Vec<TokenStream> = Vec::new();
818 let mut call_bidi_arms: Vec<TokenStream> = Vec::new();
819
820 for m in &service.method {
821 let method_name = m.name.as_deref().unwrap_or("");
822 let method_snake = make_field_ident(&method_name.to_snake_case());
823 let input_view = resolver.rust_view_type(m.input_type.as_deref().unwrap_or(""), package)?;
824 let cs = m.client_streaming.unwrap_or(false);
825 let ss = m.server_streaming.unwrap_or(false);
826
827 if cs && ss {
828 call_bidi_arms.push(quote! {
830 #method_name => {
831 let svc = Arc::clone(&self.inner);
832 Box::pin(async move {
833 let req_stream = __crpc_codegen::decode_view_request_stream::<#input_view>(requests, format);
834 let (resp_stream, ctx) = svc.#method_snake(ctx, req_stream).await?;
835 Ok((__crpc_codegen::encode_response_stream(resp_stream, format), ctx))
836 })
837 }
838 });
839 } else if cs {
840 call_cs_arms.push(quote! {
842 #method_name => {
843 let svc = Arc::clone(&self.inner);
844 Box::pin(async move {
845 let req_stream = __crpc_codegen::decode_view_request_stream::<#input_view>(requests, format);
846 let (res, ctx) = svc.#method_snake(ctx, req_stream).await?;
847 let bytes = __crpc_codegen::encode_response(&res, format)?;
848 Ok((bytes, ctx))
849 })
850 }
851 });
852 } else if ss {
853 call_ss_arms.push(quote! {
855 #method_name => {
856 let svc = Arc::clone(&self.inner);
857 Box::pin(async move {
858 let req = __crpc_codegen::decode_request_view::<#input_view>(request, format)?;
859 let (resp_stream, ctx) = svc.#method_snake(ctx, req).await?;
860 Ok((__crpc_codegen::encode_response_stream(resp_stream, format), ctx))
861 })
862 }
863 });
864 } else {
865 call_unary_arms.push(quote! {
867 #method_name => {
868 let svc = Arc::clone(&self.inner);
869 Box::pin(async move {
870 let req = __crpc_codegen::decode_request_view::<#input_view>(request, format)?;
871 let (res, ctx) = svc.#method_snake(ctx, req).await?;
872 let bytes = __crpc_codegen::encode_response(&res, format)?;
873 Ok((bytes, ctx))
874 })
875 }
876 });
877 }
878 }
879
880 let server_doc = format!(
881 "Monomorphic dispatcher for `{trait_name}`.\n\n\
882 Unlike `.register(Router)` which type-erases each method into an \
883 `Arc<dyn ErasedHandler>` stored in a `HashMap`, this struct dispatches \
884 via a compile-time `match` on method name: no vtable, no hash lookup.\n\n\
885 # Example\n\n\
886 ```rust,ignore\n\
887 use connectrpc::ConnectRpcService;\n\n\
888 let server = {server_name}::new(MyImpl);\n\
889 let service = ConnectRpcService::new(server);\n\
890 // hand `service` to axum/hyper as a fallback_service\n\
891 ```"
892 );
893 let server_doc_tokens = doc_attrs(&server_doc);
894
895 Ok(quote! {
896 #server_doc_tokens
897 pub struct #server_name<T> {
898 inner: Arc<T>,
899 }
900
901 impl<T: #trait_name> #server_name<T> {
902 pub fn new(service: T) -> Self {
904 Self { inner: Arc::new(service) }
905 }
906
907 pub fn from_arc(inner: Arc<T>) -> Self {
909 Self { inner }
910 }
911 }
912
913 impl<T> Clone for #server_name<T> {
914 fn clone(&self) -> Self {
915 Self { inner: Arc::clone(&self.inner) }
916 }
917 }
918
919 impl<T: #trait_name> Dispatcher for #server_name<T> {
920 #[inline]
921 fn lookup(&self, path: &str) -> Option<__crpc_codegen::MethodDescriptor> {
922 let method = path.strip_prefix(#path_prefix)?;
923 match method {
924 #(#lookup_arms)*
925 _ => None,
926 }
927 }
928
929 fn call_unary(
930 &self,
931 path: &str,
932 ctx: Context,
933 request: __Bytes,
934 format: __CodecFormat,
935 ) -> __crpc_codegen::UnaryResult {
936 let Some(method) = path.strip_prefix(#path_prefix) else {
937 return __crpc_codegen::unimplemented_unary(path);
938 };
939 let _ = (&ctx, &request, &format);
941 match method {
942 #(#call_unary_arms)*
943 _ => __crpc_codegen::unimplemented_unary(path),
944 }
945 }
946
947 fn call_server_streaming(
948 &self,
949 path: &str,
950 ctx: Context,
951 request: __Bytes,
952 format: __CodecFormat,
953 ) -> __crpc_codegen::StreamingResult {
954 let Some(method) = path.strip_prefix(#path_prefix) else {
955 return __crpc_codegen::unimplemented_streaming(path);
956 };
957 let _ = (&ctx, &request, &format);
958 match method {
959 #(#call_ss_arms)*
960 _ => __crpc_codegen::unimplemented_streaming(path),
961 }
962 }
963
964 fn call_client_streaming(
965 &self,
966 path: &str,
967 ctx: Context,
968 requests: __crpc_codegen::RequestStream,
969 format: __CodecFormat,
970 ) -> __crpc_codegen::UnaryResult {
971 let Some(method) = path.strip_prefix(#path_prefix) else {
972 return __crpc_codegen::unimplemented_unary(path);
973 };
974 let _ = (&ctx, &requests, &format);
975 match method {
976 #(#call_cs_arms)*
977 _ => __crpc_codegen::unimplemented_unary(path),
978 }
979 }
980
981 fn call_bidi_streaming(
982 &self,
983 path: &str,
984 ctx: Context,
985 requests: __crpc_codegen::RequestStream,
986 format: __CodecFormat,
987 ) -> __crpc_codegen::StreamingResult {
988 let Some(method) = path.strip_prefix(#path_prefix) else {
989 return __crpc_codegen::unimplemented_streaming(path);
990 };
991 let _ = (&ctx, &requests, &format);
992 match method {
993 #(#call_bidi_arms)*
994 _ => __crpc_codegen::unimplemented_streaming(path),
995 }
996 }
997 }
998 })
999}
1000
1001fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
1003 let comment = if doc.is_empty() { default } else { doc };
1004 doc_attrs(comment)
1005}
1006
1007fn generate_trait_method(
1009 file: &FileDescriptorProto,
1010 service: &ServiceDescriptorProto,
1011 method: &MethodDescriptorProto,
1012 resolver: &TypeResolver<'_>,
1013 package: &str,
1014) -> Result<TokenStream> {
1015 let method_name = method.name.as_deref().unwrap_or("");
1016 let method_snake = make_field_ident(&method_name.to_snake_case());
1017 let input_view_type =
1018 resolver.rust_view_type(method.input_type.as_deref().unwrap_or(""), package)?;
1019 let output_type = resolver.rust_type(method.output_type.as_deref().unwrap_or(""), package)?;
1020
1021 let method_doc = get_method_comment(file, service, method).unwrap_or_default();
1023 let method_doc_tokens =
1024 generate_doc_comment(&method_doc, &format!("Handle the {method_name} RPC."));
1025
1026 let client_streaming = method.client_streaming.unwrap_or(false);
1028 let server_streaming = method.server_streaming.unwrap_or(false);
1029
1030 if server_streaming && !client_streaming {
1031 Ok(quote! {
1033 #method_doc_tokens
1034 fn #method_snake(
1035 &self,
1036 ctx: Context,
1037 request: OwnedView<#input_view_type<'static>>,
1038 ) -> impl Future<Output = Result<(Pin<Box<dyn Stream<Item = Result<#output_type, ConnectError>> + Send>>, Context), ConnectError>> + Send;
1039 })
1040 } else if client_streaming && !server_streaming {
1041 Ok(quote! {
1043 #method_doc_tokens
1044 fn #method_snake(
1045 &self,
1046 ctx: Context,
1047 requests: Pin<Box<dyn Stream<Item = Result<OwnedView<#input_view_type<'static>>, ConnectError>> + Send>>,
1048 ) -> impl Future<Output = Result<(#output_type, Context), ConnectError>> + Send;
1049 })
1050 } else if client_streaming && server_streaming {
1051 Ok(quote! {
1053 #method_doc_tokens
1054 fn #method_snake(
1055 &self,
1056 ctx: Context,
1057 requests: Pin<Box<dyn Stream<Item = Result<OwnedView<#input_view_type<'static>>, ConnectError>> + Send>>,
1058 ) -> impl Future<Output = Result<(Pin<Box<dyn Stream<Item = Result<#output_type, ConnectError>> + Send>>, Context), ConnectError>> + Send;
1059 })
1060 } else {
1061 Ok(quote! {
1063 #method_doc_tokens
1064 fn #method_snake(
1065 &self,
1066 ctx: Context,
1067 request: OwnedView<#input_view_type<'static>>,
1068 ) -> impl Future<Output = Result<(#output_type, Context), ConnectError>> + Send;
1069 })
1070 }
1071}
1072
1073fn generate_client_method(
1084 full_service_name: &str,
1085 method: &MethodDescriptorProto,
1086 resolver: &TypeResolver<'_>,
1087 package: &str,
1088) -> Result<TokenStream> {
1089 let method_name = method.name.as_deref().unwrap_or("");
1090 let method_snake = make_field_ident(&method_name.to_snake_case());
1091 let method_with_opts = format_ident!("{}_with_options", method_name.to_snake_case());
1092 let input_type = resolver.rust_type(method.input_type.as_deref().unwrap_or(""), package)?;
1093 let output_view_type =
1094 resolver.rust_view_type(method.output_type.as_deref().unwrap_or(""), package)?;
1095
1096 let client_streaming = method.client_streaming.unwrap_or(false);
1097 let server_streaming = method.server_streaming.unwrap_or(false);
1098
1099 let doc = format!(
1100 " Call the {method_name} RPC. Sends a request to /{full_service_name}/{method_name}."
1101 );
1102 let doc_opts = format!(
1103 " Call the {method_name} RPC with explicit per-call options. \
1104 Options override [`ClientConfig`] defaults."
1105 );
1106
1107 let ret_ty: TokenStream;
1109 let call_body: TokenStream;
1110 let short_args: TokenStream; let opts_args: TokenStream; let short_delegate_args: TokenStream; if client_streaming && !server_streaming {
1115 ret_ty = quote! {
1117 Result<
1118 ::connectrpc::client::UnaryResponse<OwnedView<#output_view_type<'static>>>,
1119 ConnectError,
1120 >
1121 };
1122 call_body = quote! {
1123 call_client_stream(
1124 &self.transport, &self.config,
1125 #full_service_name, #method_name,
1126 requests, options,
1127 ).await
1128 };
1129 short_args = quote! { requests: impl IntoIterator<Item = #input_type> };
1130 opts_args =
1131 quote! { requests: impl IntoIterator<Item = #input_type>, options: CallOptions };
1132 short_delegate_args = quote! { requests, CallOptions::default() };
1133 } else if client_streaming && server_streaming {
1134 ret_ty = quote! {
1136 Result<
1137 ::connectrpc::client::BidiStream<
1138 T::ResponseBody, #input_type, #output_view_type<'static>
1139 >,
1140 ConnectError,
1141 >
1142 };
1143 call_body = quote! {
1144 call_bidi_stream(
1145 &self.transport, &self.config,
1146 #full_service_name, #method_name, options,
1147 ).await
1148 };
1149 short_args = quote! {};
1150 opts_args = quote! { options: CallOptions };
1151 short_delegate_args = quote! { CallOptions::default() };
1152 } else if server_streaming {
1153 ret_ty = quote! {
1155 Result<
1156 ::connectrpc::client::ServerStream<T::ResponseBody, #output_view_type<'static>>,
1157 ConnectError,
1158 >
1159 };
1160 call_body = quote! {
1161 call_server_stream(
1162 &self.transport, &self.config,
1163 #full_service_name, #method_name,
1164 request, options,
1165 ).await
1166 };
1167 short_args = quote! { request: #input_type };
1168 opts_args = quote! { request: #input_type, options: CallOptions };
1169 short_delegate_args = quote! { request, CallOptions::default() };
1170 } else {
1171 ret_ty = quote! {
1173 Result<
1174 ::connectrpc::client::UnaryResponse<OwnedView<#output_view_type<'static>>>,
1175 ConnectError,
1176 >
1177 };
1178 call_body = quote! {
1179 call_unary(
1180 &self.transport, &self.config,
1181 #full_service_name, #method_name,
1182 request, options,
1183 ).await
1184 };
1185 short_args = quote! { request: #input_type };
1186 opts_args = quote! { request: #input_type, options: CallOptions };
1187 short_delegate_args = quote! { request, CallOptions::default() };
1188 }
1189
1190 Ok(quote! {
1191 #[doc = #doc]
1192 pub async fn #method_snake(&self, #short_args) -> #ret_ty {
1193 self.#method_with_opts(#short_delegate_args).await
1194 }
1195
1196 #[doc = #doc_opts]
1197 pub async fn #method_with_opts(&self, #opts_args) -> #ret_ty {
1198 #call_body
1199 }
1200 })
1201}
1202
1203fn get_service_comment(
1205 file: &FileDescriptorProto,
1206 service: &ServiceDescriptorProto,
1207) -> Option<String> {
1208 let source_info: &SourceCodeInfo = &file.source_code_info;
1210
1211 let service_index = file.service.iter().position(|s| s.name == service.name)?;
1213
1214 let target_path = vec![6, service_index as i32];
1217
1218 find_comment(source_info, &target_path)
1219}
1220
1221fn get_method_comment(
1223 file: &FileDescriptorProto,
1224 service: &ServiceDescriptorProto,
1225 method: &MethodDescriptorProto,
1226) -> Option<String> {
1227 let source_info: &SourceCodeInfo = &file.source_code_info;
1228
1229 let (service_index, method_index) = file.service.iter().enumerate().find_map(|(si, s)| {
1232 if s.name != service.name {
1233 return None;
1234 }
1235 s.method
1236 .iter()
1237 .position(|m| m.name == method.name)
1238 .map(|mi| (si, mi))
1239 })?;
1240
1241 let target_path = vec![6, service_index as i32, 2, method_index as i32];
1245
1246 find_comment(source_info, &target_path)
1247}
1248
1249fn find_comment(source_info: &SourceCodeInfo, target_path: &[i32]) -> Option<String> {
1251 for location in &source_info.location {
1252 if location.path == target_path {
1253 let comment = location
1254 .leading_comments
1255 .as_ref()
1256 .or(location.trailing_comments.as_ref())?;
1257
1258 let cleaned: String = comment
1262 .lines()
1263 .map(|line| line.trim())
1264 .filter(|line| !line.is_empty())
1265 .collect::<Vec<_>>()
1266 .join("\n");
1267
1268 if !cleaned.is_empty() {
1269 return Some(cleaned);
1270 }
1271 }
1272 }
1273 None
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use buffa_codegen::generated::descriptor::DescriptorProto;
1280
1281 #[test]
1282 fn doc_attrs_prefixes_space_for_prettyplease() {
1283 let ts = quote! {
1286 #[allow(dead_code)]
1287 mod m {}
1288 };
1289 let doc = doc_attrs("Hello.\n\nSecond paragraph.");
1290 let combined = quote! { #doc #ts };
1291 let file = syn::parse2::<syn::File>(combined).unwrap();
1292 let out = prettyplease::unparse(&file);
1293 assert!(out.contains("/// Hello."), "got: {out}");
1295 assert!(out.contains("/// Second paragraph."), "got: {out}");
1296 assert!(out.contains("///\n"), "got: {out}");
1298 assert!(!out.contains("///Hello"), "got: {out}");
1300 assert!(!out.contains("/// Hello"), "got: {out}");
1301 }
1302
1303 fn minimal_file(
1308 package: Option<&str>,
1309 input_type: &str,
1310 output_type: &str,
1311 local_messages: &[&str],
1312 ) -> FileDescriptorProto {
1313 minimal_file_with_method(package, "Ping", input_type, output_type, local_messages)
1314 }
1315
1316 fn minimal_file_with_method(
1319 package: Option<&str>,
1320 method_name: &str,
1321 input_type: &str,
1322 output_type: &str,
1323 local_messages: &[&str],
1324 ) -> FileDescriptorProto {
1325 let method = MethodDescriptorProto {
1326 name: Some(method_name.into()),
1327 input_type: Some(input_type.into()),
1328 output_type: Some(output_type.into()),
1329 ..Default::default()
1330 };
1331 let service = ServiceDescriptorProto {
1332 name: Some("PingService".into()),
1333 method: vec![method],
1334 ..Default::default()
1335 };
1336 FileDescriptorProto {
1337 name: Some("ping.proto".into()),
1338 package: package.map(|p| p.into()),
1339 service: vec![service],
1340 message_type: local_messages
1341 .iter()
1342 .map(|name| DescriptorProto {
1343 name: Some((*name).into()),
1344 ..Default::default()
1345 })
1346 .collect(),
1347 ..Default::default()
1348 }
1349 }
1350
1351 fn minimal_file_with_methods(package: &str, method_names: &[&str]) -> FileDescriptorProto {
1355 let methods = method_names
1356 .iter()
1357 .map(|n| MethodDescriptorProto {
1358 name: Some((*n).into()),
1359 input_type: Some(format!(".{package}.Empty")),
1360 output_type: Some(format!(".{package}.Empty")),
1361 ..Default::default()
1362 })
1363 .collect();
1364 let service = ServiceDescriptorProto {
1365 name: Some("PingService".into()),
1366 method: methods,
1367 ..Default::default()
1368 };
1369 FileDescriptorProto {
1370 name: Some("ping.proto".into()),
1371 package: Some(package.into()),
1372 service: vec![service],
1373 message_type: vec![DescriptorProto {
1374 name: Some("Empty".into()),
1375 ..Default::default()
1376 }],
1377 ..Default::default()
1378 }
1379 }
1380
1381 fn gen_service(
1390 files: &[FileDescriptorProto],
1391 target_idx: usize,
1392 extern_paths: &[(String, String)],
1393 require_extern: bool,
1394 ) -> Result<String> {
1395 let mut config = buffa_codegen::CodeGenConfig::default();
1396 config.extern_paths = extern_paths.to_vec();
1397 let target_name = files[target_idx]
1398 .name
1399 .clone()
1400 .into_iter()
1401 .collect::<Vec<_>>();
1402 let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
1403 let file = &files[target_idx];
1404 let service = &file.service[0];
1405 Ok(generate_service(file, service, &resolver)?.to_string())
1406 }
1407
1408 #[test]
1409 fn service_name_with_package() {
1410 let file = minimal_file(
1411 Some("example.v1"),
1412 ".example.v1.PingReq",
1413 ".example.v1.PingResp",
1414 &["PingReq", "PingResp"],
1415 );
1416 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1417 assert!(code.contains("\"example.v1.PingService\""), "got: {code}");
1418 }
1419
1420 #[test]
1421 fn service_name_without_package() {
1422 let file = minimal_file(None, ".PingReq", ".PingResp", &["PingReq", "PingResp"]);
1424 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1425 assert!(code.contains("\"PingService\""), "got: {code}");
1426 assert!(
1427 !code.contains("\".PingService\""),
1428 "must not have leading dot: {code}"
1429 );
1430 }
1431
1432 #[test]
1433 fn same_package_types_use_bare_names() {
1434 let file = minimal_file(
1435 Some("example.v1"),
1436 ".example.v1.PingReq",
1437 ".example.v1.PingResp",
1438 &["PingReq", "PingResp"],
1439 );
1440 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1441 assert!(code.contains("PingReq"), "input type missing: {code}");
1443 assert!(code.contains("PingResp"), "output type missing: {code}");
1444 assert!(
1446 !code.contains("super :: PingReq"),
1447 "unexpected super: {code}"
1448 );
1449 }
1450
1451 #[test]
1452 fn cross_package_types_use_relative_paths() {
1453 let common = FileDescriptorProto {
1457 name: Some("common.proto".into()),
1458 package: Some("common.v1".into()),
1459 message_type: vec![DescriptorProto {
1460 name: Some("Shared".into()),
1461 ..Default::default()
1462 }],
1463 ..Default::default()
1464 };
1465 let svc = minimal_file(
1466 Some("example.v1"),
1467 ".common.v1.Shared",
1468 ".example.v1.Out",
1469 &["Out"],
1470 );
1471 let code = gen_service(&[common, svc], 1, &[], false).unwrap();
1472
1473 assert!(
1476 code.contains("super :: super :: common :: v1 :: Shared"),
1477 "cross-package path not emitted: {code}"
1478 );
1479 assert!(
1480 code.contains("super :: super :: common :: v1 :: SharedView"),
1481 "cross-package view path not emitted: {code}"
1482 );
1483 }
1484
1485 #[test]
1486 fn wkt_types_use_buffa_types_extern_path() {
1487 let wkt = FileDescriptorProto {
1491 name: Some("google/protobuf/empty.proto".into()),
1492 package: Some("google.protobuf".into()),
1493 message_type: vec![DescriptorProto {
1494 name: Some("Empty".into()),
1495 ..Default::default()
1496 }],
1497 ..Default::default()
1498 };
1499 let svc = minimal_file(
1500 Some("example.v1"),
1501 ".google.protobuf.Empty",
1502 ".example.v1.Out",
1503 &["Out"],
1504 );
1505 let code = gen_service(&[wkt, svc], 1, &[], false).unwrap();
1506
1507 assert!(
1508 code.contains(":: buffa_types :: google :: protobuf :: Empty"),
1509 "WKT extern path not emitted: {code}"
1510 );
1511 }
1512
1513 #[test]
1514 fn extern_catchall_uses_absolute_paths() {
1515 let file = minimal_file(
1516 Some("example.v1"),
1517 ".example.v1.PingReq",
1518 ".example.v1.PingResp",
1519 &["PingReq", "PingResp"],
1520 );
1521 let extern_paths = [(".".into(), "crate::proto".into())];
1522 let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
1523 assert!(
1524 code.contains("crate :: proto :: example :: v1 :: PingReq"),
1525 "owned type path missing: {code}"
1526 );
1527 assert!(
1528 code.contains("crate :: proto :: example :: v1 :: PingReqView"),
1529 "view type path missing: {code}"
1530 );
1531 }
1532
1533 #[test]
1534 fn extern_catchall_with_wkt_longest_wins() {
1535 let wkt = FileDescriptorProto {
1538 name: Some("google/protobuf/empty.proto".into()),
1539 package: Some("google.protobuf".into()),
1540 message_type: vec![DescriptorProto {
1541 name: Some("Empty".into()),
1542 ..Default::default()
1543 }],
1544 ..Default::default()
1545 };
1546 let svc = minimal_file(
1547 Some("example.v1"),
1548 ".google.protobuf.Empty",
1549 ".example.v1.Out",
1550 &["Out"],
1551 );
1552 let extern_paths = [(".".into(), "crate::proto".into())];
1553 let code = gen_service(&[wkt, svc], 1, &extern_paths, true).unwrap();
1554 assert!(
1555 code.contains(":: buffa_types :: google :: protobuf :: Empty"),
1556 "WKT mapping lost to catch-all: {code}"
1557 );
1558 assert!(
1559 code.contains("crate :: proto :: example :: v1 :: Out"),
1560 "local type not routed through catch-all: {code}"
1561 );
1562 }
1563
1564 #[test]
1565 fn missing_extern_path_errors() {
1566 let file = minimal_file(
1567 Some("example.v1"),
1568 ".example.v1.PingReq",
1569 ".example.v1.PingResp",
1570 &["PingReq", "PingResp"],
1571 );
1572 let err = gen_service(std::slice::from_ref(&file), 0, &[], true).unwrap_err();
1573 let msg = err.to_string();
1574 assert!(
1575 msg.contains("extern_path"),
1576 "error message lacks hint: {msg}"
1577 );
1578 }
1579
1580 #[test]
1581 fn keyword_package_escaped() {
1582 let file = minimal_file(
1584 Some("google.type"),
1585 ".google.type.LatLng",
1586 ".google.type.LatLng",
1587 &["LatLng"],
1588 );
1589 let extern_paths = [(".".into(), "crate::proto".into())];
1590 let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
1591 assert!(
1592 code.contains("crate :: proto :: google :: r#type :: LatLng"),
1593 "keyword segment not escaped: {code}"
1594 );
1595 }
1596
1597 #[test]
1598 fn keyword_method_escaped() {
1599 let file = minimal_file_with_method(
1602 Some("example.v1"),
1603 "Move",
1604 ".example.v1.Empty",
1605 ".example.v1.Empty",
1606 &["Empty"],
1607 );
1608 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1609 assert!(
1610 code.contains("fn r#move"),
1611 "keyword method not escaped: {code}"
1612 );
1613 assert!(
1614 code.contains("move_with_options"),
1615 "suffixed variant should not need escaping: {code}"
1616 );
1617 assert!(code.contains("client.r#move(request)"));
1619 syn::parse_str::<syn::File>(&code).expect("generated code parses");
1620 }
1621
1622 #[test]
1623 fn path_keyword_method_suffixed() {
1624 let file = minimal_file_with_method(
1627 Some("example.v1"),
1628 "Self",
1629 ".example.v1.Empty",
1630 ".example.v1.Empty",
1631 &["Empty"],
1632 );
1633 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1634 assert!(
1635 code.contains("fn self_"),
1636 "path-keyword method not suffixed: {code}"
1637 );
1638 assert!(code.contains("self_with_options"));
1642 syn::parse_str::<syn::File>(&code).expect("generated code parses");
1643 }
1644
1645 #[test]
1646 fn service_name_keyword_suffixed() {
1647 let mut file = minimal_file(
1651 Some("example.v1"),
1652 ".example.v1.Empty",
1653 ".example.v1.Empty",
1654 &["Empty"],
1655 );
1656 file.service[0].name = Some("Self".into());
1657 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1658 assert!(code.contains("trait Self_ "), "trait not suffixed: {code}");
1659 assert!(code.contains("trait SelfExt"));
1660 assert!(code.contains("struct SelfClient"));
1661 assert!(code.contains("struct SelfServer"));
1662 syn::parse_str::<syn::File>(&code).expect("generated code parses");
1663 }
1664
1665 #[test]
1666 fn method_snake_collision_errors() {
1667 let file = minimal_file_with_methods("example.v1", &["GetFoo", "get_foo"]);
1670 let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
1671 let msg = err.to_string();
1672 assert!(msg.contains("PingService"), "missing service name: {msg}");
1673 assert!(msg.contains("\"GetFoo\""), "missing first method: {msg}");
1674 assert!(msg.contains("\"get_foo\""), "missing second method: {msg}");
1675 assert!(msg.contains("`get_foo`"), "missing rust ident: {msg}");
1676 }
1677
1678 #[test]
1679 fn method_with_options_collision_errors() {
1680 let file = minimal_file_with_methods("example.v1", &["Ping", "PingWithOptions"]);
1683 let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
1684 let msg = err.to_string();
1685 assert!(msg.contains("\"Ping\""), "missing first method: {msg}");
1686 assert!(
1687 msg.contains("\"PingWithOptions\""),
1688 "missing second method: {msg}"
1689 );
1690 assert!(
1691 msg.contains("`ping_with_options`"),
1692 "missing rust ident: {msg}"
1693 );
1694 }
1695
1696 #[test]
1697 fn distinct_methods_do_not_collide() {
1698 let file = minimal_file_with_methods("example.v1", &["GetFoo", "GetBar"]);
1699 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
1700 syn::parse_str::<syn::File>(&code).expect("generated code parses");
1701 }
1702}