1use std::collections::HashMap;
12
13use anyhow::Result;
14use heck::ToSnakeCase;
15use heck::ToUpperCamelCase;
16use proc_macro2::{Ident, 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::generated::descriptor;
29pub use buffa_codegen::{CodeGenConfig, GeneratedFile, GeneratedFileKind};
30
31use crate::plugin::CodeGeneratorRequest;
32use crate::plugin::CodeGeneratorResponse;
33use crate::plugin::CodeGeneratorResponseFile;
34
35#[derive(Debug, Clone)]
44#[non_exhaustive]
45pub struct Options {
46 pub buffa: CodeGenConfig,
60}
61
62impl Default for Options {
63 fn default() -> Self {
64 let mut buffa = CodeGenConfig::default();
65 buffa.generate_json = true;
66 Self { buffa }
67 }
68}
69
70impl Options {
71 fn to_buffa_config(&self) -> CodeGenConfig {
74 let mut config = self.buffa.clone();
75 config.generate_views = true;
76 config
77 }
78}
79
80fn emit_service_files(
83 proto_file: &[FileDescriptorProto],
84 file_to_generate: &[String],
85 resolver: &TypeResolver<'_>,
86) -> Result<Vec<GeneratedFile>> {
87 let mut out = Vec::new();
88 let mut batch = BatchState {
96 colliding_aliases: collect_alias_collisions(proto_file, file_to_generate),
97 ..BatchState::default()
98 };
99 for file_name in file_to_generate {
100 let file_desc = proto_file
101 .iter()
102 .find(|f| f.name.as_deref() == Some(file_name.as_str()));
103
104 if let Some(file) = file_desc
105 && !file.service.is_empty()
106 {
107 let service_tokens = generate_connect_services(file, resolver, &mut batch)?;
108 let service_code = format_token_stream(&service_tokens)?;
109 out.push(GeneratedFile {
117 name: format!(
118 "{}.__connect.rs",
119 buffa_codegen::proto_path_to_stem(file_name)
120 ),
121 package: file.package.clone().unwrap_or_default(),
122 kind: GeneratedFileKind::Companion,
123 content: service_code,
124 });
125 }
126 }
127 Ok(out)
128}
129
130pub fn generate_files(
156 proto_file: &[FileDescriptorProto],
157 file_to_generate: &[String],
158 options: &Options,
159) -> Result<Vec<GeneratedFile>> {
160 let config = options.to_buffa_config();
161
162 let mut files = buffa_codegen::generate(proto_file, file_to_generate, &config)
163 .map_err(|e| anyhow::anyhow!("buffa-codegen failed: {e}"))?;
164
165 let resolver = TypeResolver::new(proto_file, file_to_generate, &config, false);
166 let service_files = emit_service_files(proto_file, file_to_generate, &resolver)?;
167
168 if config.file_per_package {
169 inline_companions_into_package_mods(&mut files, service_files);
177 } else {
178 buffa_codegen::apply_companions(&mut files, service_files);
185
186 debug_assert!(
193 files.iter().all(|f| {
194 f.kind != GeneratedFileKind::Companion
195 || files.iter().any(|g| {
196 g.kind == GeneratedFileKind::PackageMod
197 && g.content.contains(&format!("include!(\"{}\")", f.name))
198 })
199 }),
200 "a companion service file was not wired into any package stitcher"
201 );
202 }
203
204 Ok(files)
205}
206
207fn inline_companions_into_package_mods(
226 files: &mut [GeneratedFile],
229 companions: Vec<GeneratedFile>,
230) {
231 debug_assert!(
235 companions.iter().all(|c| files
236 .iter()
237 .any(|f| f.kind == GeneratedFileKind::PackageMod && f.package == c.package)),
238 "a companion service file's package has no PackageMod to inline into"
239 );
240 for comp in companions {
241 if let Some(pkg_mod) = files
242 .iter_mut()
243 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == comp.package)
244 {
245 pkg_mod.content.push('\n');
246 pkg_mod.content.push_str(&comp.content);
247 }
248 }
249}
250
251pub fn generate_services(
286 proto_file: &[FileDescriptorProto],
287 file_to_generate: &[String],
288 options: &Options,
289) -> Result<Vec<GeneratedFile>> {
290 use std::collections::BTreeMap;
291
292 let config = options.to_buffa_config();
293 let resolver = TypeResolver::new(proto_file, file_to_generate, &config, true);
294 let mut files = emit_service_files(proto_file, file_to_generate, &resolver)?;
295
296 if config.file_per_package {
297 let mut by_package: BTreeMap<String, String> = BTreeMap::new();
302 for f in files {
303 let entry = by_package.entry(f.package).or_insert_with(|| {
304 String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n")
305 });
306 entry.push('\n');
307 entry.push_str(&f.content);
308 }
309 return Ok(by_package
310 .into_iter()
311 .map(|(package, content)| GeneratedFile {
312 name: buffa_codegen::package_to_filename(&package),
313 package,
314 kind: GeneratedFileKind::PackageMod,
315 content,
316 })
317 .collect());
318 }
319
320 let mut by_package: BTreeMap<String, Vec<String>> = BTreeMap::new();
326 for f in &files {
327 by_package
328 .entry(f.package.clone())
329 .or_default()
330 .push(f.name.clone());
331 }
332 for (package, names) in by_package {
333 let mut content = String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n");
334 for n in &names {
335 content.push_str(&format!("include!({n:?});\n"));
337 }
338 files.push(GeneratedFile {
339 name: buffa_codegen::package_to_mod_filename(&package),
340 package,
341 kind: GeneratedFileKind::PackageMod,
342 content,
343 });
344 }
345
346 Ok(files)
347}
348
349pub fn generate(request: &CodeGeneratorRequest) -> Result<CodeGeneratorResponse> {
427 let mut options = Options::default();
428
429 if let Some(ref param) = request.parameter {
430 for opt in param.split(',').map(str::trim).filter(|s| !s.is_empty()) {
431 if let Some(value) = opt.strip_prefix("buffa_module=") {
432 let rust = value.trim();
433 if rust.is_empty() {
434 anyhow::bail!(
435 "buffa_module requires a non-empty path, \
436 e.g. buffa_module=crate::proto"
437 );
438 }
439 options
440 .buffa
441 .extern_paths
442 .push((".".into(), rust.to_string()));
443 } else if let Some(value) = opt.strip_prefix("extern_path=") {
444 let (proto, rust) = value.split_once('=').ok_or_else(|| {
446 anyhow::anyhow!(
447 "invalid extern_path format {value:?}, expected \
448 extern_path=.proto.pkg=::rust::path"
449 )
450 })?;
451 let proto = proto.trim();
452 let rust = rust.trim();
453 if proto.is_empty() || rust.is_empty() {
454 anyhow::bail!(
455 "invalid extern_path format {value:?}, expected \
456 extern_path=.proto.pkg=::rust::path (both sides non-empty)"
457 );
458 }
459 let mut proto = proto.to_string();
460 if !proto.starts_with('.') {
461 proto.insert(0, '.');
462 }
463 options.buffa.extern_paths.push((proto, rust.to_string()));
464 } else {
465 match opt {
466 "file_per_package" => options.buffa.file_per_package = true,
467 "strict_utf8_mapping" => options.buffa.strict_utf8_mapping = true,
468 "no_json" => options.buffa.generate_json = false,
469 "no_register_fn" => options.buffa.emit_register_fn = false,
470 _ => {
471 return Err(anyhow::anyhow!(
472 "unknown plugin option: {opt:?}. Supported: \
473 buffa_module=<rust_path>, extern_path=<proto>=<rust>, \
474 file_per_package, strict_utf8_mapping, no_json, \
475 no_register_fn"
476 ));
477 }
478 }
479 }
480 }
481 }
482
483 let generated = generate_services(&request.proto_file, &request.file_to_generate, &options)?;
484
485 let files: Vec<CodeGeneratorResponseFile> = generated
486 .into_iter()
487 .map(|g| CodeGeneratorResponseFile {
488 name: Some(g.name),
489 content: Some(g.content),
490 ..Default::default()
491 })
492 .collect();
493
494 Ok(CodeGeneratorResponse {
495 supported_features: Some(feature_flags()),
496 minimum_edition: Some(EDITION_2023),
497 maximum_edition: Some(EDITION_2023),
498 file: files,
499 ..Default::default()
500 })
501}
502
503fn feature_flags() -> u64 {
506 const FEATURE_PROTO3_OPTIONAL: u64 = 1;
507 const FEATURE_SUPPORTS_EDITIONS: u64 = 2;
508 FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS
509}
510
511const EDITION_2023: i32 = 1000;
514
515fn format_token_stream(tokens: &TokenStream) -> Result<String> {
517 let file = syn::parse2::<syn::File>(tokens.clone())
518 .map_err(|e| anyhow::anyhow!("generated code failed to parse: {e}"))?;
519 Ok(prettyplease::unparse(&file))
520}
521
522fn doc_attrs(text: &str) -> TokenStream {
531 let lines: Vec<String> = text
532 .lines()
533 .map(|l| {
534 if l.is_empty() {
535 String::new()
536 } else {
537 format!(" {l}")
538 }
539 })
540 .collect();
541 quote! { #(#[doc = #lines])* }
542}
543
544struct TypeResolver<'a> {
557 ctx: buffa_codegen::context::CodeGenContext<'a>,
558 require_extern: bool,
564}
565
566impl<'a> TypeResolver<'a> {
567 fn new(
568 proto_file: &'a [FileDescriptorProto],
569 file_to_generate: &[String],
570 config: &'a buffa_codegen::CodeGenConfig,
571 require_extern: bool,
572 ) -> Self {
573 Self {
574 ctx: buffa_codegen::context::CodeGenContext::for_generate(
575 proto_file,
576 file_to_generate,
577 config,
578 ),
579 require_extern,
580 }
581 }
582
583 fn resolve_path(&self, proto_fqn: &str, current_package: &str) -> Result<String> {
590 match self.ctx.rust_type_relative(proto_fqn, current_package, 0) {
591 Some(path) => {
592 self.check_extern_coverage(proto_fqn, &path)?;
593 Ok(path)
594 }
595 None => self.fallback_unresolved(proto_fqn).map(str::to_string),
596 }
597 }
598
599 fn check_extern_coverage(&self, proto_fqn: &str, path_prefix: &str) -> Result<()> {
603 if self.require_extern
604 && !path_prefix.starts_with("::")
605 && !path_prefix.starts_with("crate::")
606 {
607 anyhow::bail!(
608 "type {proto_fqn} is not covered by any extern_path mapping. \
609 Add extern_path=.=<your_buffa_module> (e.g. \
610 extern_path=.=crate::proto) to the plugin opts."
611 );
612 }
613 Ok(())
614 }
615
616 fn fallback_unresolved<'f>(&self, proto_fqn: &'f str) -> Result<&'f str> {
620 if self.require_extern {
621 anyhow::bail!("type {proto_fqn} not found in descriptor set (missing proto import?)");
622 }
623 Ok(bare_type_name(proto_fqn))
624 }
625
626 fn rust_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
628 let path = self.resolve_path(proto_fqn, current_package)?;
629 Ok(rust_path_to_tokens(&path))
630 }
631
632 fn rust_view_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
639 use buffa_codegen::context::SENTINEL_MOD;
640 let (to_package, within) =
641 match self
642 .ctx
643 .rust_type_relative_split(proto_fqn, current_package, 0)
644 {
645 Some(s) => {
646 self.check_extern_coverage(proto_fqn, &s.to_package)?;
647 (s.to_package, s.within_package)
648 }
649 None => (
650 String::new(),
651 self.fallback_unresolved(proto_fqn)?.to_string(),
652 ),
653 };
654 let prefix = if to_package.is_empty() {
655 format!("{SENTINEL_MOD}::view")
656 } else {
657 format!("{to_package}::{SENTINEL_MOD}::view")
658 };
659 Ok(rust_path_to_tokens(&format!("{prefix}::{within}View")))
660 }
661}
662
663fn bare_type_name(proto_fqn: &str) -> &str {
666 proto_fqn
667 .strip_prefix('.')
668 .unwrap_or(proto_fqn)
669 .rsplit('.')
670 .next()
671 .unwrap_or(proto_fqn)
672}
673
674#[derive(Default)]
681struct BatchState {
682 encodable_seen: std::collections::BTreeSet<String>,
685 alias_seen: std::collections::BTreeSet<(String, String)>,
689 colliding_aliases: std::collections::BTreeSet<(String, String)>,
702}
703
704fn generate_connect_services(
705 file: &FileDescriptorProto,
706 resolver: &TypeResolver<'_>,
707 batch: &mut BatchState,
708) -> Result<TokenStream> {
709 let mut tokens = TokenStream::new();
710
711 tokens.extend(generate_owned_view_aliases(file, resolver, batch)?);
717 tokens.extend(generate_encodable_view_impls(file, resolver, batch)?);
718
719 for service in &file.service {
720 tokens.extend(generate_service(file, service, resolver, batch)?);
721 }
722
723 Ok(tokens)
724}
725
726fn owned_view_alias_ident(fqn: &str) -> Ident {
729 format_ident!("Owned{}View", bare_type_name(fqn).to_upper_camel_case())
730}
731
732fn alias_collides(batch: &BatchState, current_package: &str, proto_fqn: &str) -> bool {
740 let alias = owned_view_alias_ident(proto_fqn).to_string();
741 batch
742 .colliding_aliases
743 .contains(&(current_package.to_string(), alias))
744}
745
746fn owned_view_input_arg_type(
753 resolver: &TypeResolver<'_>,
754 batch: &BatchState,
755 proto_fqn: &str,
756 current_package: &str,
757) -> Result<TokenStream> {
758 if alias_collides(batch, current_package, proto_fqn) {
759 let view = resolver.rust_view_type(proto_fqn, current_package)?;
760 Ok(quote!(::buffa::view::OwnedView<#view<'static>>))
761 } else {
762 let alias = owned_view_alias_ident(proto_fqn);
763 Ok(quote!(#alias))
764 }
765}
766
767fn collect_alias_collisions(
779 proto_file: &[FileDescriptorProto],
780 file_to_generate: &[String],
781) -> std::collections::BTreeSet<(String, String)> {
782 use std::collections::BTreeMap;
783 let mut first_seen: BTreeMap<(String, String), String> = BTreeMap::new();
786 let mut colliding: std::collections::BTreeSet<(String, String)> =
787 std::collections::BTreeSet::new();
788
789 for file_name in file_to_generate {
790 let Some(file) = proto_file
791 .iter()
792 .find(|f| f.name.as_deref() == Some(file_name.as_str()))
793 else {
794 continue;
795 };
796 let package = file.package.clone().unwrap_or_default();
797 for service in &file.service {
798 for m in &service.method {
799 for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
800 .into_iter()
801 .flatten()
802 {
803 let alias = owned_view_alias_ident(fqn).to_string();
804 let key = (package.clone(), alias);
805 match first_seen.get(&key) {
806 Some(prev) if prev != fqn => {
807 colliding.insert(key);
808 }
809 Some(_) => {} None => {
811 first_seen.insert(key, fqn.to_string());
812 }
813 }
814 }
815 }
816 }
817 }
818 colliding
819}
820
821fn generate_owned_view_aliases(
839 file: &FileDescriptorProto,
840 resolver: &TypeResolver<'_>,
841 batch: &mut BatchState,
842) -> Result<TokenStream> {
843 let package = file.package.as_deref().unwrap_or("");
844 let mut out = TokenStream::new();
845 for service in &file.service {
846 for m in &service.method {
847 for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
848 .into_iter()
849 .flatten()
850 {
851 if alias_collides(batch, package, fqn) {
852 continue;
853 }
854 if !batch
855 .alias_seen
856 .insert((package.to_string(), fqn.to_string()))
857 {
858 continue;
859 }
860 let alias = owned_view_alias_ident(fqn);
861 let view = resolver.rust_view_type(fqn, package)?;
862 let doc = format!(
863 "Shorthand for `OwnedView<{}View<'static>>`.",
864 bare_type_name(fqn).to_upper_camel_case()
865 );
866 out.extend(quote! {
867 #[doc = #doc]
868 pub type #alias = ::buffa::view::OwnedView<#view<'static>>;
869 });
870 }
871 }
872 }
873 Ok(out)
874}
875
876fn generate_encodable_view_impls(
892 file: &FileDescriptorProto,
893 resolver: &TypeResolver<'_>,
894 batch: &mut BatchState,
895) -> Result<TokenStream> {
896 let package = file.package.as_deref().unwrap_or("");
897 let mut out = TokenStream::new();
898 for service in &file.service {
899 for m in &service.method {
900 let fqn = m.output_type.as_deref().unwrap_or("");
901 if !batch.encodable_seen.insert(fqn.to_string()) {
902 continue;
903 }
904 let path = resolver.resolve_path(fqn, package)?;
905 if path.starts_with("::") {
908 continue;
909 }
910 let owned = resolver.rust_type(fqn, package)?;
911 let view = resolver.rust_view_type(fqn, package)?;
912 out.extend(quote! {
913 impl ::connectrpc::Encodable<#owned> for #view<'_> {
914 fn encode(&self, codec: ::connectrpc::CodecFormat)
915 -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
916 {
917 ::connectrpc::__codegen::encode_view_body(self, codec)
918 }
919 }
920 impl ::connectrpc::Encodable<#owned> for ::buffa::view::OwnedView<#view<'static>> {
921 fn encode(&self, codec: ::connectrpc::CodecFormat)
922 -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
923 {
924 ::connectrpc::__codegen::encode_view_body(&**self, codec)
925 }
926 }
927 });
928 }
929 }
930 Ok(out)
931}
932
933fn check_method_collisions(service_name: &str, service: &ServiceDescriptorProto) -> Result<()> {
942 let mut seen: HashMap<String, String> = HashMap::new();
943 for m in &service.method {
944 let proto_name = m.name.as_deref().unwrap_or("");
945 let snake = proto_name.to_snake_case();
946 let with_opts = format!("{snake}_with_options");
947 for ident in [snake.as_str(), with_opts.as_str()] {
948 if let Some(prev) = seen.get(ident) {
949 anyhow::bail!(
950 "service {service_name}: RPC methods {prev:?} and {proto_name:?} \
951 both generate Rust identifier `{ident}`; rename one in the proto"
952 );
953 }
954 }
955 seen.insert(snake, proto_name.to_string());
956 seen.insert(with_opts, proto_name.to_string());
957 }
958 Ok(())
959}
960
961fn generate_service(
962 file: &FileDescriptorProto,
963 service: &ServiceDescriptorProto,
964 resolver: &TypeResolver<'_>,
965 batch: &BatchState,
966) -> Result<TokenStream> {
967 let package = file.package.as_deref().unwrap_or("");
968 let service_name = service.name.as_deref().unwrap_or("");
969 check_method_collisions(service_name, service)?;
970 let full_service_name = if package.is_empty() {
973 service_name.to_string()
974 } else {
975 format!("{package}.{service_name}")
976 };
977 let service_upper = service_name.to_upper_camel_case();
978 let trait_name = if service_upper == "Self" {
982 format_ident!("Self_")
983 } else {
984 format_ident!("{}", service_upper)
985 };
986 let ext_trait_name = format_ident!("{}Ext", service_upper);
987 let client_name = format_ident!("{}Client", service_upper);
988 let server_name = format_ident!("{}Server", service_upper);
989 let service_name_const = format_ident!(
990 "{}_SERVICE_NAME",
991 service_name.to_snake_case().to_uppercase()
992 );
993
994 let service_doc = get_service_comment(file, service).unwrap_or_default();
996 let base_doc = if service_doc.is_empty() {
997 format!("Server trait for {service_name}.")
998 } else {
999 service_doc
1000 };
1001 let full_doc = format!(
1002 "{base_doc}\n\n\
1003 # Implementing handlers\n\n\
1004 Handlers receive requests as `OwnedFooView` (an alias for\n\
1005 `OwnedView<FooView<'static>>`), which gives zero-copy borrowed access\n\
1006 to fields (e.g. `request.name` is a `&str` into the decoded buffer).\n\
1007 The view can be held across `.await` points. When two RPC types in\n\
1008 the same package would alias to the same `Owned<…>View` name (e.g.\n\
1009 a local message plus an imported one with the same short name), the\n\
1010 alias is suppressed for both and the request type is spelled as\n\
1011 `OwnedView<…View<'static>>` directly in the trait signature.\n\n\
1012 Implement methods with plain `async fn`; the returned future satisfies\n\
1013 the `Send` bound automatically. See the\n\
1014 [buffa user guide](https://github.com/anthropics/buffa/blob/main/docs/guide.md#ownedview-in-async-trait-implementations)\n\
1015 for zero-copy access patterns and when `to_owned_message()` is needed.\n\n\
1016 The `impl Encodable<Out>` return bound accepts the owned `Out`, the\n\
1017 generated `OutView<'_>` / `OwnedOutView`,\n\
1018 [`MaybeBorrowed`](::connectrpc::MaybeBorrowed), or\n\
1019 [`PreEncoded`](::connectrpc::PreEncoded) for handlers that encode a\n\
1020 non-`'static` view internally and pass the bytes across the handler\n\
1021 boundary. View bodies are not emitted for output types mapped via\n\
1022 `extern_path` (the impl would be an orphan); return owned for\n\
1023 WKT/extern outputs.\n\n\
1024 Server-streaming and bidi-streaming methods return\n\
1025 `ServiceStream<impl Encodable<Out> + Send + use<Self>>`. The\n\
1026 `use<Self>` precise-capturing clause excludes `&self`'s lifetime\n\
1027 (unary methods use `use<'a, Self>` and may borrow), so stream items\n\
1028 must be `'static`. To stream view-encoded data, encode each item\n\
1029 inside the stream body and yield\n\
1030 [`PreEncoded`](::connectrpc::PreEncoded) — see its `# Streaming\n\
1031 example` doc."
1032 );
1033 let service_doc_tokens = doc_attrs(&full_doc);
1034
1035 let trait_methods: Vec<TokenStream> = service
1037 .method
1038 .iter()
1039 .map(|m| generate_trait_method(file, service, m, resolver, batch, package))
1040 .collect::<Result<Vec<_>>>()?;
1041
1042 let route_registrations: Vec<TokenStream> = service
1044 .method
1045 .iter()
1046 .map(|m| {
1047 let method_name = m.name.as_deref().unwrap_or("");
1048 let method_snake = make_field_ident(&method_name.to_snake_case());
1049 let spec_const = method_spec_const_ident(service, method_name);
1053
1054 let client_streaming = m.client_streaming.unwrap_or(false);
1055 let server_streaming = m.server_streaming.unwrap_or(false);
1056
1057 let route_call = if server_streaming && !client_streaming {
1058 let output_type = resolver
1063 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1064 .unwrap();
1065 quote! {
1066 .route_view_server_stream::<_, _, #output_type>(
1067 #service_name_const,
1068 #method_name,
1069 ::connectrpc::view_streaming_handler_fn({
1070 let svc = ::std::sync::Arc::clone(&self);
1071 move |ctx, req| {
1072 let svc = ::std::sync::Arc::clone(&svc);
1073 async move { svc.#method_snake(ctx, req).await }
1074 }
1075 }),
1076 )
1077 }
1078 } else if client_streaming && !server_streaming {
1079 let output_type = resolver
1081 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1082 .unwrap();
1083 quote! {
1084 .route_view_client_stream(
1085 #service_name_const,
1086 #method_name,
1087 ::connectrpc::view_client_streaming_handler_fn({
1088 let svc = ::std::sync::Arc::clone(&self);
1089 move |ctx, req, format| {
1090 let svc = ::std::sync::Arc::clone(&svc);
1091 async move {
1092 svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1093 }
1094 }
1095 }),
1096 )
1097 }
1098 } else if client_streaming && server_streaming {
1099 let output_type = resolver
1102 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1103 .unwrap();
1104 quote! {
1105 .route_view_bidi_stream::<_, _, #output_type>(
1106 #service_name_const,
1107 #method_name,
1108 ::connectrpc::view_bidi_streaming_handler_fn({
1109 let svc = ::std::sync::Arc::clone(&self);
1110 move |ctx, req| {
1111 let svc = ::std::sync::Arc::clone(&svc);
1112 async move { svc.#method_snake(ctx, req).await }
1113 }
1114 }),
1115 )
1116 }
1117 } else {
1118 let is_idempotent = m
1120 .options
1121 .idempotency_level
1122 .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1123 .unwrap_or(false);
1124
1125 let route_method = if is_idempotent {
1126 quote! { route_view_idempotent }
1127 } else {
1128 quote! { route_view }
1129 };
1130 let output_type = resolver
1131 .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1132 .unwrap();
1133
1134 quote! {
1135 .#route_method(
1136 #service_name_const,
1137 #method_name,
1138 {
1139 let svc = ::std::sync::Arc::clone(&self);
1140 ::connectrpc::view_handler_fn(move |ctx, req, format| {
1141 let svc = ::std::sync::Arc::clone(&svc);
1142 async move {
1143 svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1144 }
1145 })
1146 },
1147 )
1148 }
1149 };
1150
1151 quote! {
1152 #route_call
1153 .with_spec(#spec_const)
1154 }
1155 })
1156 .collect();
1157
1158 let client_methods: Vec<TokenStream> = service
1160 .method
1161 .iter()
1162 .map(|m| {
1163 generate_client_method(
1164 &service_name_const,
1165 &full_service_name,
1166 m,
1167 resolver,
1168 package,
1169 )
1170 })
1171 .collect::<Result<Vec<_>>>()?;
1172
1173 let service_server = generate_service_server(
1175 &full_service_name,
1176 &trait_name,
1177 &server_name,
1178 service,
1179 resolver,
1180 package,
1181 )?;
1182
1183 let example_method = service
1185 .method
1186 .first()
1187 .and_then(|m| m.name.as_deref())
1188 .map(|n| make_field_ident(&n.to_snake_case()).to_string())
1189 .unwrap_or_else(|| "method".to_string());
1190
1191 let client_name_str = client_name.to_string();
1193 let client_doc = format!(
1194 r#"Client for this service.
1195
1196Generic over `T: ClientTransport`. For **gRPC** (HTTP/2), use
1197`Http2Connection` — it has honest `poll_ready` and composes with
1198`tower::balance` for multi-connection load balancing. For **Connect
1199over HTTP/1.1** (or unknown protocol), use `HttpClient`.
1200
1201# Example (gRPC / HTTP/2)
1202
1203```rust,ignore
1204use connectrpc::client::{{Http2Connection, ClientConfig}};
1205use connectrpc::Protocol;
1206
1207let uri: http::Uri = "http://localhost:8080".parse()?;
1208let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
1209let config = ClientConfig::new(uri).with_protocol(Protocol::Grpc);
1210
1211let client = {client_name_str}::new(conn, config);
1212let response = client.{example_method}(request).await?;
1213```
1214
1215# Example (Connect / HTTP/1.1 or ALPN)
1216
1217```rust,ignore
1218use connectrpc::client::{{HttpClient, ClientConfig}};
1219
1220let http = HttpClient::plaintext(); // cleartext http:// only
1221let config = ClientConfig::new("http://localhost:8080".parse()?);
1222
1223let client = {client_name_str}::new(http, config);
1224let response = client.{example_method}(request).await?;
1225```
1226
1227# Working with the response
1228
1229Unary calls return [`UnaryResponse<OwnedView<FooView>>`](::connectrpc::client::UnaryResponse).
1230The `OwnedView` derefs to the view, so field access is zero-copy:
1231
1232```rust,ignore
1233let resp = client.{example_method}(request).await?.into_view();
1234let name: &str = resp.name; // borrow into the response buffer
1235```
1236
1237If you need the owned struct (e.g. to store or pass by value), use
1238[`into_owned()`](::connectrpc::client::UnaryResponse::into_owned):
1239
1240```rust,ignore
1241let owned = client.{example_method}(request).await?.into_owned();
1242```"#
1243 );
1244 let client_doc_tokens = doc_attrs(&client_doc);
1245
1246 let spec_consts = generate_spec_consts(&full_service_name, service);
1250
1251 Ok(quote! {
1252 pub const #service_name_const: &str = #full_service_name;
1258
1259 #(#spec_consts)*
1260
1261 #service_doc_tokens
1262 #[allow(clippy::type_complexity)]
1263 pub trait #trait_name: Send + Sync + 'static {
1264 #(#trait_methods)*
1265 }
1266
1267 pub trait #ext_trait_name: #trait_name {
1280 fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router;
1285 }
1286
1287 impl<S: #trait_name> #ext_trait_name for S {
1288 fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router {
1289 router
1290 #(#route_registrations)*
1291 }
1292 }
1293
1294 #service_server
1295
1296 #client_doc_tokens
1297 #[derive(Clone)]
1298 pub struct #client_name<T> {
1299 transport: T,
1300 config: ::connectrpc::client::ClientConfig,
1301 }
1302
1303 impl<T> #client_name<T>
1304 where
1305 T: ::connectrpc::client::ClientTransport,
1306 <T::ResponseBody as ::http_body::Body>::Error: ::std::fmt::Display,
1307 {
1308 pub fn new(transport: T, config: ::connectrpc::client::ClientConfig) -> Self {
1310 Self { transport, config }
1311 }
1312
1313 pub fn config(&self) -> &::connectrpc::client::ClientConfig {
1315 &self.config
1316 }
1317
1318 pub fn config_mut(&mut self) -> &mut ::connectrpc::client::ClientConfig {
1320 &mut self.config
1321 }
1322
1323 #(#client_methods)*
1324 }
1325 })
1326}
1327
1328fn method_spec_const_ident(service: &ServiceDescriptorProto, method_name: &str) -> Ident {
1335 let service_name = service.name.as_deref().unwrap_or("");
1336 format_ident!(
1337 "{}_{}_SPEC",
1338 service_name.to_snake_case().to_uppercase(),
1339 method_name.to_snake_case().to_uppercase()
1340 )
1341}
1342
1343fn generate_spec_consts(
1352 full_service_name: &str,
1353 service: &ServiceDescriptorProto,
1354) -> Vec<TokenStream> {
1355 service
1356 .method
1357 .iter()
1358 .map(|m| {
1359 let method_name = m.name.as_deref().unwrap_or("");
1360 let spec_const = method_spec_const_ident(service, method_name);
1361 let procedure = format!("/{full_service_name}/{method_name}");
1362 let cs = m.client_streaming.unwrap_or(false);
1363 let ss = m.server_streaming.unwrap_or(false);
1364 let stream_type = match (cs, ss) {
1365 (true, true) => quote! { ::connectrpc::StreamType::BidiStream },
1366 (true, false) => quote! { ::connectrpc::StreamType::ClientStream },
1367 (false, true) => quote! { ::connectrpc::StreamType::ServerStream },
1368 (false, false) => quote! { ::connectrpc::StreamType::Unary },
1369 };
1370 let idempotency_level = match m.options.idempotency_level {
1371 Some(IdempotencyLevel::NO_SIDE_EFFECTS) => {
1372 quote! { ::connectrpc::IdempotencyLevel::NoSideEffects }
1373 }
1374 Some(IdempotencyLevel::IDEMPOTENT) => {
1375 quote! { ::connectrpc::IdempotencyLevel::Idempotent }
1376 }
1377 _ => quote! { ::connectrpc::IdempotencyLevel::Unknown },
1378 };
1379 let doc = format!(
1380 "Static [`Spec`](::connectrpc::Spec) for the server-side `{method_name}` RPC.\n\n\
1381 The dispatcher surfaces this on\n\
1382 [`RequestContext::spec`](::connectrpc::RequestContext::spec)."
1383 );
1384 let doc_tokens = doc_attrs(&doc);
1385 quote! {
1386 #doc_tokens
1387 pub const #spec_const: ::connectrpc::Spec =
1388 ::connectrpc::Spec::server(#procedure, #stream_type)
1389 .with_idempotency_level(#idempotency_level);
1390 }
1391 })
1392 .collect()
1393}
1394
1395fn generate_service_server(
1402 full_service_name: &str,
1403 trait_name: &proc_macro2::Ident,
1404 server_name: &proc_macro2::Ident,
1405 service: &ServiceDescriptorProto,
1406 resolver: &TypeResolver<'_>,
1407 package: &str,
1408) -> Result<TokenStream> {
1409 let path_prefix = format!("{full_service_name}/");
1411
1412 let lookup_arms: Vec<TokenStream> = service
1414 .method
1415 .iter()
1416 .map(|m| {
1417 let method_name = m.name.as_deref().unwrap_or("");
1418 let client_streaming = m.client_streaming.unwrap_or(false);
1419 let server_streaming = m.server_streaming.unwrap_or(false);
1420 let is_idempotent = m
1421 .options
1422 .idempotency_level
1423 .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1424 .unwrap_or(false);
1425 let spec_const = method_spec_const_ident(service, method_name);
1426
1427 let desc = if client_streaming && server_streaming {
1428 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::bidi_streaming() }
1429 } else if client_streaming {
1430 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::client_streaming() }
1431 } else if server_streaming {
1432 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::server_streaming() }
1433 } else {
1434 quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::unary(#is_idempotent) }
1435 };
1436 quote! { #method_name => Some(#desc.with_spec(#spec_const)), }
1437 })
1438 .collect();
1439
1440 let mut call_unary_arms: Vec<TokenStream> = Vec::new();
1445 let mut call_ss_arms: Vec<TokenStream> = Vec::new();
1446 let mut call_cs_arms: Vec<TokenStream> = Vec::new();
1447 let mut call_bidi_arms: Vec<TokenStream> = Vec::new();
1448
1449 for m in &service.method {
1450 let method_name = m.name.as_deref().unwrap_or("");
1451 let method_snake = make_field_ident(&method_name.to_snake_case());
1452 let input_view = resolver.rust_view_type(m.input_type.as_deref().unwrap_or(""), package)?;
1453 let output_type = resolver.rust_type(m.output_type.as_deref().unwrap_or(""), package)?;
1454 let cs = m.client_streaming.unwrap_or(false);
1455 let ss = m.server_streaming.unwrap_or(false);
1456
1457 if cs && ss {
1458 call_bidi_arms.push(quote! {
1460 #method_name => {
1461 let svc = ::std::sync::Arc::clone(&self.inner);
1462 Box::pin(async move {
1463 let req_stream = ::connectrpc::dispatcher::codegen::decode_view_request_stream::<#input_view>(requests, format);
1464 let resp = svc.#method_snake(ctx, req_stream).await?;
1465 Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
1466 })
1467 }
1468 });
1469 } else if cs {
1470 call_cs_arms.push(quote! {
1472 #method_name => {
1473 let svc = ::std::sync::Arc::clone(&self.inner);
1474 Box::pin(async move {
1475 let req_stream = ::connectrpc::dispatcher::codegen::decode_view_request_stream::<#input_view>(requests, format);
1476 svc.#method_snake(ctx, req_stream).await?.encode::<#output_type>(format)
1477 })
1478 }
1479 });
1480 } else if ss {
1481 call_ss_arms.push(quote! {
1483 #method_name => {
1484 let svc = ::std::sync::Arc::clone(&self.inner);
1485 Box::pin(async move {
1486 let req = ::connectrpc::dispatcher::codegen::decode_request_view::<#input_view>(request, format)?;
1487 let resp = svc.#method_snake(ctx, req).await?;
1488 Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
1489 })
1490 }
1491 });
1492 } else {
1493 call_unary_arms.push(quote! {
1495 #method_name => {
1496 let svc = ::std::sync::Arc::clone(&self.inner);
1497 Box::pin(async move {
1498 let req = ::connectrpc::dispatcher::codegen::decode_request_view::<#input_view>(request.encoded()?, format)?;
1503 svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1504 })
1505 }
1506 });
1507 }
1508 }
1509
1510 let server_doc = format!(
1511 "Monomorphic dispatcher for `{trait_name}`.\n\n\
1512 Unlike `.register(Router)` which type-erases each method into an \
1513 `Arc<dyn ErasedHandler>` stored in a `HashMap`, this struct dispatches \
1514 via a compile-time `match` on method name: no vtable, no hash lookup.\n\n\
1515 # Example\n\n\
1516 ```rust,ignore\n\
1517 use connectrpc::ConnectRpcService;\n\n\
1518 let server = {server_name}::new(MyImpl);\n\
1519 let service = ConnectRpcService::new(server);\n\
1520 // hand `service` to axum/hyper as a fallback_service\n\
1521 ```"
1522 );
1523 let server_doc_tokens = doc_attrs(&server_doc);
1524
1525 Ok(quote! {
1526 #server_doc_tokens
1527 pub struct #server_name<T> {
1528 inner: ::std::sync::Arc<T>,
1529 }
1530
1531 impl<T: #trait_name> #server_name<T> {
1532 pub fn new(service: T) -> Self {
1534 Self { inner: ::std::sync::Arc::new(service) }
1535 }
1536
1537 pub fn from_arc(inner: ::std::sync::Arc<T>) -> Self {
1539 Self { inner }
1540 }
1541 }
1542
1543 impl<T> Clone for #server_name<T> {
1544 fn clone(&self) -> Self {
1545 Self { inner: ::std::sync::Arc::clone(&self.inner) }
1546 }
1547 }
1548
1549 impl<T: #trait_name> ::connectrpc::Dispatcher for #server_name<T> {
1550 #[inline]
1551 fn lookup(&self, path: &str) -> Option<::connectrpc::dispatcher::codegen::MethodDescriptor> {
1552 let method = path.strip_prefix(#path_prefix)?;
1553 match method {
1554 #(#lookup_arms)*
1555 _ => None,
1556 }
1557 }
1558
1559 fn call_unary(
1560 &self,
1561 path: &str,
1562 ctx: ::connectrpc::RequestContext,
1563 request: ::connectrpc::Payload,
1564 format: ::connectrpc::CodecFormat,
1565 ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
1566 let Some(method) = path.strip_prefix(#path_prefix) else {
1567 return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
1568 };
1569 let _ = (&ctx, &request, &format);
1571 match method {
1572 #(#call_unary_arms)*
1573 _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
1574 }
1575 }
1576
1577 fn call_server_streaming(
1578 &self,
1579 path: &str,
1580 ctx: ::connectrpc::RequestContext,
1581 request: ::buffa::bytes::Bytes,
1582 format: ::connectrpc::CodecFormat,
1583 ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
1584 let Some(method) = path.strip_prefix(#path_prefix) else {
1585 return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
1586 };
1587 let _ = (&ctx, &request, &format);
1588 match method {
1589 #(#call_ss_arms)*
1590 _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
1591 }
1592 }
1593
1594 fn call_client_streaming(
1595 &self,
1596 path: &str,
1597 ctx: ::connectrpc::RequestContext,
1598 requests: ::connectrpc::dispatcher::codegen::RequestStream,
1599 format: ::connectrpc::CodecFormat,
1600 ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
1601 let Some(method) = path.strip_prefix(#path_prefix) else {
1602 return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
1603 };
1604 let _ = (&ctx, &requests, &format);
1605 match method {
1606 #(#call_cs_arms)*
1607 _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
1608 }
1609 }
1610
1611 fn call_bidi_streaming(
1612 &self,
1613 path: &str,
1614 ctx: ::connectrpc::RequestContext,
1615 requests: ::connectrpc::dispatcher::codegen::RequestStream,
1616 format: ::connectrpc::CodecFormat,
1617 ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
1618 let Some(method) = path.strip_prefix(#path_prefix) else {
1619 return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
1620 };
1621 let _ = (&ctx, &requests, &format);
1622 match method {
1623 #(#call_bidi_arms)*
1624 _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
1625 }
1626 }
1627 }
1628 })
1629}
1630
1631fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
1633 let comment = if doc.is_empty() { default } else { doc };
1634 doc_attrs(comment)
1635}
1636
1637fn generate_trait_method(
1639 file: &FileDescriptorProto,
1640 service: &ServiceDescriptorProto,
1641 method: &MethodDescriptorProto,
1642 resolver: &TypeResolver<'_>,
1643 batch: &BatchState,
1644 package: &str,
1645) -> Result<TokenStream> {
1646 let method_name = method.name.as_deref().unwrap_or("");
1647 let method_snake = make_field_ident(&method_name.to_snake_case());
1648 let input_arg = owned_view_input_arg_type(
1649 resolver,
1650 batch,
1651 method.input_type.as_deref().unwrap_or(""),
1652 package,
1653 )?;
1654 let output_type = resolver.rust_type(method.output_type.as_deref().unwrap_or(""), package)?;
1655
1656 let method_doc = get_method_comment(file, service, method).unwrap_or_default();
1658 let method_doc_tokens =
1659 generate_doc_comment(&method_doc, &format!("Handle the {method_name} RPC."));
1660
1661 let client_streaming = method.client_streaming.unwrap_or(false);
1663 let server_streaming = method.server_streaming.unwrap_or(false);
1664
1665 let borrow_doc = quote! {
1666 #[doc = ""]
1667 #[doc = " `'a` lets the response body borrow from `&self` (e.g. server-resident state)."]
1668 };
1669
1670 if server_streaming && !client_streaming {
1671 Ok(quote! {
1679 #method_doc_tokens
1680 fn #method_snake(
1681 &self,
1682 ctx: ::connectrpc::RequestContext,
1683 request: #input_arg,
1684 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
1685 })
1686 } else if client_streaming && !server_streaming {
1687 Ok(quote! {
1689 #method_doc_tokens
1690 #borrow_doc
1691 fn #method_snake<'a>(
1692 &'a self,
1693 ctx: ::connectrpc::RequestContext,
1694 requests: ::connectrpc::ServiceStream<#input_arg>,
1695 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
1696 })
1697 } else if client_streaming && server_streaming {
1698 Ok(quote! {
1701 #method_doc_tokens
1702 fn #method_snake(
1703 &self,
1704 ctx: ::connectrpc::RequestContext,
1705 requests: ::connectrpc::ServiceStream<#input_arg>,
1706 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
1707 })
1708 } else {
1709 Ok(quote! {
1711 #method_doc_tokens
1712 #borrow_doc
1713 fn #method_snake<'a>(
1714 &'a self,
1715 ctx: ::connectrpc::RequestContext,
1716 request: #input_arg,
1717 ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
1718 })
1719 }
1720}
1721
1722fn generate_client_method(
1733 service_name_const: &Ident,
1734 full_service_name: &str,
1735 method: &MethodDescriptorProto,
1736 resolver: &TypeResolver<'_>,
1737 package: &str,
1738) -> Result<TokenStream> {
1739 let method_name = method.name.as_deref().unwrap_or("");
1740 let method_snake = make_field_ident(&method_name.to_snake_case());
1741 let method_with_opts = format_ident!("{}_with_options", method_name.to_snake_case());
1742 let input_type = resolver.rust_type(method.input_type.as_deref().unwrap_or(""), package)?;
1743 let output_view_type =
1744 resolver.rust_view_type(method.output_type.as_deref().unwrap_or(""), package)?;
1745
1746 let client_streaming = method.client_streaming.unwrap_or(false);
1747 let server_streaming = method.server_streaming.unwrap_or(false);
1748
1749 let doc = format!(
1750 " Call the {method_name} RPC. Sends a request to /{full_service_name}/{method_name}."
1751 );
1752 let doc_opts = format!(
1753 " Call the {method_name} RPC with explicit per-call options. \
1754 Options override [`ClientConfig`](::connectrpc::client::ClientConfig) defaults."
1755 );
1756
1757 let ret_ty: TokenStream;
1759 let call_body: TokenStream;
1760 let short_args: TokenStream; let opts_args: TokenStream; let short_delegate_args: TokenStream; if client_streaming && !server_streaming {
1765 ret_ty = quote! {
1767 Result<
1768 ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
1769 ::connectrpc::ConnectError,
1770 >
1771 };
1772 call_body = quote! {
1773 ::connectrpc::client::call_client_stream(
1774 &self.transport, &self.config,
1775 #service_name_const, #method_name,
1776 requests, options,
1777 ).await
1778 };
1779 short_args = quote! { requests: impl IntoIterator<Item = #input_type> };
1780 opts_args = quote! { requests: impl IntoIterator<Item = #input_type>, options: ::connectrpc::client::CallOptions };
1781 short_delegate_args = quote! { requests, ::connectrpc::client::CallOptions::default() };
1782 } else if client_streaming && server_streaming {
1783 ret_ty = quote! {
1785 Result<
1786 ::connectrpc::client::BidiStream<
1787 T::ResponseBody, #input_type, #output_view_type<'static>
1788 >,
1789 ::connectrpc::ConnectError,
1790 >
1791 };
1792 call_body = quote! {
1793 ::connectrpc::client::call_bidi_stream(
1794 &self.transport, &self.config,
1795 #service_name_const, #method_name, options,
1796 ).await
1797 };
1798 short_args = quote! {};
1799 opts_args = quote! { options: ::connectrpc::client::CallOptions };
1800 short_delegate_args = quote! { ::connectrpc::client::CallOptions::default() };
1801 } else if server_streaming {
1802 ret_ty = quote! {
1804 Result<
1805 ::connectrpc::client::ServerStream<T::ResponseBody, #output_view_type<'static>>,
1806 ::connectrpc::ConnectError,
1807 >
1808 };
1809 call_body = quote! {
1810 ::connectrpc::client::call_server_stream(
1811 &self.transport, &self.config,
1812 #service_name_const, #method_name,
1813 request, options,
1814 ).await
1815 };
1816 short_args = quote! { request: #input_type };
1817 opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
1818 short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
1819 } else {
1820 ret_ty = quote! {
1822 Result<
1823 ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
1824 ::connectrpc::ConnectError,
1825 >
1826 };
1827 call_body = quote! {
1828 ::connectrpc::client::call_unary(
1829 &self.transport, &self.config,
1830 #service_name_const, #method_name,
1831 request, options,
1832 ).await
1833 };
1834 short_args = quote! { request: #input_type };
1835 opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
1836 short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
1837 }
1838
1839 Ok(quote! {
1840 #[doc = #doc]
1841 pub async fn #method_snake(&self, #short_args) -> #ret_ty {
1842 self.#method_with_opts(#short_delegate_args).await
1843 }
1844
1845 #[doc = #doc_opts]
1846 pub async fn #method_with_opts(&self, #opts_args) -> #ret_ty {
1847 #call_body
1848 }
1849 })
1850}
1851
1852fn get_service_comment(
1854 file: &FileDescriptorProto,
1855 service: &ServiceDescriptorProto,
1856) -> Option<String> {
1857 let source_info: &SourceCodeInfo = &file.source_code_info;
1859
1860 let service_index = file.service.iter().position(|s| s.name == service.name)?;
1862
1863 let target_path = vec![6, service_index as i32];
1866
1867 find_comment(source_info, &target_path)
1868}
1869
1870fn get_method_comment(
1872 file: &FileDescriptorProto,
1873 service: &ServiceDescriptorProto,
1874 method: &MethodDescriptorProto,
1875) -> Option<String> {
1876 let source_info: &SourceCodeInfo = &file.source_code_info;
1877
1878 let (service_index, method_index) = file.service.iter().enumerate().find_map(|(si, s)| {
1881 if s.name != service.name {
1882 return None;
1883 }
1884 s.method
1885 .iter()
1886 .position(|m| m.name == method.name)
1887 .map(|mi| (si, mi))
1888 })?;
1889
1890 let target_path = vec![6, service_index as i32, 2, method_index as i32];
1894
1895 find_comment(source_info, &target_path)
1896}
1897
1898fn find_comment(source_info: &SourceCodeInfo, target_path: &[i32]) -> Option<String> {
1900 for location in &source_info.location {
1901 if location.path == target_path {
1902 let comment = location
1903 .leading_comments
1904 .as_ref()
1905 .or(location.trailing_comments.as_ref())?;
1906
1907 let cleaned: String = comment
1911 .lines()
1912 .map(|line| line.trim())
1913 .filter(|line| !line.is_empty())
1914 .collect::<Vec<_>>()
1915 .join("\n");
1916
1917 if !cleaned.is_empty() {
1918 return Some(cleaned);
1919 }
1920 }
1921 }
1922 None
1923}
1924
1925#[cfg(test)]
1926mod tests {
1927 use super::*;
1928 use buffa_codegen::generated::descriptor::DescriptorProto;
1929
1930 #[test]
1931 fn doc_attrs_prefixes_space_for_prettyplease() {
1932 let ts = quote! {
1935 #[allow(dead_code)]
1936 mod m {}
1937 };
1938 let doc = doc_attrs("Hello.\n\nSecond paragraph.");
1939 let combined = quote! { #doc #ts };
1940 let file = syn::parse2::<syn::File>(combined).unwrap();
1941 let out = prettyplease::unparse(&file);
1942 assert!(out.contains("/// Hello."), "got: {out}");
1944 assert!(out.contains("/// Second paragraph."), "got: {out}");
1945 assert!(out.contains("///\n"), "got: {out}");
1947 assert!(!out.contains("///Hello"), "got: {out}");
1949 assert!(!out.contains("/// Hello"), "got: {out}");
1950 }
1951
1952 fn minimal_file(
1957 package: Option<&str>,
1958 input_type: &str,
1959 output_type: &str,
1960 local_messages: &[&str],
1961 ) -> FileDescriptorProto {
1962 minimal_file_with_method(package, "Ping", input_type, output_type, local_messages)
1963 }
1964
1965 fn minimal_file_with_method(
1968 package: Option<&str>,
1969 method_name: &str,
1970 input_type: &str,
1971 output_type: &str,
1972 local_messages: &[&str],
1973 ) -> FileDescriptorProto {
1974 let method = MethodDescriptorProto {
1975 name: Some(method_name.into()),
1976 input_type: Some(input_type.into()),
1977 output_type: Some(output_type.into()),
1978 ..Default::default()
1979 };
1980 let service = ServiceDescriptorProto {
1981 name: Some("PingService".into()),
1982 method: vec![method],
1983 ..Default::default()
1984 };
1985 FileDescriptorProto {
1986 name: Some("ping.proto".into()),
1987 package: package.map(|p| p.into()),
1988 service: vec![service],
1989 message_type: local_messages
1990 .iter()
1991 .map(|name| DescriptorProto {
1992 name: Some((*name).into()),
1993 ..Default::default()
1994 })
1995 .collect(),
1996 ..Default::default()
1997 }
1998 }
1999
2000 fn minimal_file_with_methods(package: &str, method_names: &[&str]) -> FileDescriptorProto {
2004 let methods = method_names
2005 .iter()
2006 .map(|n| MethodDescriptorProto {
2007 name: Some((*n).into()),
2008 input_type: Some(format!(".{package}.Empty")),
2009 output_type: Some(format!(".{package}.Empty")),
2010 ..Default::default()
2011 })
2012 .collect();
2013 let service = ServiceDescriptorProto {
2014 name: Some("PingService".into()),
2015 method: methods,
2016 ..Default::default()
2017 };
2018 FileDescriptorProto {
2019 name: Some("ping.proto".into()),
2020 package: Some(package.into()),
2021 service: vec![service],
2022 message_type: vec![DescriptorProto {
2023 name: Some("Empty".into()),
2024 ..Default::default()
2025 }],
2026 ..Default::default()
2027 }
2028 }
2029
2030 fn gen_service(
2039 files: &[FileDescriptorProto],
2040 target_idx: usize,
2041 extern_paths: &[(String, String)],
2042 require_extern: bool,
2043 ) -> Result<String> {
2044 let mut config = buffa_codegen::CodeGenConfig::default();
2045 config.extern_paths = extern_paths.to_vec();
2046 let target_name = files[target_idx]
2047 .name
2048 .clone()
2049 .into_iter()
2050 .collect::<Vec<_>>();
2051 let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
2052 let file = &files[target_idx];
2053 let service = &file.service[0];
2054 let batch = BatchState {
2055 colliding_aliases: collect_alias_collisions(files, &target_name),
2056 ..BatchState::default()
2057 };
2058 Ok(generate_service(file, service, &resolver, &batch)?.to_string())
2059 }
2060
2061 fn assert_no_top_level_use(formatted: &str, label: &str) {
2066 let parsed: syn::File = syn::parse_str(formatted).expect("formatted code parses");
2067 let offenders: Vec<String> = parsed
2068 .items
2069 .iter()
2070 .filter_map(|item| match item {
2071 syn::Item::Use(u) => Some(quote!(#u).to_string()),
2072 _ => None,
2073 })
2074 .collect();
2075 assert!(
2076 offenders.is_empty(),
2077 "{label} contains top-level use statement(s): {offenders:?}\nFull source:\n{formatted}"
2078 );
2079 }
2080
2081 fn gen_file(
2082 files: &[FileDescriptorProto],
2083 target_idx: usize,
2084 extern_paths: &[(String, String)],
2085 require_extern: bool,
2086 ) -> Result<String> {
2087 let mut config = buffa_codegen::CodeGenConfig::default();
2088 config.extern_paths = extern_paths.to_vec();
2089 let target_name = files[target_idx]
2090 .name
2091 .clone()
2092 .into_iter()
2093 .collect::<Vec<_>>();
2094 let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
2095 let mut batch = BatchState {
2096 colliding_aliases: collect_alias_collisions(files, &target_name),
2097 ..BatchState::default()
2098 };
2099 Ok(generate_connect_services(&files[target_idx], &resolver, &mut batch)?.to_string())
2100 }
2101
2102 #[test]
2103 fn unary_response_body_captures_self_lifetime() {
2104 let file = minimal_file(
2105 Some("example.v1"),
2106 ".example.v1.PingReq",
2107 ".example.v1.PingResp",
2108 &["PingReq", "PingResp"],
2109 );
2110 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2111 assert!(code.contains("< 'a >"), "trait method missing 'a: {code}");
2112 assert!(code.contains("& 'a self"), "missing &'a self: {code}");
2113 assert!(
2114 code.contains("use < 'a , Self >"),
2115 "missing use<'a, Self> capture: {code}"
2116 );
2117 assert!(
2118 !code.contains("'static + use"),
2119 "'static bound on body should be dropped: {code}"
2120 );
2121 }
2122
2123 #[test]
2124 fn owned_view_aliases_emitted_for_input_and_output() {
2125 let file = minimal_file(
2126 Some("example.v1"),
2127 ".example.v1.PingReq",
2128 ".example.v1.PingResp",
2129 &["PingReq", "PingResp"],
2130 );
2131 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2132 assert!(
2133 code.contains("pub type OwnedPingReqView = :: buffa :: view :: OwnedView"),
2134 "missing OwnedPingReqView alias: {code}"
2135 );
2136 assert!(
2137 code.contains("pub type OwnedPingRespView = :: buffa :: view :: OwnedView"),
2138 "missing OwnedPingRespView alias: {code}"
2139 );
2140 assert!(
2142 code.contains("request : OwnedPingReqView ,"),
2143 "trait method should take request: OwnedPingReqView: {code}"
2144 );
2145 }
2146
2147 #[test]
2148 fn cross_package_input_collision_suppresses_alias_for_both_sides() {
2149 let v1 = FileDescriptorProto {
2157 name: Some("api/v1/foo/bar/foobar.proto".into()),
2158 package: Some("api.v1.foo.bar".into()),
2159 message_type: vec![DescriptorProto {
2160 name: Some("MyMessage".into()),
2161 ..Default::default()
2162 }],
2163 ..Default::default()
2164 };
2165 let v2 = minimal_file(
2166 Some("api.v2.foo.bar"),
2167 ".api.v1.foo.bar.MyMessage",
2168 ".api.v2.foo.bar.MyMessage",
2169 &["MyMessage"],
2170 );
2171 let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2172
2173 let alias_count = code.matches("pub type OwnedMyMessageView").count();
2176 assert_eq!(
2177 alias_count, 0,
2178 "expected zero OwnedMyMessageView aliases when both sides collide; got {alias_count}: {code}"
2179 );
2180
2181 assert!(
2184 !code.contains("request : OwnedMyMessageView"),
2185 "colliding input must not reference the suppressed alias: {code}"
2186 );
2187 assert!(
2188 code.contains("request : :: buffa :: view :: OwnedView <"),
2189 "colliding input should be inlined as OwnedView<…<'static>>: {code}"
2190 );
2191 }
2192
2193 #[test]
2194 fn cross_package_input_without_collision_keeps_alias() {
2195 let wkt = FileDescriptorProto {
2202 name: Some("google/protobuf/empty.proto".into()),
2203 package: Some("google.protobuf".into()),
2204 message_type: vec![DescriptorProto {
2205 name: Some("Empty".into()),
2206 ..Default::default()
2207 }],
2208 ..Default::default()
2209 };
2210 let svc = minimal_file(
2211 Some("example.v1"),
2212 ".google.protobuf.Empty",
2213 ".example.v1.PingResp",
2214 &["PingResp"],
2215 );
2216 let code = gen_file(&[wkt, svc], 1, &[], false).unwrap();
2217 assert!(
2218 code.contains("pub type OwnedEmptyView = :: buffa :: view :: OwnedView"),
2219 "WKT cross-package input should keep its alias: {code}"
2220 );
2221 assert!(
2222 code.contains("request : OwnedEmptyView ,"),
2223 "trait method should still use OwnedEmptyView for non-colliding cross-package input: {code}"
2224 );
2225 }
2226
2227 #[test]
2228 fn collision_inlines_in_all_streaming_method_shapes() {
2229 let v1 = FileDescriptorProto {
2235 name: Some("api/v1/foo/bar/foobar.proto".into()),
2236 package: Some("api.v1.foo.bar".into()),
2237 message_type: vec![DescriptorProto {
2238 name: Some("MyMessage".into()),
2239 ..Default::default()
2240 }],
2241 ..Default::default()
2242 };
2243 let v2 = FileDescriptorProto {
2244 name: Some("api/v2/foo/bar/foobar.proto".into()),
2245 package: Some("api.v2.foo.bar".into()),
2246 message_type: vec![DescriptorProto {
2247 name: Some("MyMessage".into()),
2248 ..Default::default()
2249 }],
2250 service: vec![ServiceDescriptorProto {
2251 name: Some("FooBar".into()),
2252 method: vec![
2253 MethodDescriptorProto {
2254 name: Some("Unary".into()),
2255 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2256 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2257 ..Default::default()
2258 },
2259 MethodDescriptorProto {
2260 name: Some("ServerStream".into()),
2261 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2262 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2263 server_streaming: Some(true),
2264 ..Default::default()
2265 },
2266 MethodDescriptorProto {
2267 name: Some("ClientStream".into()),
2268 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2269 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2270 client_streaming: Some(true),
2271 ..Default::default()
2272 },
2273 MethodDescriptorProto {
2274 name: Some("Bidi".into()),
2275 input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2276 output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2277 client_streaming: Some(true),
2278 server_streaming: Some(true),
2279 ..Default::default()
2280 },
2281 ],
2282 ..Default::default()
2283 }],
2284 ..Default::default()
2285 };
2286 let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2287
2288 assert!(
2290 !code.contains("OwnedMyMessageView"),
2291 "no method shape should reference the suppressed alias: {code}"
2292 );
2293
2294 assert!(
2298 code.matches("request : :: buffa :: view :: OwnedView <")
2299 .count()
2300 >= 2,
2301 "unary and server-streaming should both inline the request type: {code}"
2302 );
2303 assert!(
2304 code.matches(
2305 "requests : :: connectrpc :: ServiceStream < :: buffa :: view :: OwnedView <"
2306 )
2307 .count()
2308 >= 2,
2309 "client-streaming and bidi should both inline the streamed request type: {code}"
2310 );
2311 }
2312
2313 #[test]
2314 fn streaming_methods_use_encodable_item_type() {
2315 let file = FileDescriptorProto {
2323 name: Some("ex/v1/svc.proto".into()),
2324 package: Some("ex.v1".into()),
2325 message_type: vec![
2326 DescriptorProto {
2327 name: Some("Req".into()),
2328 ..Default::default()
2329 },
2330 DescriptorProto {
2331 name: Some("Resp".into()),
2332 ..Default::default()
2333 },
2334 ],
2335 service: vec![ServiceDescriptorProto {
2336 name: Some("Svc".into()),
2337 method: vec![
2338 MethodDescriptorProto {
2339 name: Some("ServerStream".into()),
2340 input_type: Some(".ex.v1.Req".into()),
2341 output_type: Some(".ex.v1.Resp".into()),
2342 server_streaming: Some(true),
2343 ..Default::default()
2344 },
2345 MethodDescriptorProto {
2346 name: Some("Bidi".into()),
2347 input_type: Some(".ex.v1.Req".into()),
2348 output_type: Some(".ex.v1.Resp".into()),
2349 client_streaming: Some(true),
2350 server_streaming: Some(true),
2351 ..Default::default()
2352 },
2353 ],
2354 ..Default::default()
2355 }],
2356 ..Default::default()
2357 };
2358 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2359
2360 assert_eq!(
2362 code.matches(":: connectrpc :: ServiceStream < impl :: connectrpc :: Encodable < Resp > + Send + use < Self >>")
2363 .count(),
2364 2,
2365 "server-streaming and bidi should both use the Encodable item type: {code}"
2366 );
2367
2368 assert_eq!(
2370 code.matches("encode_response_stream :: < Resp , _ , _ >")
2371 .count(),
2372 2,
2373 "dispatcher arms must turbofish Res to encode_response_stream: {code}"
2374 );
2375
2376 assert!(
2378 code.contains("route_view_server_stream :: < _ , _ , Resp >"),
2379 "route_view_server_stream must turbofish Res: {code}"
2380 );
2381 assert!(
2382 code.contains("route_view_bidi_stream :: < _ , _ , Resp >"),
2383 "route_view_bidi_stream must turbofish Res: {code}"
2384 );
2385 }
2386
2387 #[test]
2388 fn encodable_view_impls_emitted_per_output_type() {
2389 let file = minimal_file(
2390 Some("example.v1"),
2391 ".example.v1.PingReq",
2392 ".example.v1.PingResp",
2393 &["PingReq", "PingResp"],
2394 );
2395 let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2396 assert!(
2397 code.contains(
2398 ":: connectrpc :: Encodable < PingResp > for __buffa :: view :: PingRespView"
2399 ),
2400 "missing Encodable<PingResp> for PingRespView: {code}"
2401 );
2402 assert!(
2403 code.contains(
2404 ":: connectrpc :: Encodable < PingResp > for :: buffa :: view :: OwnedView"
2405 ),
2406 "missing Encodable<PingResp> for OwnedView<PingRespView>: {code}"
2407 );
2408 assert!(!code.contains("Encodable < PingReq >"), "got: {code}");
2410 }
2411
2412 #[test]
2413 fn encodable_view_impls_skipped_for_extern_output() {
2414 let wkt = FileDescriptorProto {
2417 name: Some("google/protobuf/empty.proto".into()),
2418 package: Some("google.protobuf".into()),
2419 message_type: vec![DescriptorProto {
2420 name: Some("Empty".into()),
2421 ..Default::default()
2422 }],
2423 ..Default::default()
2424 };
2425 let file = minimal_file(
2426 Some("example.v1"),
2427 ".example.v1.PingReq",
2428 ".google.protobuf.Empty",
2429 &["PingReq"],
2430 );
2431 let code = gen_file(&[wkt, file], 1, &[], false).unwrap();
2432 assert!(
2435 !code.contains("encode_view_body"),
2436 "extern output type must not get Encodable impl: {code}"
2437 );
2438 }
2439
2440 #[test]
2441 fn encodable_view_impls_deduped_across_files() {
2442 let common = FileDescriptorProto {
2447 name: Some("common.proto".into()),
2448 package: Some("common.v1".into()),
2449 message_type: vec![DescriptorProto {
2450 name: Some("Reply".into()),
2451 ..Default::default()
2452 }],
2453 ..Default::default()
2454 };
2455 let svc = |name: &str, pkg: &str| FileDescriptorProto {
2456 name: Some(name.into()),
2457 package: Some(pkg.into()),
2458 message_type: vec![DescriptorProto {
2459 name: Some("Req".into()),
2460 ..Default::default()
2461 }],
2462 service: vec![ServiceDescriptorProto {
2463 name: Some("S".into()),
2464 method: vec![MethodDescriptorProto {
2465 name: Some("Call".into()),
2466 input_type: Some(format!(".{pkg}.Req")),
2467 output_type: Some(".common.v1.Reply".into()),
2468 ..Default::default()
2469 }],
2470 ..Default::default()
2471 }],
2472 ..Default::default()
2473 };
2474 let files = vec![common, svc("a.proto", "a.v1"), svc("b.proto", "b.v1")];
2475
2476 let generated = generate_files(
2477 &files,
2478 &["a.proto".into(), "b.proto".into()],
2479 &Options::default(),
2480 )
2481 .unwrap();
2482
2483 let companions: Vec<_> = generated
2486 .iter()
2487 .filter(|f| f.kind == GeneratedFileKind::Companion)
2488 .collect();
2489 let mut companion_names: Vec<&str> = companions.iter().map(|f| f.name.as_str()).collect();
2490 companion_names.sort_unstable();
2491 assert_eq!(companion_names, ["a.__connect.rs", "b.__connect.rs"]);
2492 for c in &companions {
2493 let stitcher = generated
2494 .iter()
2495 .find(|g| g.kind == GeneratedFileKind::PackageMod && g.package == c.package)
2496 .expect("each companion's package must have a stitcher");
2497 assert!(
2498 stitcher
2499 .content
2500 .contains(&format!("include!(\"{}\")", c.name)),
2501 "stitcher for {} must include companion {}",
2502 c.package,
2503 c.name
2504 );
2505 }
2506
2507 let combined: String = companions.iter().map(|f| f.content.as_str()).collect();
2508
2509 let view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor super::super::common::v1::__buffa::view::ReplyView<'_>";
2510 let owned_view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor ::buffa::view::OwnedView<";
2511 assert_eq!(
2512 combined.matches(view_impl).count(),
2513 1,
2514 "Encodable<Reply> for ReplyView<'_> must appear once: {combined}"
2515 );
2516 assert_eq!(
2517 combined.matches(owned_view_impl).count(),
2518 1,
2519 "Encodable<Reply> for OwnedView<ReplyView> must appear once: {combined}"
2520 );
2521 }
2522
2523 fn file_per_package_fixture() -> Vec<FileDescriptorProto> {
2528 let common = FileDescriptorProto {
2529 name: Some("common.proto".into()),
2530 package: Some("common.v1".into()),
2531 message_type: vec![DescriptorProto {
2532 name: Some("Reply".into()),
2533 ..Default::default()
2534 }],
2535 ..Default::default()
2536 };
2537 let svc = |proto_name: &str, pkg: &str, svc_name: &str, req: &str| FileDescriptorProto {
2542 name: Some(proto_name.into()),
2543 package: Some(pkg.into()),
2544 message_type: vec![DescriptorProto {
2545 name: Some(req.into()),
2546 ..Default::default()
2547 }],
2548 service: vec![ServiceDescriptorProto {
2549 name: Some(svc_name.into()),
2550 method: vec![MethodDescriptorProto {
2551 name: Some("Call".into()),
2552 input_type: Some(format!(".{pkg}.{req}")),
2553 output_type: Some(".common.v1.Reply".into()),
2554 ..Default::default()
2555 }],
2556 ..Default::default()
2557 }],
2558 ..Default::default()
2559 };
2560 vec![
2561 common,
2562 svc("a/x.proto", "a.v1", "XService", "XReq"),
2563 svc("a/y.proto", "a.v1", "YService", "YReq"),
2564 svc("b/z.proto", "b.v1", "ZService", "ZReq"),
2565 ]
2566 }
2567
2568 #[test]
2569 fn generate_files_file_per_package_inlines_companions() {
2570 let files = file_per_package_fixture();
2571 let mut options = Options::default();
2572 options.buffa.file_per_package = true;
2573
2574 let generated = generate_files(
2575 &files,
2576 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2577 &options,
2578 )
2579 .unwrap();
2580
2581 assert!(
2583 !generated
2584 .iter()
2585 .any(|f| f.kind == GeneratedFileKind::Companion),
2586 "file_per_package must not emit sibling Companion files"
2587 );
2588 assert!(
2589 !generated.iter().any(|f| f.name.ends_with(".__connect.rs")),
2590 "file_per_package must not emit `<stem>.__connect.rs` files"
2591 );
2592
2593 let a = generated
2595 .iter()
2596 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "a.v1")
2597 .expect("a.v1 PackageMod must exist");
2598 assert!(
2599 a.content.contains("pub trait XService"),
2600 "a.v1 missing XService"
2601 );
2602 assert!(
2603 a.content.contains("pub trait YService"),
2604 "a.v1 missing YService"
2605 );
2606 assert!(
2607 !a.content.contains("pub trait ZService"),
2608 "a.v1 must not inline ZService"
2609 );
2610 assert!(
2611 !a.content.contains("__connect.rs"),
2612 "a.v1 PackageMod must not include! a connect file: {}",
2613 a.content
2614 );
2615
2616 let b = generated
2617 .iter()
2618 .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "b.v1")
2619 .expect("b.v1 PackageMod must exist");
2620 assert!(
2621 b.content.contains("pub trait ZService"),
2622 "b.v1 missing ZService"
2623 );
2624 assert!(
2625 !b.content.contains("pub trait XService"),
2626 "b.v1 must not inline XService"
2627 );
2628
2629 let pkg_mods = generated
2632 .iter()
2633 .filter(|f| f.kind == GeneratedFileKind::PackageMod)
2634 .count();
2635 assert_eq!(
2636 pkg_mods, 2,
2637 "expected exactly two PackageMods: {generated:#?}"
2638 );
2639
2640 let combined: String = generated.iter().map(|f| f.content.as_str()).collect();
2645 assert_eq!(
2646 combined
2647 .matches("impl ::connectrpc::Encodable<super::super::common::v1::Reply>")
2648 .count(),
2649 2,
2650 "Encodable<Reply> impls must be deduplicated across packages \
2651 (1 for ReplyView, 1 for OwnedView<ReplyView>): {combined}"
2652 );
2653 }
2654
2655 #[test]
2656 fn generate_services_file_per_package_emits_one_file_per_package() {
2657 let files = file_per_package_fixture();
2658 let mut options = Options::default();
2659 options.buffa.file_per_package = true;
2660 options
2661 .buffa
2662 .extern_paths
2663 .push((".".into(), "crate::proto".into()));
2664
2665 let generated = generate_services(
2666 &files,
2667 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2668 &options,
2669 )
2670 .unwrap();
2671
2672 assert_eq!(
2675 generated.len(),
2676 2,
2677 "expected exactly two output files: {generated:#?}"
2678 );
2679 assert!(
2680 generated
2681 .iter()
2682 .all(|f| f.kind == GeneratedFileKind::PackageMod),
2683 "all output files must be PackageMod"
2684 );
2685 assert!(
2686 !generated.iter().any(|f| f.name.ends_with(".mod.rs")),
2687 "file_per_package must not emit a separate stitcher"
2688 );
2689 assert!(
2690 !generated.iter().any(|f| f.content.contains("include!")),
2691 "file_per_package output must not include! sibling files"
2692 );
2693
2694 let mut names: Vec<&str> = generated.iter().map(|f| f.name.as_str()).collect();
2695 names.sort_unstable();
2696 assert_eq!(
2697 names,
2698 ["a.v1.rs", "b.v1.rs"],
2699 "filenames must be `<dotted.pkg>.rs` to match buffa's file_per_package convention"
2700 );
2701
2702 let a = generated.iter().find(|f| f.package == "a.v1").unwrap();
2703 assert!(a.content.contains("pub trait XService"));
2704 assert!(a.content.contains("pub trait YService"));
2705 let b = generated.iter().find(|f| f.package == "b.v1").unwrap();
2706 assert!(b.content.contains("pub trait ZService"));
2707 assert!(!b.content.contains("pub trait XService"));
2708 }
2709
2710 #[test]
2711 fn generate_services_file_per_package_default_layout_unchanged() {
2712 let files = file_per_package_fixture();
2715 let mut options = Options::default();
2716 options
2717 .buffa
2718 .extern_paths
2719 .push((".".into(), "crate::proto".into()));
2720
2721 let generated = generate_services(
2722 &files,
2723 &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
2724 &options,
2725 )
2726 .unwrap();
2727
2728 let mut companions: Vec<&str> = generated
2729 .iter()
2730 .filter(|f| f.kind == GeneratedFileKind::Companion)
2731 .map(|f| f.name.as_str())
2732 .collect();
2733 companions.sort_unstable();
2734 assert_eq!(
2735 companions,
2736 ["a.x.__connect.rs", "a.y.__connect.rs", "b.z.__connect.rs"],
2737 "default layout emits one companion per proto"
2738 );
2739 let mut stitchers: Vec<&str> = generated
2740 .iter()
2741 .filter(|f| f.kind == GeneratedFileKind::PackageMod)
2742 .map(|f| f.name.as_str())
2743 .collect();
2744 stitchers.sort_unstable();
2745 assert_eq!(
2746 stitchers,
2747 ["a.v1.mod.rs", "b.v1.mod.rs"],
2748 "default layout emits one stitcher per package"
2749 );
2750 let a_stitcher = generated.iter().find(|f| f.name == "a.v1.mod.rs").unwrap();
2752 assert!(
2753 a_stitcher
2754 .content
2755 .contains(r#"include!("a.x.__connect.rs");"#)
2756 );
2757 assert!(
2758 a_stitcher
2759 .content
2760 .contains(r#"include!("a.y.__connect.rs");"#)
2761 );
2762 }
2763
2764 #[test]
2765 fn service_name_with_package() {
2766 let file = minimal_file(
2767 Some("example.v1"),
2768 ".example.v1.PingReq",
2769 ".example.v1.PingResp",
2770 &["PingReq", "PingResp"],
2771 );
2772 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2773 assert!(code.contains("\"example.v1.PingService\""), "got: {code}");
2774 }
2775
2776 #[test]
2777 fn service_name_without_package() {
2778 let file = minimal_file(None, ".PingReq", ".PingResp", &["PingReq", "PingResp"]);
2780 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2781 assert!(code.contains("\"PingService\""), "got: {code}");
2782 assert!(
2783 !code.contains("\".PingService\""),
2784 "must not have leading dot: {code}"
2785 );
2786 }
2787
2788 #[test]
2789 fn same_package_types_use_bare_names() {
2790 let file = minimal_file(
2791 Some("example.v1"),
2792 ".example.v1.PingReq",
2793 ".example.v1.PingResp",
2794 &["PingReq", "PingResp"],
2795 );
2796 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2797 assert!(code.contains("PingReq"), "input type missing: {code}");
2799 assert!(code.contains("PingResp"), "output type missing: {code}");
2800 assert!(
2802 !code.contains("super :: PingReq"),
2803 "unexpected super: {code}"
2804 );
2805 }
2806
2807 #[test]
2808 fn cross_package_types_use_relative_paths() {
2809 let common = FileDescriptorProto {
2813 name: Some("common.proto".into()),
2814 package: Some("common.v1".into()),
2815 message_type: vec![DescriptorProto {
2816 name: Some("Shared".into()),
2817 ..Default::default()
2818 }],
2819 ..Default::default()
2820 };
2821 let svc = minimal_file(
2822 Some("example.v1"),
2823 ".common.v1.Shared",
2824 ".example.v1.Out",
2825 &["Out"],
2826 );
2827 let code = gen_service(&[common, svc], 1, &[], false).unwrap();
2828
2829 assert!(
2832 code.contains("super :: super :: common :: v1 :: Shared"),
2833 "cross-package path not emitted: {code}"
2834 );
2835 assert!(
2836 code.contains("super :: super :: common :: v1 :: __buffa :: view :: SharedView"),
2837 "cross-package view path not emitted: {code}"
2838 );
2839 }
2840
2841 #[test]
2842 fn nested_message_view_type_mirrors_owned_module_nesting() {
2843 let file = FileDescriptorProto {
2848 name: Some("nested.proto".into()),
2849 package: Some("example.v1".into()),
2850 message_type: vec![
2851 DescriptorProto {
2852 name: Some("Outer".into()),
2853 nested_type: vec![DescriptorProto {
2854 name: Some("Inner".into()),
2855 ..Default::default()
2856 }],
2857 ..Default::default()
2858 },
2859 DescriptorProto {
2860 name: Some("Out".into()),
2861 ..Default::default()
2862 },
2863 ],
2864 service: vec![ServiceDescriptorProto {
2865 name: Some("NestedService".into()),
2866 method: vec![MethodDescriptorProto {
2867 name: Some("Ping".into()),
2868 input_type: Some(".example.v1.Outer.Inner".into()),
2869 output_type: Some(".example.v1.Out".into()),
2870 ..Default::default()
2871 }],
2872 ..Default::default()
2873 }],
2874 ..Default::default()
2875 };
2876 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2877
2878 assert!(
2879 code.contains("__buffa :: view :: outer :: InnerView"),
2880 "nested view path not emitted: {code}"
2881 );
2882 assert!(
2883 code.contains("outer :: Inner"),
2884 "nested owned path not emitted: {code}"
2885 );
2886 }
2887
2888 #[test]
2889 fn wkt_types_use_buffa_types_extern_path() {
2890 let wkt = FileDescriptorProto {
2894 name: Some("google/protobuf/empty.proto".into()),
2895 package: Some("google.protobuf".into()),
2896 message_type: vec![DescriptorProto {
2897 name: Some("Empty".into()),
2898 ..Default::default()
2899 }],
2900 ..Default::default()
2901 };
2902 let svc = minimal_file(
2903 Some("example.v1"),
2904 ".google.protobuf.Empty",
2905 ".example.v1.Out",
2906 &["Out"],
2907 );
2908 let code = gen_service(&[wkt, svc], 1, &[], false).unwrap();
2909
2910 assert!(
2911 code.contains(":: buffa_types :: google :: protobuf :: Empty"),
2912 "WKT extern path not emitted: {code}"
2913 );
2914 }
2915
2916 #[test]
2917 fn extern_catchall_uses_absolute_paths() {
2918 let file = minimal_file(
2919 Some("example.v1"),
2920 ".example.v1.PingReq",
2921 ".example.v1.PingResp",
2922 &["PingReq", "PingResp"],
2923 );
2924 let extern_paths = [(".".into(), "crate::proto".into())];
2925 let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
2926 assert!(
2927 code.contains("crate :: proto :: example :: v1 :: PingReq"),
2928 "owned type path missing: {code}"
2929 );
2930 assert!(
2931 code.contains("crate :: proto :: example :: v1 :: __buffa :: view :: PingReqView"),
2932 "view type path missing: {code}"
2933 );
2934 }
2935
2936 #[test]
2937 fn extern_catchall_with_wkt_longest_wins() {
2938 let wkt = FileDescriptorProto {
2941 name: Some("google/protobuf/empty.proto".into()),
2942 package: Some("google.protobuf".into()),
2943 message_type: vec![DescriptorProto {
2944 name: Some("Empty".into()),
2945 ..Default::default()
2946 }],
2947 ..Default::default()
2948 };
2949 let svc = minimal_file(
2950 Some("example.v1"),
2951 ".google.protobuf.Empty",
2952 ".example.v1.Out",
2953 &["Out"],
2954 );
2955 let extern_paths = [(".".into(), "crate::proto".into())];
2956 let code = gen_service(&[wkt, svc], 1, &extern_paths, true).unwrap();
2957 assert!(
2958 code.contains(":: buffa_types :: google :: protobuf :: Empty"),
2959 "WKT mapping lost to catch-all: {code}"
2960 );
2961 assert!(
2962 code.contains("crate :: proto :: example :: v1 :: Out"),
2963 "local type not routed through catch-all: {code}"
2964 );
2965 }
2966
2967 #[test]
2968 fn missing_extern_path_errors() {
2969 let file = minimal_file(
2970 Some("example.v1"),
2971 ".example.v1.PingReq",
2972 ".example.v1.PingResp",
2973 &["PingReq", "PingResp"],
2974 );
2975 let err = gen_service(std::slice::from_ref(&file), 0, &[], true).unwrap_err();
2976 let msg = err.to_string();
2977 assert!(
2978 msg.contains("extern_path"),
2979 "error message lacks hint: {msg}"
2980 );
2981 }
2982
2983 #[test]
2984 fn keyword_package_escaped() {
2985 let file = minimal_file(
2987 Some("google.type"),
2988 ".google.type.LatLng",
2989 ".google.type.LatLng",
2990 &["LatLng"],
2991 );
2992 let extern_paths = [(".".into(), "crate::proto".into())];
2993 let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
2994 assert!(
2995 code.contains("crate :: proto :: google :: r#type :: LatLng"),
2996 "keyword segment not escaped: {code}"
2997 );
2998 }
2999
3000 #[test]
3001 fn keyword_method_escaped() {
3002 let file = minimal_file_with_method(
3005 Some("example.v1"),
3006 "Move",
3007 ".example.v1.Empty",
3008 ".example.v1.Empty",
3009 &["Empty"],
3010 );
3011 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3012 assert!(
3013 code.contains("fn r#move"),
3014 "keyword method not escaped: {code}"
3015 );
3016 assert!(
3017 code.contains("move_with_options"),
3018 "suffixed variant should not need escaping: {code}"
3019 );
3020 assert!(code.contains("client.r#move(request)"));
3022 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3023 }
3024
3025 #[test]
3026 fn path_keyword_method_suffixed() {
3027 let file = minimal_file_with_method(
3030 Some("example.v1"),
3031 "Self",
3032 ".example.v1.Empty",
3033 ".example.v1.Empty",
3034 &["Empty"],
3035 );
3036 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3037 assert!(
3038 code.contains("fn self_"),
3039 "path-keyword method not suffixed: {code}"
3040 );
3041 assert!(code.contains("self_with_options"));
3045 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3046 }
3047
3048 #[test]
3049 fn service_name_keyword_suffixed() {
3050 let mut file = minimal_file(
3054 Some("example.v1"),
3055 ".example.v1.Empty",
3056 ".example.v1.Empty",
3057 &["Empty"],
3058 );
3059 file.service[0].name = Some("Self".into());
3060 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3061 assert!(code.contains("trait Self_ "), "trait not suffixed: {code}");
3062 assert!(code.contains("trait SelfExt"));
3063 assert!(code.contains("struct SelfClient"));
3064 assert!(code.contains("struct SelfServer"));
3065 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3066 }
3067
3068 #[test]
3069 fn method_snake_collision_errors() {
3070 let file = minimal_file_with_methods("example.v1", &["GetFoo", "get_foo"]);
3073 let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
3074 let msg = err.to_string();
3075 assert!(msg.contains("PingService"), "missing service name: {msg}");
3076 assert!(msg.contains("\"GetFoo\""), "missing first method: {msg}");
3077 assert!(msg.contains("\"get_foo\""), "missing second method: {msg}");
3078 assert!(msg.contains("`get_foo`"), "missing rust ident: {msg}");
3079 }
3080
3081 #[test]
3082 fn method_with_options_collision_errors() {
3083 let file = minimal_file_with_methods("example.v1", &["Ping", "PingWithOptions"]);
3086 let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
3087 let msg = err.to_string();
3088 assert!(msg.contains("\"Ping\""), "missing first method: {msg}");
3089 assert!(
3090 msg.contains("\"PingWithOptions\""),
3091 "missing second method: {msg}"
3092 );
3093 assert!(
3094 msg.contains("`ping_with_options`"),
3095 "missing rust ident: {msg}"
3096 );
3097 }
3098
3099 #[test]
3100 fn distinct_methods_do_not_collide() {
3101 let file = minimal_file_with_methods("example.v1", &["GetFoo", "GetBar"]);
3102 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3103 syn::parse_str::<syn::File>(&code).expect("generated code parses");
3104 }
3105
3106 #[test]
3107 fn options_default_buffa_config() {
3108 let cfg = Options::default().to_buffa_config();
3109 assert!(cfg.generate_json, "connectrpc enables JSON by default");
3110 assert!(cfg.generate_views);
3111 assert!(cfg.emit_register_fn);
3112 assert!(!cfg.strict_utf8_mapping);
3113 }
3114
3115 #[test]
3116 fn options_buffa_passthrough_forces_views() {
3117 let mut opts = Options::default();
3118 opts.buffa.emit_register_fn = false;
3119 opts.buffa.generate_views = false;
3120 let cfg = opts.to_buffa_config();
3121 assert!(!cfg.emit_register_fn);
3122 assert!(cfg.generate_views, "generate_views must be forced on");
3123 }
3124
3125 #[test]
3126 fn generate_files_emit_register_fn_false_suppresses_register_types() {
3127 let file = FileDescriptorProto {
3130 name: Some("ping.proto".into()),
3131 package: Some("example.v1".into()),
3132 message_type: vec![DescriptorProto {
3133 name: Some("PingReq".into()),
3134 ..Default::default()
3135 }],
3136 ..Default::default()
3137 };
3138
3139 let stitcher = |files: &[GeneratedFile]| {
3142 files
3143 .iter()
3144 .find(|f| f.kind == GeneratedFileKind::PackageMod)
3145 .expect("PackageMod file emitted")
3146 .content
3147 .clone()
3148 };
3149
3150 let with_fn = generate_files(
3151 std::slice::from_ref(&file),
3152 &["ping.proto".into()],
3153 &Options::default(),
3154 )
3155 .unwrap();
3156 let mod_rs = stitcher(&with_fn);
3157 assert!(
3158 mod_rs.contains("fn register_types"),
3159 "expected register_types in default output: {mod_rs}"
3160 );
3161
3162 let mut opts = Options::default();
3163 opts.buffa.emit_register_fn = false;
3164 let without_fn =
3165 generate_files(std::slice::from_ref(&file), &["ping.proto".into()], &opts).unwrap();
3166 let mod_rs = stitcher(&without_fn);
3167 assert!(
3168 !mod_rs.contains("fn register_types"),
3169 "register_types should be suppressed: {mod_rs}"
3170 );
3171 }
3172
3173 #[test]
3174 fn plugin_no_register_fn_parses() {
3175 let request = CodeGeneratorRequest {
3176 parameter: Some("buffa_module=crate::proto,no_register_fn".into()),
3177 file_to_generate: vec![],
3178 proto_file: vec![],
3179 ..Default::default()
3180 };
3181 generate(&request).expect("no_register_fn should be a recognized plugin option");
3184 }
3185
3186 #[test]
3187 fn plugin_file_per_package_collapses_output() {
3188 let request = CodeGeneratorRequest {
3191 parameter: Some("buffa_module=crate::proto,file_per_package".into()),
3192 file_to_generate: vec!["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3193 proto_file: file_per_package_fixture(),
3194 ..Default::default()
3195 };
3196 let response = generate(&request).expect("file_per_package should parse and generate");
3197 let mut names: Vec<&str> = response
3198 .file
3199 .iter()
3200 .filter_map(|f| f.name.as_deref())
3201 .collect();
3202 names.sort_unstable();
3203 assert_eq!(
3204 names,
3205 ["a.v1.rs", "b.v1.rs"],
3206 "expected one file per package: {names:?}"
3207 );
3208 for f in &response.file {
3209 let content = f.content.as_deref().unwrap_or_default();
3210 assert!(
3211 !content.contains("include!"),
3212 "file_per_package output must be self-contained: {content}"
3213 );
3214 }
3215 }
3216
3217 #[test]
3218 fn no_top_level_use_statements_in_generated_code() {
3219 let file = minimal_file(
3223 Some("example.v1"),
3224 ".example.v1.PingReq",
3225 ".example.v1.PingResp",
3226 &["PingReq", "PingResp"],
3227 );
3228 let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3229 let formatted = format_token_stream(&code.parse::<TokenStream>().unwrap()).unwrap();
3230 assert_no_top_level_use(&formatted, "generated code");
3231 }
3232
3233 #[test]
3234 fn multi_service_include_no_e0252() {
3235 let file_a = {
3238 let method = MethodDescriptorProto {
3239 name: Some("Ping".into()),
3240 input_type: Some(".svc.v1.PingReq".into()),
3241 output_type: Some(".svc.v1.PingResp".into()),
3242 ..Default::default()
3243 };
3244 let service = ServiceDescriptorProto {
3245 name: Some("Alpha".into()),
3246 method: vec![method],
3247 ..Default::default()
3248 };
3249 FileDescriptorProto {
3250 name: Some("alpha.proto".into()),
3251 package: Some("svc.v1".into()),
3252 service: vec![service],
3253 message_type: vec![
3254 DescriptorProto {
3255 name: Some("PingReq".into()),
3256 ..Default::default()
3257 },
3258 DescriptorProto {
3259 name: Some("PingResp".into()),
3260 ..Default::default()
3261 },
3262 ],
3263 ..Default::default()
3264 }
3265 };
3266 let file_b = {
3267 let method = MethodDescriptorProto {
3268 name: Some("Pong".into()),
3269 input_type: Some(".svc.v1.PongReq".into()),
3270 output_type: Some(".svc.v1.PongResp".into()),
3271 ..Default::default()
3272 };
3273 let service = ServiceDescriptorProto {
3274 name: Some("Beta".into()),
3275 method: vec![method],
3276 ..Default::default()
3277 };
3278 FileDescriptorProto {
3279 name: Some("beta.proto".into()),
3280 package: Some("svc.v1".into()),
3281 service: vec![service],
3282 message_type: vec![
3283 DescriptorProto {
3284 name: Some("PongReq".into()),
3285 ..Default::default()
3286 },
3287 DescriptorProto {
3288 name: Some("PongResp".into()),
3289 ..Default::default()
3290 },
3291 ],
3292 ..Default::default()
3293 }
3294 };
3295
3296 let files = vec![file_a, file_b];
3297 let config = buffa_codegen::CodeGenConfig::default();
3298 let targets = vec!["alpha.proto".to_string(), "beta.proto".to_string()];
3299 let resolver = TypeResolver::new(&files, &targets, &config, false);
3300
3301 let mut batch = BatchState {
3302 colliding_aliases: collect_alias_collisions(&files, &targets),
3303 ..BatchState::default()
3304 };
3305 let code_a = generate_connect_services(&files[0], &resolver, &mut batch).unwrap();
3306 let code_b = generate_connect_services(&files[1], &resolver, &mut batch).unwrap();
3307
3308 let formatted_a = format_token_stream(&code_a).unwrap();
3309 let formatted_b = format_token_stream(&code_b).unwrap();
3310
3311 syn::parse_str::<syn::File>(&formatted_a).expect("service A should parse independently");
3313 syn::parse_str::<syn::File>(&formatted_b).expect("service B should parse independently");
3314
3315 let combined = format!("{formatted_a}\n{formatted_b}");
3317 syn::parse_str::<syn::File>(&combined)
3318 .expect("combined services should parse without E0252");
3319
3320 assert_no_top_level_use(&formatted_a, "service A");
3322 assert_no_top_level_use(&formatted_b, "service B");
3323 }
3324
3325 #[test]
3329 fn generate_spec_consts_per_method() {
3330 use buffa_codegen::generated::descriptor::MethodOptions;
3331
3332 let m = |name: &str, cs: bool, ss: bool, idem: Option<IdempotencyLevel>| {
3333 MethodDescriptorProto {
3334 name: Some(name.into()),
3335 input_type: Some(".pkg.Req".into()),
3336 output_type: Some(".pkg.Resp".into()),
3337 client_streaming: Some(cs),
3338 server_streaming: Some(ss),
3339 options: MethodOptions {
3340 idempotency_level: idem,
3341 ..Default::default()
3342 }
3343 .into(),
3344 ..Default::default()
3345 }
3346 };
3347 let service = ServiceDescriptorProto {
3348 name: Some("EchoService".into()),
3349 method: vec![
3350 m("Say", false, false, Some(IdempotencyLevel::NO_SIDE_EFFECTS)),
3351 m("Subscribe", false, true, Some(IdempotencyLevel::IDEMPOTENT)),
3352 m("Upload", true, false, None),
3353 m("Chat", true, true, None),
3354 ],
3355 ..Default::default()
3356 };
3357
3358 assert_eq!(
3360 method_spec_const_ident(&service, "Say").to_string(),
3361 "ECHO_SERVICE_SAY_SPEC"
3362 );
3363
3364 let consts = generate_spec_consts("pkg.EchoService", &service);
3365 assert_eq!(consts.len(), 4, "one const per method");
3366
3367 let render = |ts: &TokenStream| {
3368 let file = syn::parse2::<syn::File>(ts.clone()).expect("const should parse");
3369 prettyplease::unparse(&file)
3370 };
3371 let say = render(&consts[0]);
3372 assert!(say.contains("pub const ECHO_SERVICE_SAY_SPEC"), "{say}");
3373 assert!(say.contains(r#""/pkg.EchoService/Say""#), "{say}");
3374 assert!(say.contains("StreamType::Unary"), "{say}");
3375 assert!(say.contains("IdempotencyLevel::NoSideEffects"), "{say}");
3376
3377 let subscribe = render(&consts[1]);
3378 assert!(
3379 subscribe.contains("StreamType::ServerStream"),
3380 "{subscribe}"
3381 );
3382 assert!(
3383 subscribe.contains("IdempotencyLevel::Idempotent"),
3384 "{subscribe}"
3385 );
3386
3387 let upload = render(&consts[2]);
3388 assert!(upload.contains("StreamType::ClientStream"), "{upload}");
3389 assert!(upload.contains("IdempotencyLevel::Unknown"), "{upload}");
3390
3391 let chat = render(&consts[3]);
3392 assert!(chat.contains("StreamType::BidiStream"), "{chat}");
3393 }
3394}