use std::collections::HashMap;
use anyhow::Result;
use heck::ToSnakeCase;
use heck::ToUpperCamelCase;
use proc_macro2::{Ident, TokenStream};
use quote::format_ident;
use quote::quote;
use buffa_codegen::generated::descriptor::FileDescriptorProto;
use buffa_codegen::generated::descriptor::MethodDescriptorProto;
use buffa_codegen::generated::descriptor::ServiceDescriptorProto;
use buffa_codegen::generated::descriptor::SourceCodeInfo;
use buffa_codegen::generated::descriptor::method_options::IdempotencyLevel;
use buffa_codegen::idents::make_field_ident;
use buffa_codegen::idents::rust_path_to_tokens;
pub use buffa_codegen::generated::descriptor;
pub use buffa_codegen::{CodeGenConfig, GeneratedFile, GeneratedFileKind};
use crate::plugin::CodeGeneratorRequest;
use crate::plugin::CodeGeneratorResponse;
use crate::plugin::CodeGeneratorResponseFile;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Options {
pub buffa: CodeGenConfig,
}
impl Default for Options {
fn default() -> Self {
let mut buffa = CodeGenConfig::default();
buffa.generate_json = true;
Self { buffa }
}
}
impl Options {
fn to_buffa_config(&self) -> CodeGenConfig {
let mut config = self.buffa.clone();
config.generate_views = true;
config
}
}
fn emit_service_files(
proto_file: &[FileDescriptorProto],
file_to_generate: &[String],
resolver: &TypeResolver<'_>,
) -> Result<Vec<GeneratedFile>> {
let mut out = Vec::new();
let mut batch = BatchState {
colliding_aliases: collect_alias_collisions(proto_file, file_to_generate),
..BatchState::default()
};
for file_name in file_to_generate {
let file_desc = proto_file
.iter()
.find(|f| f.name.as_deref() == Some(file_name.as_str()));
if let Some(file) = file_desc
&& !file.service.is_empty()
{
let service_tokens = generate_connect_services(file, resolver, &mut batch)?;
let service_code = format_token_stream(&service_tokens)?;
out.push(GeneratedFile {
name: format!(
"{}.__connect.rs",
buffa_codegen::proto_path_to_stem(file_name)
),
package: file.package.clone().unwrap_or_default(),
kind: GeneratedFileKind::Companion,
content: service_code,
});
}
}
Ok(out)
}
pub fn generate_files(
proto_file: &[FileDescriptorProto],
file_to_generate: &[String],
options: &Options,
) -> Result<Vec<GeneratedFile>> {
let config = options.to_buffa_config();
let mut files = buffa_codegen::generate(proto_file, file_to_generate, &config)
.map_err(|e| anyhow::anyhow!("buffa-codegen failed: {e}"))?;
let resolver = TypeResolver::new(proto_file, file_to_generate, &config, false);
let service_files = emit_service_files(proto_file, file_to_generate, &resolver)?;
if config.file_per_package {
inline_companions_into_package_mods(&mut files, service_files);
} else {
buffa_codegen::apply_companions(&mut files, service_files);
debug_assert!(
files.iter().all(|f| {
f.kind != GeneratedFileKind::Companion
|| files.iter().any(|g| {
g.kind == GeneratedFileKind::PackageMod
&& g.content.contains(&format!("include!(\"{}\")", f.name))
})
}),
"a companion service file was not wired into any package stitcher"
);
}
Ok(files)
}
fn inline_companions_into_package_mods(
files: &mut [GeneratedFile],
companions: Vec<GeneratedFile>,
) {
debug_assert!(
companions.iter().all(|c| files
.iter()
.any(|f| f.kind == GeneratedFileKind::PackageMod && f.package == c.package)),
"a companion service file's package has no PackageMod to inline into"
);
for comp in companions {
if let Some(pkg_mod) = files
.iter_mut()
.find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == comp.package)
{
pkg_mod.content.push('\n');
pkg_mod.content.push_str(&comp.content);
}
}
}
pub fn generate_services(
proto_file: &[FileDescriptorProto],
file_to_generate: &[String],
options: &Options,
) -> Result<Vec<GeneratedFile>> {
use std::collections::BTreeMap;
let config = options.to_buffa_config();
let resolver = TypeResolver::new(proto_file, file_to_generate, &config, true);
let mut files = emit_service_files(proto_file, file_to_generate, &resolver)?;
if config.file_per_package {
let mut by_package: BTreeMap<String, String> = BTreeMap::new();
for f in files {
let entry = by_package.entry(f.package).or_insert_with(|| {
String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n")
});
entry.push('\n');
entry.push_str(&f.content);
}
return Ok(by_package
.into_iter()
.map(|(package, content)| GeneratedFile {
name: buffa_codegen::package_to_filename(&package),
package,
kind: GeneratedFileKind::PackageMod,
content,
})
.collect());
}
let mut by_package: BTreeMap<String, Vec<String>> = BTreeMap::new();
for f in &files {
by_package
.entry(f.package.clone())
.or_default()
.push(f.name.clone());
}
for (package, names) in by_package {
let mut content = String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n");
for n in &names {
content.push_str(&format!("include!({n:?});\n"));
}
files.push(GeneratedFile {
name: buffa_codegen::package_to_mod_filename(&package),
package,
kind: GeneratedFileKind::PackageMod,
content,
});
}
Ok(files)
}
pub fn generate(request: &CodeGeneratorRequest) -> Result<CodeGeneratorResponse> {
let mut options = Options::default();
if let Some(ref param) = request.parameter {
for opt in param.split(',').map(str::trim).filter(|s| !s.is_empty()) {
if let Some(value) = opt.strip_prefix("buffa_module=") {
let rust = value.trim();
if rust.is_empty() {
anyhow::bail!(
"buffa_module requires a non-empty path, \
e.g. buffa_module=crate::proto"
);
}
options
.buffa
.extern_paths
.push((".".into(), rust.to_string()));
} else if let Some(value) = opt.strip_prefix("extern_path=") {
let (proto, rust) = value.split_once('=').ok_or_else(|| {
anyhow::anyhow!(
"invalid extern_path format {value:?}, expected \
extern_path=.proto.pkg=::rust::path"
)
})?;
let proto = proto.trim();
let rust = rust.trim();
if proto.is_empty() || rust.is_empty() {
anyhow::bail!(
"invalid extern_path format {value:?}, expected \
extern_path=.proto.pkg=::rust::path (both sides non-empty)"
);
}
let mut proto = proto.to_string();
if !proto.starts_with('.') {
proto.insert(0, '.');
}
options.buffa.extern_paths.push((proto, rust.to_string()));
} else {
match opt {
"file_per_package" => options.buffa.file_per_package = true,
"strict_utf8_mapping" => options.buffa.strict_utf8_mapping = true,
"no_json" => options.buffa.generate_json = false,
"no_register_fn" => options.buffa.emit_register_fn = false,
_ => {
return Err(anyhow::anyhow!(
"unknown plugin option: {opt:?}. Supported: \
buffa_module=<rust_path>, extern_path=<proto>=<rust>, \
file_per_package, strict_utf8_mapping, no_json, \
no_register_fn"
));
}
}
}
}
}
let generated = generate_services(&request.proto_file, &request.file_to_generate, &options)?;
let files: Vec<CodeGeneratorResponseFile> = generated
.into_iter()
.map(|g| CodeGeneratorResponseFile {
name: Some(g.name),
content: Some(g.content),
..Default::default()
})
.collect();
Ok(CodeGeneratorResponse {
supported_features: Some(feature_flags()),
minimum_edition: Some(EDITION_2023),
maximum_edition: Some(EDITION_2023),
file: files,
..Default::default()
})
}
fn feature_flags() -> u64 {
const FEATURE_PROTO3_OPTIONAL: u64 = 1;
const FEATURE_SUPPORTS_EDITIONS: u64 = 2;
FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS
}
const EDITION_2023: i32 = 1000;
fn format_token_stream(tokens: &TokenStream) -> Result<String> {
let file = syn::parse2::<syn::File>(tokens.clone())
.map_err(|e| anyhow::anyhow!("generated code failed to parse: {e}"))?;
Ok(prettyplease::unparse(&file))
}
fn doc_attrs(text: &str) -> TokenStream {
let lines: Vec<String> = text
.lines()
.map(|l| {
if l.is_empty() {
String::new()
} else {
format!(" {l}")
}
})
.collect();
quote! { #(#[doc = #lines])* }
}
struct TypeResolver<'a> {
ctx: buffa_codegen::context::CodeGenContext<'a>,
require_extern: bool,
}
impl<'a> TypeResolver<'a> {
fn new(
proto_file: &'a [FileDescriptorProto],
file_to_generate: &[String],
config: &'a buffa_codegen::CodeGenConfig,
require_extern: bool,
) -> Self {
Self {
ctx: buffa_codegen::context::CodeGenContext::for_generate(
proto_file,
file_to_generate,
config,
),
require_extern,
}
}
fn resolve_path(&self, proto_fqn: &str, current_package: &str) -> Result<String> {
match self.ctx.rust_type_relative(proto_fqn, current_package, 0) {
Some(path) => {
self.check_extern_coverage(proto_fqn, &path)?;
Ok(path)
}
None => self.fallback_unresolved(proto_fqn).map(str::to_string),
}
}
fn check_extern_coverage(&self, proto_fqn: &str, path_prefix: &str) -> Result<()> {
if self.require_extern
&& !path_prefix.starts_with("::")
&& !path_prefix.starts_with("crate::")
{
anyhow::bail!(
"type {proto_fqn} is not covered by any extern_path mapping. \
Add extern_path=.=<your_buffa_module> (e.g. \
extern_path=.=crate::proto) to the plugin opts."
);
}
Ok(())
}
fn fallback_unresolved<'f>(&self, proto_fqn: &'f str) -> Result<&'f str> {
if self.require_extern {
anyhow::bail!("type {proto_fqn} not found in descriptor set (missing proto import?)");
}
Ok(bare_type_name(proto_fqn))
}
fn rust_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
let path = self.resolve_path(proto_fqn, current_package)?;
Ok(rust_path_to_tokens(&path))
}
fn rust_view_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
use buffa_codegen::context::SENTINEL_MOD;
let (to_package, within) =
match self
.ctx
.rust_type_relative_split(proto_fqn, current_package, 0)
{
Some(s) => {
self.check_extern_coverage(proto_fqn, &s.to_package)?;
(s.to_package, s.within_package)
}
None => (
String::new(),
self.fallback_unresolved(proto_fqn)?.to_string(),
),
};
let prefix = if to_package.is_empty() {
format!("{SENTINEL_MOD}::view")
} else {
format!("{to_package}::{SENTINEL_MOD}::view")
};
Ok(rust_path_to_tokens(&format!("{prefix}::{within}View")))
}
}
fn bare_type_name(proto_fqn: &str) -> &str {
proto_fqn
.strip_prefix('.')
.unwrap_or(proto_fqn)
.rsplit('.')
.next()
.unwrap_or(proto_fqn)
}
#[derive(Default)]
struct BatchState {
encodable_seen: std::collections::BTreeSet<String>,
alias_seen: std::collections::BTreeSet<(String, String)>,
colliding_aliases: std::collections::BTreeSet<(String, String)>,
}
fn generate_connect_services(
file: &FileDescriptorProto,
resolver: &TypeResolver<'_>,
batch: &mut BatchState,
) -> Result<TokenStream> {
let mut tokens = TokenStream::new();
tokens.extend(generate_owned_view_aliases(file, resolver, batch)?);
tokens.extend(generate_encodable_view_impls(file, resolver, batch)?);
for service in &file.service {
tokens.extend(generate_service(file, service, resolver, batch)?);
}
Ok(tokens)
}
fn owned_view_alias_ident(fqn: &str) -> Ident {
format_ident!("Owned{}View", bare_type_name(fqn).to_upper_camel_case())
}
fn alias_collides(batch: &BatchState, current_package: &str, proto_fqn: &str) -> bool {
let alias = owned_view_alias_ident(proto_fqn).to_string();
batch
.colliding_aliases
.contains(&(current_package.to_string(), alias))
}
fn owned_view_input_arg_type(
resolver: &TypeResolver<'_>,
batch: &BatchState,
proto_fqn: &str,
current_package: &str,
) -> Result<TokenStream> {
if alias_collides(batch, current_package, proto_fqn) {
let view = resolver.rust_view_type(proto_fqn, current_package)?;
Ok(quote!(::buffa::view::OwnedView<#view<'static>>))
} else {
let alias = owned_view_alias_ident(proto_fqn);
Ok(quote!(#alias))
}
}
fn collect_alias_collisions(
proto_file: &[FileDescriptorProto],
file_to_generate: &[String],
) -> std::collections::BTreeSet<(String, String)> {
use std::collections::BTreeMap;
let mut first_seen: BTreeMap<(String, String), String> = BTreeMap::new();
let mut colliding: std::collections::BTreeSet<(String, String)> =
std::collections::BTreeSet::new();
for file_name in file_to_generate {
let Some(file) = proto_file
.iter()
.find(|f| f.name.as_deref() == Some(file_name.as_str()))
else {
continue;
};
let package = file.package.clone().unwrap_or_default();
for service in &file.service {
for m in &service.method {
for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
.into_iter()
.flatten()
{
let alias = owned_view_alias_ident(fqn).to_string();
let key = (package.clone(), alias);
match first_seen.get(&key) {
Some(prev) if prev != fqn => {
colliding.insert(key);
}
Some(_) => {} None => {
first_seen.insert(key, fqn.to_string());
}
}
}
}
}
}
colliding
}
fn generate_owned_view_aliases(
file: &FileDescriptorProto,
resolver: &TypeResolver<'_>,
batch: &mut BatchState,
) -> Result<TokenStream> {
let package = file.package.as_deref().unwrap_or("");
let mut out = TokenStream::new();
for service in &file.service {
for m in &service.method {
for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
.into_iter()
.flatten()
{
if alias_collides(batch, package, fqn) {
continue;
}
if !batch
.alias_seen
.insert((package.to_string(), fqn.to_string()))
{
continue;
}
let alias = owned_view_alias_ident(fqn);
let view = resolver.rust_view_type(fqn, package)?;
let doc = format!(
"Shorthand for `OwnedView<{}View<'static>>`.",
bare_type_name(fqn).to_upper_camel_case()
);
out.extend(quote! {
#[doc = #doc]
pub type #alias = ::buffa::view::OwnedView<#view<'static>>;
});
}
}
}
Ok(out)
}
fn generate_encodable_view_impls(
file: &FileDescriptorProto,
resolver: &TypeResolver<'_>,
batch: &mut BatchState,
) -> Result<TokenStream> {
let package = file.package.as_deref().unwrap_or("");
let mut out = TokenStream::new();
for service in &file.service {
for m in &service.method {
let fqn = m.output_type.as_deref().unwrap_or("");
if !batch.encodable_seen.insert(fqn.to_string()) {
continue;
}
let path = resolver.resolve_path(fqn, package)?;
if path.starts_with("::") {
continue;
}
let owned = resolver.rust_type(fqn, package)?;
let view = resolver.rust_view_type(fqn, package)?;
out.extend(quote! {
impl ::connectrpc::Encodable<#owned> for #view<'_> {
fn encode(&self, codec: ::connectrpc::CodecFormat)
-> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
{
::connectrpc::__codegen::encode_view_body(self, codec)
}
}
impl ::connectrpc::Encodable<#owned> for ::buffa::view::OwnedView<#view<'static>> {
fn encode(&self, codec: ::connectrpc::CodecFormat)
-> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
{
::connectrpc::__codegen::encode_view_body(&**self, codec)
}
}
});
}
}
Ok(out)
}
fn check_method_collisions(service_name: &str, service: &ServiceDescriptorProto) -> Result<()> {
let mut seen: HashMap<String, String> = HashMap::new();
for m in &service.method {
let proto_name = m.name.as_deref().unwrap_or("");
let snake = proto_name.to_snake_case();
let with_opts = format!("{snake}_with_options");
for ident in [snake.as_str(), with_opts.as_str()] {
if let Some(prev) = seen.get(ident) {
anyhow::bail!(
"service {service_name}: RPC methods {prev:?} and {proto_name:?} \
both generate Rust identifier `{ident}`; rename one in the proto"
);
}
}
seen.insert(snake, proto_name.to_string());
seen.insert(with_opts, proto_name.to_string());
}
Ok(())
}
fn generate_service(
file: &FileDescriptorProto,
service: &ServiceDescriptorProto,
resolver: &TypeResolver<'_>,
batch: &BatchState,
) -> Result<TokenStream> {
let package = file.package.as_deref().unwrap_or("");
let service_name = service.name.as_deref().unwrap_or("");
check_method_collisions(service_name, service)?;
let full_service_name = if package.is_empty() {
service_name.to_string()
} else {
format!("{package}.{service_name}")
};
let service_upper = service_name.to_upper_camel_case();
let trait_name = if service_upper == "Self" {
format_ident!("Self_")
} else {
format_ident!("{}", service_upper)
};
let ext_trait_name = format_ident!("{}Ext", service_upper);
let client_name = format_ident!("{}Client", service_upper);
let server_name = format_ident!("{}Server", service_upper);
let service_name_const = format_ident!(
"{}_SERVICE_NAME",
service_name.to_snake_case().to_uppercase()
);
let service_doc = get_service_comment(file, service).unwrap_or_default();
let base_doc = if service_doc.is_empty() {
format!("Server trait for {service_name}.")
} else {
service_doc
};
let full_doc = format!(
"{base_doc}\n\n\
# Implementing handlers\n\n\
Handlers receive requests as `OwnedFooView` (an alias for\n\
`OwnedView<FooView<'static>>`), which gives zero-copy borrowed access\n\
to fields (e.g. `request.name` is a `&str` into the decoded buffer).\n\
The view can be held across `.await` points. When two RPC types in\n\
the same package would alias to the same `Owned<…>View` name (e.g.\n\
a local message plus an imported one with the same short name), the\n\
alias is suppressed for both and the request type is spelled as\n\
`OwnedView<…View<'static>>` directly in the trait signature.\n\n\
Implement methods with plain `async fn`; the returned future satisfies\n\
the `Send` bound automatically. See the\n\
[buffa user guide](https://github.com/anthropics/buffa/blob/main/docs/guide.md#ownedview-in-async-trait-implementations)\n\
for zero-copy access patterns and when `to_owned_message()` is needed.\n\n\
The `impl Encodable<Out>` return bound accepts the owned `Out`, the\n\
generated `OutView<'_>` / `OwnedOutView`,\n\
[`MaybeBorrowed`](::connectrpc::MaybeBorrowed), or\n\
[`PreEncoded`](::connectrpc::PreEncoded) for handlers that encode a\n\
non-`'static` view internally and pass the bytes across the handler\n\
boundary. View bodies are not emitted for output types mapped via\n\
`extern_path` (the impl would be an orphan); return owned for\n\
WKT/extern outputs.\n\n\
Server-streaming and bidi-streaming methods return\n\
`ServiceStream<impl Encodable<Out> + Send + use<Self>>`. The\n\
`use<Self>` precise-capturing clause excludes `&self`'s lifetime\n\
(unary methods use `use<'a, Self>` and may borrow), so stream items\n\
must be `'static`. To stream view-encoded data, encode each item\n\
inside the stream body and yield\n\
[`PreEncoded`](::connectrpc::PreEncoded) — see its `# Streaming\n\
example` doc."
);
let service_doc_tokens = doc_attrs(&full_doc);
let trait_methods: Vec<TokenStream> = service
.method
.iter()
.map(|m| generate_trait_method(file, service, m, resolver, batch, package))
.collect::<Result<Vec<_>>>()?;
let route_registrations: Vec<TokenStream> = service
.method
.iter()
.map(|m| {
let method_name = m.name.as_deref().unwrap_or("");
let method_snake = make_field_ident(&method_name.to_snake_case());
let spec_const = method_spec_const_ident(service, method_name);
let client_streaming = m.client_streaming.unwrap_or(false);
let server_streaming = m.server_streaming.unwrap_or(false);
let route_call = if server_streaming && !client_streaming {
let output_type = resolver
.rust_type(m.output_type.as_deref().unwrap_or(""), package)
.unwrap();
quote! {
.route_view_server_stream::<_, _, #output_type>(
#service_name_const,
#method_name,
::connectrpc::view_streaming_handler_fn({
let svc = ::std::sync::Arc::clone(&self);
move |ctx, req| {
let svc = ::std::sync::Arc::clone(&svc);
async move { svc.#method_snake(ctx, req).await }
}
}),
)
}
} else if client_streaming && !server_streaming {
let output_type = resolver
.rust_type(m.output_type.as_deref().unwrap_or(""), package)
.unwrap();
quote! {
.route_view_client_stream(
#service_name_const,
#method_name,
::connectrpc::view_client_streaming_handler_fn({
let svc = ::std::sync::Arc::clone(&self);
move |ctx, req, format| {
let svc = ::std::sync::Arc::clone(&svc);
async move {
svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
}
}
}),
)
}
} else if client_streaming && server_streaming {
let output_type = resolver
.rust_type(m.output_type.as_deref().unwrap_or(""), package)
.unwrap();
quote! {
.route_view_bidi_stream::<_, _, #output_type>(
#service_name_const,
#method_name,
::connectrpc::view_bidi_streaming_handler_fn({
let svc = ::std::sync::Arc::clone(&self);
move |ctx, req| {
let svc = ::std::sync::Arc::clone(&svc);
async move { svc.#method_snake(ctx, req).await }
}
}),
)
}
} else {
let is_idempotent = m
.options
.idempotency_level
.map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
.unwrap_or(false);
let route_method = if is_idempotent {
quote! { route_view_idempotent }
} else {
quote! { route_view }
};
let output_type = resolver
.rust_type(m.output_type.as_deref().unwrap_or(""), package)
.unwrap();
quote! {
.#route_method(
#service_name_const,
#method_name,
{
let svc = ::std::sync::Arc::clone(&self);
::connectrpc::view_handler_fn(move |ctx, req, format| {
let svc = ::std::sync::Arc::clone(&svc);
async move {
svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
}
})
},
)
}
};
quote! {
#route_call
.with_spec(#spec_const)
}
})
.collect();
let client_methods: Vec<TokenStream> = service
.method
.iter()
.map(|m| {
generate_client_method(
&service_name_const,
&full_service_name,
m,
resolver,
package,
)
})
.collect::<Result<Vec<_>>>()?;
let service_server = generate_service_server(
&full_service_name,
&trait_name,
&server_name,
service,
resolver,
package,
)?;
let example_method = service
.method
.first()
.and_then(|m| m.name.as_deref())
.map(|n| make_field_ident(&n.to_snake_case()).to_string())
.unwrap_or_else(|| "method".to_string());
let client_name_str = client_name.to_string();
let client_doc = format!(
r#"Client for this service.
Generic over `T: ClientTransport`. For **gRPC** (HTTP/2), use
`Http2Connection` — it has honest `poll_ready` and composes with
`tower::balance` for multi-connection load balancing. For **Connect
over HTTP/1.1** (or unknown protocol), use `HttpClient`.
# Example (gRPC / HTTP/2)
```rust,ignore
use connectrpc::client::{{Http2Connection, ClientConfig}};
use connectrpc::Protocol;
let uri: http::Uri = "http://localhost:8080".parse()?;
let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
let config = ClientConfig::new(uri).with_protocol(Protocol::Grpc);
let client = {client_name_str}::new(conn, config);
let response = client.{example_method}(request).await?;
```
# Example (Connect / HTTP/1.1 or ALPN)
```rust,ignore
use connectrpc::client::{{HttpClient, ClientConfig}};
let http = HttpClient::plaintext(); // cleartext http:// only
let config = ClientConfig::new("http://localhost:8080".parse()?);
let client = {client_name_str}::new(http, config);
let response = client.{example_method}(request).await?;
```
# Working with the response
Unary calls return [`UnaryResponse<OwnedView<FooView>>`](::connectrpc::client::UnaryResponse).
The `OwnedView` derefs to the view, so field access is zero-copy:
```rust,ignore
let resp = client.{example_method}(request).await?.into_view();
let name: &str = resp.name; // borrow into the response buffer
```
If you need the owned struct (e.g. to store or pass by value), use
[`into_owned()`](::connectrpc::client::UnaryResponse::into_owned):
```rust,ignore
let owned = client.{example_method}(request).await?.into_owned();
```"#
);
let client_doc_tokens = doc_attrs(&client_doc);
let spec_consts = generate_spec_consts(&full_service_name, service);
Ok(quote! {
pub const #service_name_const: &str = #full_service_name;
#(#spec_consts)*
#service_doc_tokens
#[allow(clippy::type_complexity)]
pub trait #trait_name: Send + Sync + 'static {
#(#trait_methods)*
}
pub trait #ext_trait_name: #trait_name {
fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router;
}
impl<S: #trait_name> #ext_trait_name for S {
fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router {
router
#(#route_registrations)*
}
}
#service_server
#client_doc_tokens
#[derive(Clone)]
pub struct #client_name<T> {
transport: T,
config: ::connectrpc::client::ClientConfig,
}
impl<T> #client_name<T>
where
T: ::connectrpc::client::ClientTransport,
<T::ResponseBody as ::http_body::Body>::Error: ::std::fmt::Display,
{
pub fn new(transport: T, config: ::connectrpc::client::ClientConfig) -> Self {
Self { transport, config }
}
pub fn config(&self) -> &::connectrpc::client::ClientConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut ::connectrpc::client::ClientConfig {
&mut self.config
}
#(#client_methods)*
}
})
}
fn method_spec_const_ident(service: &ServiceDescriptorProto, method_name: &str) -> Ident {
let service_name = service.name.as_deref().unwrap_or("");
format_ident!(
"{}_{}_SPEC",
service_name.to_snake_case().to_uppercase(),
method_name.to_snake_case().to_uppercase()
)
}
fn generate_spec_consts(
full_service_name: &str,
service: &ServiceDescriptorProto,
) -> Vec<TokenStream> {
service
.method
.iter()
.map(|m| {
let method_name = m.name.as_deref().unwrap_or("");
let spec_const = method_spec_const_ident(service, method_name);
let procedure = format!("/{full_service_name}/{method_name}");
let cs = m.client_streaming.unwrap_or(false);
let ss = m.server_streaming.unwrap_or(false);
let stream_type = match (cs, ss) {
(true, true) => quote! { ::connectrpc::StreamType::BidiStream },
(true, false) => quote! { ::connectrpc::StreamType::ClientStream },
(false, true) => quote! { ::connectrpc::StreamType::ServerStream },
(false, false) => quote! { ::connectrpc::StreamType::Unary },
};
let idempotency_level = match m.options.idempotency_level {
Some(IdempotencyLevel::NO_SIDE_EFFECTS) => {
quote! { ::connectrpc::IdempotencyLevel::NoSideEffects }
}
Some(IdempotencyLevel::IDEMPOTENT) => {
quote! { ::connectrpc::IdempotencyLevel::Idempotent }
}
_ => quote! { ::connectrpc::IdempotencyLevel::Unknown },
};
let doc = format!(
"Static [`Spec`](::connectrpc::Spec) for the server-side `{method_name}` RPC.\n\n\
The dispatcher surfaces this on\n\
[`RequestContext::spec`](::connectrpc::RequestContext::spec)."
);
let doc_tokens = doc_attrs(&doc);
quote! {
#doc_tokens
pub const #spec_const: ::connectrpc::Spec =
::connectrpc::Spec::server(#procedure, #stream_type)
.with_idempotency_level(#idempotency_level);
}
})
.collect()
}
fn generate_service_server(
full_service_name: &str,
trait_name: &proc_macro2::Ident,
server_name: &proc_macro2::Ident,
service: &ServiceDescriptorProto,
resolver: &TypeResolver<'_>,
package: &str,
) -> Result<TokenStream> {
let path_prefix = format!("{full_service_name}/");
let lookup_arms: Vec<TokenStream> = service
.method
.iter()
.map(|m| {
let method_name = m.name.as_deref().unwrap_or("");
let client_streaming = m.client_streaming.unwrap_or(false);
let server_streaming = m.server_streaming.unwrap_or(false);
let is_idempotent = m
.options
.idempotency_level
.map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
.unwrap_or(false);
let spec_const = method_spec_const_ident(service, method_name);
let desc = if client_streaming && server_streaming {
quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::bidi_streaming() }
} else if client_streaming {
quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::client_streaming() }
} else if server_streaming {
quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::server_streaming() }
} else {
quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::unary(#is_idempotent) }
};
quote! { #method_name => Some(#desc.with_spec(#spec_const)), }
})
.collect();
let mut call_unary_arms: Vec<TokenStream> = Vec::new();
let mut call_ss_arms: Vec<TokenStream> = Vec::new();
let mut call_cs_arms: Vec<TokenStream> = Vec::new();
let mut call_bidi_arms: Vec<TokenStream> = Vec::new();
for m in &service.method {
let method_name = m.name.as_deref().unwrap_or("");
let method_snake = make_field_ident(&method_name.to_snake_case());
let input_view = resolver.rust_view_type(m.input_type.as_deref().unwrap_or(""), package)?;
let output_type = resolver.rust_type(m.output_type.as_deref().unwrap_or(""), package)?;
let cs = m.client_streaming.unwrap_or(false);
let ss = m.server_streaming.unwrap_or(false);
if cs && ss {
call_bidi_arms.push(quote! {
#method_name => {
let svc = ::std::sync::Arc::clone(&self.inner);
Box::pin(async move {
let req_stream = ::connectrpc::dispatcher::codegen::decode_view_request_stream::<#input_view>(requests, format);
let resp = svc.#method_snake(ctx, req_stream).await?;
Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
})
}
});
} else if cs {
call_cs_arms.push(quote! {
#method_name => {
let svc = ::std::sync::Arc::clone(&self.inner);
Box::pin(async move {
let req_stream = ::connectrpc::dispatcher::codegen::decode_view_request_stream::<#input_view>(requests, format);
svc.#method_snake(ctx, req_stream).await?.encode::<#output_type>(format)
})
}
});
} else if ss {
call_ss_arms.push(quote! {
#method_name => {
let svc = ::std::sync::Arc::clone(&self.inner);
Box::pin(async move {
let req = ::connectrpc::dispatcher::codegen::decode_request_view::<#input_view>(request, format)?;
let resp = svc.#method_snake(ctx, req).await?;
Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
})
}
});
} else {
call_unary_arms.push(quote! {
#method_name => {
let svc = ::std::sync::Arc::clone(&self.inner);
Box::pin(async move {
let req = ::connectrpc::dispatcher::codegen::decode_request_view::<#input_view>(request.encoded()?, format)?;
svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
})
}
});
}
}
let server_doc = format!(
"Monomorphic dispatcher for `{trait_name}`.\n\n\
Unlike `.register(Router)` which type-erases each method into an \
`Arc<dyn ErasedHandler>` stored in a `HashMap`, this struct dispatches \
via a compile-time `match` on method name: no vtable, no hash lookup.\n\n\
# Example\n\n\
```rust,ignore\n\
use connectrpc::ConnectRpcService;\n\n\
let server = {server_name}::new(MyImpl);\n\
let service = ConnectRpcService::new(server);\n\
// hand `service` to axum/hyper as a fallback_service\n\
```"
);
let server_doc_tokens = doc_attrs(&server_doc);
Ok(quote! {
#server_doc_tokens
pub struct #server_name<T> {
inner: ::std::sync::Arc<T>,
}
impl<T: #trait_name> #server_name<T> {
pub fn new(service: T) -> Self {
Self { inner: ::std::sync::Arc::new(service) }
}
pub fn from_arc(inner: ::std::sync::Arc<T>) -> Self {
Self { inner }
}
}
impl<T> Clone for #server_name<T> {
fn clone(&self) -> Self {
Self { inner: ::std::sync::Arc::clone(&self.inner) }
}
}
impl<T: #trait_name> ::connectrpc::Dispatcher for #server_name<T> {
#[inline]
fn lookup(&self, path: &str) -> Option<::connectrpc::dispatcher::codegen::MethodDescriptor> {
let method = path.strip_prefix(#path_prefix)?;
match method {
#(#lookup_arms)*
_ => None,
}
}
fn call_unary(
&self,
path: &str,
ctx: ::connectrpc::RequestContext,
request: ::connectrpc::Payload,
format: ::connectrpc::CodecFormat,
) -> ::connectrpc::dispatcher::codegen::UnaryResult {
let Some(method) = path.strip_prefix(#path_prefix) else {
return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
};
let _ = (&ctx, &request, &format);
match method {
#(#call_unary_arms)*
_ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
}
}
fn call_server_streaming(
&self,
path: &str,
ctx: ::connectrpc::RequestContext,
request: ::buffa::bytes::Bytes,
format: ::connectrpc::CodecFormat,
) -> ::connectrpc::dispatcher::codegen::StreamingResult {
let Some(method) = path.strip_prefix(#path_prefix) else {
return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
};
let _ = (&ctx, &request, &format);
match method {
#(#call_ss_arms)*
_ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
}
}
fn call_client_streaming(
&self,
path: &str,
ctx: ::connectrpc::RequestContext,
requests: ::connectrpc::dispatcher::codegen::RequestStream,
format: ::connectrpc::CodecFormat,
) -> ::connectrpc::dispatcher::codegen::UnaryResult {
let Some(method) = path.strip_prefix(#path_prefix) else {
return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
};
let _ = (&ctx, &requests, &format);
match method {
#(#call_cs_arms)*
_ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
}
}
fn call_bidi_streaming(
&self,
path: &str,
ctx: ::connectrpc::RequestContext,
requests: ::connectrpc::dispatcher::codegen::RequestStream,
format: ::connectrpc::CodecFormat,
) -> ::connectrpc::dispatcher::codegen::StreamingResult {
let Some(method) = path.strip_prefix(#path_prefix) else {
return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
};
let _ = (&ctx, &requests, &format);
match method {
#(#call_bidi_arms)*
_ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
}
}
}
})
}
fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
let comment = if doc.is_empty() { default } else { doc };
doc_attrs(comment)
}
fn generate_trait_method(
file: &FileDescriptorProto,
service: &ServiceDescriptorProto,
method: &MethodDescriptorProto,
resolver: &TypeResolver<'_>,
batch: &BatchState,
package: &str,
) -> Result<TokenStream> {
let method_name = method.name.as_deref().unwrap_or("");
let method_snake = make_field_ident(&method_name.to_snake_case());
let input_arg = owned_view_input_arg_type(
resolver,
batch,
method.input_type.as_deref().unwrap_or(""),
package,
)?;
let output_type = resolver.rust_type(method.output_type.as_deref().unwrap_or(""), package)?;
let method_doc = get_method_comment(file, service, method).unwrap_or_default();
let method_doc_tokens =
generate_doc_comment(&method_doc, &format!("Handle the {method_name} RPC."));
let client_streaming = method.client_streaming.unwrap_or(false);
let server_streaming = method.server_streaming.unwrap_or(false);
let borrow_doc = quote! {
#[doc = ""]
#[doc = " `'a` lets the response body borrow from `&self` (e.g. server-resident state)."]
};
if server_streaming && !client_streaming {
Ok(quote! {
#method_doc_tokens
fn #method_snake(
&self,
ctx: ::connectrpc::RequestContext,
request: #input_arg,
) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
})
} else if client_streaming && !server_streaming {
Ok(quote! {
#method_doc_tokens
#borrow_doc
fn #method_snake<'a>(
&'a self,
ctx: ::connectrpc::RequestContext,
requests: ::connectrpc::ServiceStream<#input_arg>,
) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
})
} else if client_streaming && server_streaming {
Ok(quote! {
#method_doc_tokens
fn #method_snake(
&self,
ctx: ::connectrpc::RequestContext,
requests: ::connectrpc::ServiceStream<#input_arg>,
) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
})
} else {
Ok(quote! {
#method_doc_tokens
#borrow_doc
fn #method_snake<'a>(
&'a self,
ctx: ::connectrpc::RequestContext,
request: #input_arg,
) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
})
}
}
fn generate_client_method(
service_name_const: &Ident,
full_service_name: &str,
method: &MethodDescriptorProto,
resolver: &TypeResolver<'_>,
package: &str,
) -> Result<TokenStream> {
let method_name = method.name.as_deref().unwrap_or("");
let method_snake = make_field_ident(&method_name.to_snake_case());
let method_with_opts = format_ident!("{}_with_options", method_name.to_snake_case());
let input_type = resolver.rust_type(method.input_type.as_deref().unwrap_or(""), package)?;
let output_view_type =
resolver.rust_view_type(method.output_type.as_deref().unwrap_or(""), package)?;
let client_streaming = method.client_streaming.unwrap_or(false);
let server_streaming = method.server_streaming.unwrap_or(false);
let doc = format!(
" Call the {method_name} RPC. Sends a request to /{full_service_name}/{method_name}."
);
let doc_opts = format!(
" Call the {method_name} RPC with explicit per-call options. \
Options override [`ClientConfig`](::connectrpc::client::ClientConfig) defaults."
);
let ret_ty: TokenStream;
let call_body: TokenStream;
let short_args: TokenStream; let opts_args: TokenStream; let short_delegate_args: TokenStream;
if client_streaming && !server_streaming {
ret_ty = quote! {
Result<
::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
::connectrpc::ConnectError,
>
};
call_body = quote! {
::connectrpc::client::call_client_stream(
&self.transport, &self.config,
#service_name_const, #method_name,
requests, options,
).await
};
short_args = quote! { requests: impl IntoIterator<Item = #input_type> };
opts_args = quote! { requests: impl IntoIterator<Item = #input_type>, options: ::connectrpc::client::CallOptions };
short_delegate_args = quote! { requests, ::connectrpc::client::CallOptions::default() };
} else if client_streaming && server_streaming {
ret_ty = quote! {
Result<
::connectrpc::client::BidiStream<
T::ResponseBody, #input_type, #output_view_type<'static>
>,
::connectrpc::ConnectError,
>
};
call_body = quote! {
::connectrpc::client::call_bidi_stream(
&self.transport, &self.config,
#service_name_const, #method_name, options,
).await
};
short_args = quote! {};
opts_args = quote! { options: ::connectrpc::client::CallOptions };
short_delegate_args = quote! { ::connectrpc::client::CallOptions::default() };
} else if server_streaming {
ret_ty = quote! {
Result<
::connectrpc::client::ServerStream<T::ResponseBody, #output_view_type<'static>>,
::connectrpc::ConnectError,
>
};
call_body = quote! {
::connectrpc::client::call_server_stream(
&self.transport, &self.config,
#service_name_const, #method_name,
request, options,
).await
};
short_args = quote! { request: #input_type };
opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
} else {
ret_ty = quote! {
Result<
::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
::connectrpc::ConnectError,
>
};
call_body = quote! {
::connectrpc::client::call_unary(
&self.transport, &self.config,
#service_name_const, #method_name,
request, options,
).await
};
short_args = quote! { request: #input_type };
opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
}
Ok(quote! {
#[doc = #doc]
pub async fn #method_snake(&self, #short_args) -> #ret_ty {
self.#method_with_opts(#short_delegate_args).await
}
#[doc = #doc_opts]
pub async fn #method_with_opts(&self, #opts_args) -> #ret_ty {
#call_body
}
})
}
fn get_service_comment(
file: &FileDescriptorProto,
service: &ServiceDescriptorProto,
) -> Option<String> {
let source_info: &SourceCodeInfo = &file.source_code_info;
let service_index = file.service.iter().position(|s| s.name == service.name)?;
let target_path = vec![6, service_index as i32];
find_comment(source_info, &target_path)
}
fn get_method_comment(
file: &FileDescriptorProto,
service: &ServiceDescriptorProto,
method: &MethodDescriptorProto,
) -> Option<String> {
let source_info: &SourceCodeInfo = &file.source_code_info;
let (service_index, method_index) = file.service.iter().enumerate().find_map(|(si, s)| {
if s.name != service.name {
return None;
}
s.method
.iter()
.position(|m| m.name == method.name)
.map(|mi| (si, mi))
})?;
let target_path = vec![6, service_index as i32, 2, method_index as i32];
find_comment(source_info, &target_path)
}
fn find_comment(source_info: &SourceCodeInfo, target_path: &[i32]) -> Option<String> {
for location in &source_info.location {
if location.path == target_path {
let comment = location
.leading_comments
.as_ref()
.or(location.trailing_comments.as_ref())?;
let cleaned: String = comment
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n");
if !cleaned.is_empty() {
return Some(cleaned);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use buffa_codegen::generated::descriptor::DescriptorProto;
#[test]
fn doc_attrs_prefixes_space_for_prettyplease() {
let ts = quote! {
#[allow(dead_code)]
mod m {}
};
let doc = doc_attrs("Hello.\n\nSecond paragraph.");
let combined = quote! { #doc #ts };
let file = syn::parse2::<syn::File>(combined).unwrap();
let out = prettyplease::unparse(&file);
assert!(out.contains("/// Hello."), "got: {out}");
assert!(out.contains("/// Second paragraph."), "got: {out}");
assert!(out.contains("///\n"), "got: {out}");
assert!(!out.contains("///Hello"), "got: {out}");
assert!(!out.contains("/// Hello"), "got: {out}");
}
fn minimal_file(
package: Option<&str>,
input_type: &str,
output_type: &str,
local_messages: &[&str],
) -> FileDescriptorProto {
minimal_file_with_method(package, "Ping", input_type, output_type, local_messages)
}
fn minimal_file_with_method(
package: Option<&str>,
method_name: &str,
input_type: &str,
output_type: &str,
local_messages: &[&str],
) -> FileDescriptorProto {
let method = MethodDescriptorProto {
name: Some(method_name.into()),
input_type: Some(input_type.into()),
output_type: Some(output_type.into()),
..Default::default()
};
let service = ServiceDescriptorProto {
name: Some("PingService".into()),
method: vec![method],
..Default::default()
};
FileDescriptorProto {
name: Some("ping.proto".into()),
package: package.map(|p| p.into()),
service: vec![service],
message_type: local_messages
.iter()
.map(|name| DescriptorProto {
name: Some((*name).into()),
..Default::default()
})
.collect(),
..Default::default()
}
}
fn minimal_file_with_methods(package: &str, method_names: &[&str]) -> FileDescriptorProto {
let methods = method_names
.iter()
.map(|n| MethodDescriptorProto {
name: Some((*n).into()),
input_type: Some(format!(".{package}.Empty")),
output_type: Some(format!(".{package}.Empty")),
..Default::default()
})
.collect();
let service = ServiceDescriptorProto {
name: Some("PingService".into()),
method: methods,
..Default::default()
};
FileDescriptorProto {
name: Some("ping.proto".into()),
package: Some(package.into()),
service: vec![service],
message_type: vec![DescriptorProto {
name: Some("Empty".into()),
..Default::default()
}],
..Default::default()
}
}
fn gen_service(
files: &[FileDescriptorProto],
target_idx: usize,
extern_paths: &[(String, String)],
require_extern: bool,
) -> Result<String> {
let mut config = buffa_codegen::CodeGenConfig::default();
config.extern_paths = extern_paths.to_vec();
let target_name = files[target_idx]
.name
.clone()
.into_iter()
.collect::<Vec<_>>();
let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
let file = &files[target_idx];
let service = &file.service[0];
let batch = BatchState {
colliding_aliases: collect_alias_collisions(files, &target_name),
..BatchState::default()
};
Ok(generate_service(file, service, &resolver, &batch)?.to_string())
}
fn assert_no_top_level_use(formatted: &str, label: &str) {
let parsed: syn::File = syn::parse_str(formatted).expect("formatted code parses");
let offenders: Vec<String> = parsed
.items
.iter()
.filter_map(|item| match item {
syn::Item::Use(u) => Some(quote!(#u).to_string()),
_ => None,
})
.collect();
assert!(
offenders.is_empty(),
"{label} contains top-level use statement(s): {offenders:?}\nFull source:\n{formatted}"
);
}
fn gen_file(
files: &[FileDescriptorProto],
target_idx: usize,
extern_paths: &[(String, String)],
require_extern: bool,
) -> Result<String> {
let mut config = buffa_codegen::CodeGenConfig::default();
config.extern_paths = extern_paths.to_vec();
let target_name = files[target_idx]
.name
.clone()
.into_iter()
.collect::<Vec<_>>();
let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
let mut batch = BatchState {
colliding_aliases: collect_alias_collisions(files, &target_name),
..BatchState::default()
};
Ok(generate_connect_services(&files[target_idx], &resolver, &mut batch)?.to_string())
}
#[test]
fn unary_response_body_captures_self_lifetime() {
let file = minimal_file(
Some("example.v1"),
".example.v1.PingReq",
".example.v1.PingResp",
&["PingReq", "PingResp"],
);
let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert!(code.contains("< 'a >"), "trait method missing 'a: {code}");
assert!(code.contains("& 'a self"), "missing &'a self: {code}");
assert!(
code.contains("use < 'a , Self >"),
"missing use<'a, Self> capture: {code}"
);
assert!(
!code.contains("'static + use"),
"'static bound on body should be dropped: {code}"
);
}
#[test]
fn owned_view_aliases_emitted_for_input_and_output() {
let file = minimal_file(
Some("example.v1"),
".example.v1.PingReq",
".example.v1.PingResp",
&["PingReq", "PingResp"],
);
let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert!(
code.contains("pub type OwnedPingReqView = :: buffa :: view :: OwnedView"),
"missing OwnedPingReqView alias: {code}"
);
assert!(
code.contains("pub type OwnedPingRespView = :: buffa :: view :: OwnedView"),
"missing OwnedPingRespView alias: {code}"
);
assert!(
code.contains("request : OwnedPingReqView ,"),
"trait method should take request: OwnedPingReqView: {code}"
);
}
#[test]
fn cross_package_input_collision_suppresses_alias_for_both_sides() {
let v1 = FileDescriptorProto {
name: Some("api/v1/foo/bar/foobar.proto".into()),
package: Some("api.v1.foo.bar".into()),
message_type: vec![DescriptorProto {
name: Some("MyMessage".into()),
..Default::default()
}],
..Default::default()
};
let v2 = minimal_file(
Some("api.v2.foo.bar"),
".api.v1.foo.bar.MyMessage",
".api.v2.foo.bar.MyMessage",
&["MyMessage"],
);
let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
let alias_count = code.matches("pub type OwnedMyMessageView").count();
assert_eq!(
alias_count, 0,
"expected zero OwnedMyMessageView aliases when both sides collide; got {alias_count}: {code}"
);
assert!(
!code.contains("request : OwnedMyMessageView"),
"colliding input must not reference the suppressed alias: {code}"
);
assert!(
code.contains("request : :: buffa :: view :: OwnedView <"),
"colliding input should be inlined as OwnedView<…<'static>>: {code}"
);
}
#[test]
fn cross_package_input_without_collision_keeps_alias() {
let wkt = FileDescriptorProto {
name: Some("google/protobuf/empty.proto".into()),
package: Some("google.protobuf".into()),
message_type: vec![DescriptorProto {
name: Some("Empty".into()),
..Default::default()
}],
..Default::default()
};
let svc = minimal_file(
Some("example.v1"),
".google.protobuf.Empty",
".example.v1.PingResp",
&["PingResp"],
);
let code = gen_file(&[wkt, svc], 1, &[], false).unwrap();
assert!(
code.contains("pub type OwnedEmptyView = :: buffa :: view :: OwnedView"),
"WKT cross-package input should keep its alias: {code}"
);
assert!(
code.contains("request : OwnedEmptyView ,"),
"trait method should still use OwnedEmptyView for non-colliding cross-package input: {code}"
);
}
#[test]
fn collision_inlines_in_all_streaming_method_shapes() {
let v1 = FileDescriptorProto {
name: Some("api/v1/foo/bar/foobar.proto".into()),
package: Some("api.v1.foo.bar".into()),
message_type: vec![DescriptorProto {
name: Some("MyMessage".into()),
..Default::default()
}],
..Default::default()
};
let v2 = FileDescriptorProto {
name: Some("api/v2/foo/bar/foobar.proto".into()),
package: Some("api.v2.foo.bar".into()),
message_type: vec![DescriptorProto {
name: Some("MyMessage".into()),
..Default::default()
}],
service: vec![ServiceDescriptorProto {
name: Some("FooBar".into()),
method: vec![
MethodDescriptorProto {
name: Some("Unary".into()),
input_type: Some(".api.v1.foo.bar.MyMessage".into()),
output_type: Some(".api.v2.foo.bar.MyMessage".into()),
..Default::default()
},
MethodDescriptorProto {
name: Some("ServerStream".into()),
input_type: Some(".api.v1.foo.bar.MyMessage".into()),
output_type: Some(".api.v2.foo.bar.MyMessage".into()),
server_streaming: Some(true),
..Default::default()
},
MethodDescriptorProto {
name: Some("ClientStream".into()),
input_type: Some(".api.v1.foo.bar.MyMessage".into()),
output_type: Some(".api.v2.foo.bar.MyMessage".into()),
client_streaming: Some(true),
..Default::default()
},
MethodDescriptorProto {
name: Some("Bidi".into()),
input_type: Some(".api.v1.foo.bar.MyMessage".into()),
output_type: Some(".api.v2.foo.bar.MyMessage".into()),
client_streaming: Some(true),
server_streaming: Some(true),
..Default::default()
},
],
..Default::default()
}],
..Default::default()
};
let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
assert!(
!code.contains("OwnedMyMessageView"),
"no method shape should reference the suppressed alias: {code}"
);
assert!(
code.matches("request : :: buffa :: view :: OwnedView <")
.count()
>= 2,
"unary and server-streaming should both inline the request type: {code}"
);
assert!(
code.matches(
"requests : :: connectrpc :: ServiceStream < :: buffa :: view :: OwnedView <"
)
.count()
>= 2,
"client-streaming and bidi should both inline the streamed request type: {code}"
);
}
#[test]
fn streaming_methods_use_encodable_item_type() {
let file = FileDescriptorProto {
name: Some("ex/v1/svc.proto".into()),
package: Some("ex.v1".into()),
message_type: vec![
DescriptorProto {
name: Some("Req".into()),
..Default::default()
},
DescriptorProto {
name: Some("Resp".into()),
..Default::default()
},
],
service: vec![ServiceDescriptorProto {
name: Some("Svc".into()),
method: vec![
MethodDescriptorProto {
name: Some("ServerStream".into()),
input_type: Some(".ex.v1.Req".into()),
output_type: Some(".ex.v1.Resp".into()),
server_streaming: Some(true),
..Default::default()
},
MethodDescriptorProto {
name: Some("Bidi".into()),
input_type: Some(".ex.v1.Req".into()),
output_type: Some(".ex.v1.Resp".into()),
client_streaming: Some(true),
server_streaming: Some(true),
..Default::default()
},
],
..Default::default()
}],
..Default::default()
};
let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert_eq!(
code.matches(":: connectrpc :: ServiceStream < impl :: connectrpc :: Encodable < Resp > + Send + use < Self >>")
.count(),
2,
"server-streaming and bidi should both use the Encodable item type: {code}"
);
assert_eq!(
code.matches("encode_response_stream :: < Resp , _ , _ >")
.count(),
2,
"dispatcher arms must turbofish Res to encode_response_stream: {code}"
);
assert!(
code.contains("route_view_server_stream :: < _ , _ , Resp >"),
"route_view_server_stream must turbofish Res: {code}"
);
assert!(
code.contains("route_view_bidi_stream :: < _ , _ , Resp >"),
"route_view_bidi_stream must turbofish Res: {code}"
);
}
#[test]
fn encodable_view_impls_emitted_per_output_type() {
let file = minimal_file(
Some("example.v1"),
".example.v1.PingReq",
".example.v1.PingResp",
&["PingReq", "PingResp"],
);
let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert!(
code.contains(
":: connectrpc :: Encodable < PingResp > for __buffa :: view :: PingRespView"
),
"missing Encodable<PingResp> for PingRespView: {code}"
);
assert!(
code.contains(
":: connectrpc :: Encodable < PingResp > for :: buffa :: view :: OwnedView"
),
"missing Encodable<PingResp> for OwnedView<PingRespView>: {code}"
);
assert!(!code.contains("Encodable < PingReq >"), "got: {code}");
}
#[test]
fn encodable_view_impls_skipped_for_extern_output() {
let wkt = FileDescriptorProto {
name: Some("google/protobuf/empty.proto".into()),
package: Some("google.protobuf".into()),
message_type: vec![DescriptorProto {
name: Some("Empty".into()),
..Default::default()
}],
..Default::default()
};
let file = minimal_file(
Some("example.v1"),
".example.v1.PingReq",
".google.protobuf.Empty",
&["PingReq"],
);
let code = gen_file(&[wkt, file], 1, &[], false).unwrap();
assert!(
!code.contains("encode_view_body"),
"extern output type must not get Encodable impl: {code}"
);
}
#[test]
fn encodable_view_impls_deduped_across_files() {
let common = FileDescriptorProto {
name: Some("common.proto".into()),
package: Some("common.v1".into()),
message_type: vec![DescriptorProto {
name: Some("Reply".into()),
..Default::default()
}],
..Default::default()
};
let svc = |name: &str, pkg: &str| FileDescriptorProto {
name: Some(name.into()),
package: Some(pkg.into()),
message_type: vec![DescriptorProto {
name: Some("Req".into()),
..Default::default()
}],
service: vec![ServiceDescriptorProto {
name: Some("S".into()),
method: vec![MethodDescriptorProto {
name: Some("Call".into()),
input_type: Some(format!(".{pkg}.Req")),
output_type: Some(".common.v1.Reply".into()),
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
let files = vec![common, svc("a.proto", "a.v1"), svc("b.proto", "b.v1")];
let generated = generate_files(
&files,
&["a.proto".into(), "b.proto".into()],
&Options::default(),
)
.unwrap();
let companions: Vec<_> = generated
.iter()
.filter(|f| f.kind == GeneratedFileKind::Companion)
.collect();
let mut companion_names: Vec<&str> = companions.iter().map(|f| f.name.as_str()).collect();
companion_names.sort_unstable();
assert_eq!(companion_names, ["a.__connect.rs", "b.__connect.rs"]);
for c in &companions {
let stitcher = generated
.iter()
.find(|g| g.kind == GeneratedFileKind::PackageMod && g.package == c.package)
.expect("each companion's package must have a stitcher");
assert!(
stitcher
.content
.contains(&format!("include!(\"{}\")", c.name)),
"stitcher for {} must include companion {}",
c.package,
c.name
);
}
let combined: String = companions.iter().map(|f| f.content.as_str()).collect();
let view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor super::super::common::v1::__buffa::view::ReplyView<'_>";
let owned_view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor ::buffa::view::OwnedView<";
assert_eq!(
combined.matches(view_impl).count(),
1,
"Encodable<Reply> for ReplyView<'_> must appear once: {combined}"
);
assert_eq!(
combined.matches(owned_view_impl).count(),
1,
"Encodable<Reply> for OwnedView<ReplyView> must appear once: {combined}"
);
}
fn file_per_package_fixture() -> Vec<FileDescriptorProto> {
let common = FileDescriptorProto {
name: Some("common.proto".into()),
package: Some("common.v1".into()),
message_type: vec![DescriptorProto {
name: Some("Reply".into()),
..Default::default()
}],
..Default::default()
};
let svc = |proto_name: &str, pkg: &str, svc_name: &str, req: &str| FileDescriptorProto {
name: Some(proto_name.into()),
package: Some(pkg.into()),
message_type: vec![DescriptorProto {
name: Some(req.into()),
..Default::default()
}],
service: vec![ServiceDescriptorProto {
name: Some(svc_name.into()),
method: vec![MethodDescriptorProto {
name: Some("Call".into()),
input_type: Some(format!(".{pkg}.{req}")),
output_type: Some(".common.v1.Reply".into()),
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
vec![
common,
svc("a/x.proto", "a.v1", "XService", "XReq"),
svc("a/y.proto", "a.v1", "YService", "YReq"),
svc("b/z.proto", "b.v1", "ZService", "ZReq"),
]
}
#[test]
fn generate_files_file_per_package_inlines_companions() {
let files = file_per_package_fixture();
let mut options = Options::default();
options.buffa.file_per_package = true;
let generated = generate_files(
&files,
&["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
&options,
)
.unwrap();
assert!(
!generated
.iter()
.any(|f| f.kind == GeneratedFileKind::Companion),
"file_per_package must not emit sibling Companion files"
);
assert!(
!generated.iter().any(|f| f.name.ends_with(".__connect.rs")),
"file_per_package must not emit `<stem>.__connect.rs` files"
);
let a = generated
.iter()
.find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "a.v1")
.expect("a.v1 PackageMod must exist");
assert!(
a.content.contains("pub trait XService"),
"a.v1 missing XService"
);
assert!(
a.content.contains("pub trait YService"),
"a.v1 missing YService"
);
assert!(
!a.content.contains("pub trait ZService"),
"a.v1 must not inline ZService"
);
assert!(
!a.content.contains("__connect.rs"),
"a.v1 PackageMod must not include! a connect file: {}",
a.content
);
let b = generated
.iter()
.find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "b.v1")
.expect("b.v1 PackageMod must exist");
assert!(
b.content.contains("pub trait ZService"),
"b.v1 missing ZService"
);
assert!(
!b.content.contains("pub trait XService"),
"b.v1 must not inline XService"
);
let pkg_mods = generated
.iter()
.filter(|f| f.kind == GeneratedFileKind::PackageMod)
.count();
assert_eq!(
pkg_mods, 2,
"expected exactly two PackageMods: {generated:#?}"
);
let combined: String = generated.iter().map(|f| f.content.as_str()).collect();
assert_eq!(
combined
.matches("impl ::connectrpc::Encodable<super::super::common::v1::Reply>")
.count(),
2,
"Encodable<Reply> impls must be deduplicated across packages \
(1 for ReplyView, 1 for OwnedView<ReplyView>): {combined}"
);
}
#[test]
fn generate_services_file_per_package_emits_one_file_per_package() {
let files = file_per_package_fixture();
let mut options = Options::default();
options.buffa.file_per_package = true;
options
.buffa
.extern_paths
.push((".".into(), "crate::proto".into()));
let generated = generate_services(
&files,
&["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
&options,
)
.unwrap();
assert_eq!(
generated.len(),
2,
"expected exactly two output files: {generated:#?}"
);
assert!(
generated
.iter()
.all(|f| f.kind == GeneratedFileKind::PackageMod),
"all output files must be PackageMod"
);
assert!(
!generated.iter().any(|f| f.name.ends_with(".mod.rs")),
"file_per_package must not emit a separate stitcher"
);
assert!(
!generated.iter().any(|f| f.content.contains("include!")),
"file_per_package output must not include! sibling files"
);
let mut names: Vec<&str> = generated.iter().map(|f| f.name.as_str()).collect();
names.sort_unstable();
assert_eq!(
names,
["a.v1.rs", "b.v1.rs"],
"filenames must be `<dotted.pkg>.rs` to match buffa's file_per_package convention"
);
let a = generated.iter().find(|f| f.package == "a.v1").unwrap();
assert!(a.content.contains("pub trait XService"));
assert!(a.content.contains("pub trait YService"));
let b = generated.iter().find(|f| f.package == "b.v1").unwrap();
assert!(b.content.contains("pub trait ZService"));
assert!(!b.content.contains("pub trait XService"));
}
#[test]
fn generate_services_file_per_package_default_layout_unchanged() {
let files = file_per_package_fixture();
let mut options = Options::default();
options
.buffa
.extern_paths
.push((".".into(), "crate::proto".into()));
let generated = generate_services(
&files,
&["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
&options,
)
.unwrap();
let mut companions: Vec<&str> = generated
.iter()
.filter(|f| f.kind == GeneratedFileKind::Companion)
.map(|f| f.name.as_str())
.collect();
companions.sort_unstable();
assert_eq!(
companions,
["a.x.__connect.rs", "a.y.__connect.rs", "b.z.__connect.rs"],
"default layout emits one companion per proto"
);
let mut stitchers: Vec<&str> = generated
.iter()
.filter(|f| f.kind == GeneratedFileKind::PackageMod)
.map(|f| f.name.as_str())
.collect();
stitchers.sort_unstable();
assert_eq!(
stitchers,
["a.v1.mod.rs", "b.v1.mod.rs"],
"default layout emits one stitcher per package"
);
let a_stitcher = generated.iter().find(|f| f.name == "a.v1.mod.rs").unwrap();
assert!(
a_stitcher
.content
.contains(r#"include!("a.x.__connect.rs");"#)
);
assert!(
a_stitcher
.content
.contains(r#"include!("a.y.__connect.rs");"#)
);
}
#[test]
fn service_name_with_package() {
let file = minimal_file(
Some("example.v1"),
".example.v1.PingReq",
".example.v1.PingResp",
&["PingReq", "PingResp"],
);
let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert!(code.contains("\"example.v1.PingService\""), "got: {code}");
}
#[test]
fn service_name_without_package() {
let file = minimal_file(None, ".PingReq", ".PingResp", &["PingReq", "PingResp"]);
let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert!(code.contains("\"PingService\""), "got: {code}");
assert!(
!code.contains("\".PingService\""),
"must not have leading dot: {code}"
);
}
#[test]
fn same_package_types_use_bare_names() {
let file = minimal_file(
Some("example.v1"),
".example.v1.PingReq",
".example.v1.PingResp",
&["PingReq", "PingResp"],
);
let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert!(code.contains("PingReq"), "input type missing: {code}");
assert!(code.contains("PingResp"), "output type missing: {code}");
assert!(
!code.contains("super :: PingReq"),
"unexpected super: {code}"
);
}
#[test]
fn cross_package_types_use_relative_paths() {
let common = FileDescriptorProto {
name: Some("common.proto".into()),
package: Some("common.v1".into()),
message_type: vec![DescriptorProto {
name: Some("Shared".into()),
..Default::default()
}],
..Default::default()
};
let svc = minimal_file(
Some("example.v1"),
".common.v1.Shared",
".example.v1.Out",
&["Out"],
);
let code = gen_service(&[common, svc], 1, &[], false).unwrap();
assert!(
code.contains("super :: super :: common :: v1 :: Shared"),
"cross-package path not emitted: {code}"
);
assert!(
code.contains("super :: super :: common :: v1 :: __buffa :: view :: SharedView"),
"cross-package view path not emitted: {code}"
);
}
#[test]
fn nested_message_view_type_mirrors_owned_module_nesting() {
let file = FileDescriptorProto {
name: Some("nested.proto".into()),
package: Some("example.v1".into()),
message_type: vec![
DescriptorProto {
name: Some("Outer".into()),
nested_type: vec![DescriptorProto {
name: Some("Inner".into()),
..Default::default()
}],
..Default::default()
},
DescriptorProto {
name: Some("Out".into()),
..Default::default()
},
],
service: vec![ServiceDescriptorProto {
name: Some("NestedService".into()),
method: vec![MethodDescriptorProto {
name: Some("Ping".into()),
input_type: Some(".example.v1.Outer.Inner".into()),
output_type: Some(".example.v1.Out".into()),
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert!(
code.contains("__buffa :: view :: outer :: InnerView"),
"nested view path not emitted: {code}"
);
assert!(
code.contains("outer :: Inner"),
"nested owned path not emitted: {code}"
);
}
#[test]
fn wkt_types_use_buffa_types_extern_path() {
let wkt = FileDescriptorProto {
name: Some("google/protobuf/empty.proto".into()),
package: Some("google.protobuf".into()),
message_type: vec![DescriptorProto {
name: Some("Empty".into()),
..Default::default()
}],
..Default::default()
};
let svc = minimal_file(
Some("example.v1"),
".google.protobuf.Empty",
".example.v1.Out",
&["Out"],
);
let code = gen_service(&[wkt, svc], 1, &[], false).unwrap();
assert!(
code.contains(":: buffa_types :: google :: protobuf :: Empty"),
"WKT extern path not emitted: {code}"
);
}
#[test]
fn extern_catchall_uses_absolute_paths() {
let file = minimal_file(
Some("example.v1"),
".example.v1.PingReq",
".example.v1.PingResp",
&["PingReq", "PingResp"],
);
let extern_paths = [(".".into(), "crate::proto".into())];
let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
assert!(
code.contains("crate :: proto :: example :: v1 :: PingReq"),
"owned type path missing: {code}"
);
assert!(
code.contains("crate :: proto :: example :: v1 :: __buffa :: view :: PingReqView"),
"view type path missing: {code}"
);
}
#[test]
fn extern_catchall_with_wkt_longest_wins() {
let wkt = FileDescriptorProto {
name: Some("google/protobuf/empty.proto".into()),
package: Some("google.protobuf".into()),
message_type: vec![DescriptorProto {
name: Some("Empty".into()),
..Default::default()
}],
..Default::default()
};
let svc = minimal_file(
Some("example.v1"),
".google.protobuf.Empty",
".example.v1.Out",
&["Out"],
);
let extern_paths = [(".".into(), "crate::proto".into())];
let code = gen_service(&[wkt, svc], 1, &extern_paths, true).unwrap();
assert!(
code.contains(":: buffa_types :: google :: protobuf :: Empty"),
"WKT mapping lost to catch-all: {code}"
);
assert!(
code.contains("crate :: proto :: example :: v1 :: Out"),
"local type not routed through catch-all: {code}"
);
}
#[test]
fn missing_extern_path_errors() {
let file = minimal_file(
Some("example.v1"),
".example.v1.PingReq",
".example.v1.PingResp",
&["PingReq", "PingResp"],
);
let err = gen_service(std::slice::from_ref(&file), 0, &[], true).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("extern_path"),
"error message lacks hint: {msg}"
);
}
#[test]
fn keyword_package_escaped() {
let file = minimal_file(
Some("google.type"),
".google.type.LatLng",
".google.type.LatLng",
&["LatLng"],
);
let extern_paths = [(".".into(), "crate::proto".into())];
let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
assert!(
code.contains("crate :: proto :: google :: r#type :: LatLng"),
"keyword segment not escaped: {code}"
);
}
#[test]
fn keyword_method_escaped() {
let file = minimal_file_with_method(
Some("example.v1"),
"Move",
".example.v1.Empty",
".example.v1.Empty",
&["Empty"],
);
let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert!(
code.contains("fn r#move"),
"keyword method not escaped: {code}"
);
assert!(
code.contains("move_with_options"),
"suffixed variant should not need escaping: {code}"
);
assert!(code.contains("client.r#move(request)"));
syn::parse_str::<syn::File>(&code).expect("generated code parses");
}
#[test]
fn path_keyword_method_suffixed() {
let file = minimal_file_with_method(
Some("example.v1"),
"Self",
".example.v1.Empty",
".example.v1.Empty",
&["Empty"],
);
let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert!(
code.contains("fn self_"),
"path-keyword method not suffixed: {code}"
);
assert!(code.contains("self_with_options"));
syn::parse_str::<syn::File>(&code).expect("generated code parses");
}
#[test]
fn service_name_keyword_suffixed() {
let mut file = minimal_file(
Some("example.v1"),
".example.v1.Empty",
".example.v1.Empty",
&["Empty"],
);
file.service[0].name = Some("Self".into());
let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
assert!(code.contains("trait Self_ "), "trait not suffixed: {code}");
assert!(code.contains("trait SelfExt"));
assert!(code.contains("struct SelfClient"));
assert!(code.contains("struct SelfServer"));
syn::parse_str::<syn::File>(&code).expect("generated code parses");
}
#[test]
fn method_snake_collision_errors() {
let file = minimal_file_with_methods("example.v1", &["GetFoo", "get_foo"]);
let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("PingService"), "missing service name: {msg}");
assert!(msg.contains("\"GetFoo\""), "missing first method: {msg}");
assert!(msg.contains("\"get_foo\""), "missing second method: {msg}");
assert!(msg.contains("`get_foo`"), "missing rust ident: {msg}");
}
#[test]
fn method_with_options_collision_errors() {
let file = minimal_file_with_methods("example.v1", &["Ping", "PingWithOptions"]);
let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("\"Ping\""), "missing first method: {msg}");
assert!(
msg.contains("\"PingWithOptions\""),
"missing second method: {msg}"
);
assert!(
msg.contains("`ping_with_options`"),
"missing rust ident: {msg}"
);
}
#[test]
fn distinct_methods_do_not_collide() {
let file = minimal_file_with_methods("example.v1", &["GetFoo", "GetBar"]);
let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
syn::parse_str::<syn::File>(&code).expect("generated code parses");
}
#[test]
fn options_default_buffa_config() {
let cfg = Options::default().to_buffa_config();
assert!(cfg.generate_json, "connectrpc enables JSON by default");
assert!(cfg.generate_views);
assert!(cfg.emit_register_fn);
assert!(!cfg.strict_utf8_mapping);
}
#[test]
fn options_buffa_passthrough_forces_views() {
let mut opts = Options::default();
opts.buffa.emit_register_fn = false;
opts.buffa.generate_views = false;
let cfg = opts.to_buffa_config();
assert!(!cfg.emit_register_fn);
assert!(cfg.generate_views, "generate_views must be forced on");
}
#[test]
fn generate_files_emit_register_fn_false_suppresses_register_types() {
let file = FileDescriptorProto {
name: Some("ping.proto".into()),
package: Some("example.v1".into()),
message_type: vec![DescriptorProto {
name: Some("PingReq".into()),
..Default::default()
}],
..Default::default()
};
let stitcher = |files: &[GeneratedFile]| {
files
.iter()
.find(|f| f.kind == GeneratedFileKind::PackageMod)
.expect("PackageMod file emitted")
.content
.clone()
};
let with_fn = generate_files(
std::slice::from_ref(&file),
&["ping.proto".into()],
&Options::default(),
)
.unwrap();
let mod_rs = stitcher(&with_fn);
assert!(
mod_rs.contains("fn register_types"),
"expected register_types in default output: {mod_rs}"
);
let mut opts = Options::default();
opts.buffa.emit_register_fn = false;
let without_fn =
generate_files(std::slice::from_ref(&file), &["ping.proto".into()], &opts).unwrap();
let mod_rs = stitcher(&without_fn);
assert!(
!mod_rs.contains("fn register_types"),
"register_types should be suppressed: {mod_rs}"
);
}
#[test]
fn plugin_no_register_fn_parses() {
let request = CodeGeneratorRequest {
parameter: Some("buffa_module=crate::proto,no_register_fn".into()),
file_to_generate: vec![],
proto_file: vec![],
..Default::default()
};
generate(&request).expect("no_register_fn should be a recognized plugin option");
}
#[test]
fn plugin_file_per_package_collapses_output() {
let request = CodeGeneratorRequest {
parameter: Some("buffa_module=crate::proto,file_per_package".into()),
file_to_generate: vec!["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
proto_file: file_per_package_fixture(),
..Default::default()
};
let response = generate(&request).expect("file_per_package should parse and generate");
let mut names: Vec<&str> = response
.file
.iter()
.filter_map(|f| f.name.as_deref())
.collect();
names.sort_unstable();
assert_eq!(
names,
["a.v1.rs", "b.v1.rs"],
"expected one file per package: {names:?}"
);
for f in &response.file {
let content = f.content.as_deref().unwrap_or_default();
assert!(
!content.contains("include!"),
"file_per_package output must be self-contained: {content}"
);
}
}
#[test]
fn no_top_level_use_statements_in_generated_code() {
let file = minimal_file(
Some("example.v1"),
".example.v1.PingReq",
".example.v1.PingResp",
&["PingReq", "PingResp"],
);
let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
let formatted = format_token_stream(&code.parse::<TokenStream>().unwrap()).unwrap();
assert_no_top_level_use(&formatted, "generated code");
}
#[test]
fn multi_service_include_no_e0252() {
let file_a = {
let method = MethodDescriptorProto {
name: Some("Ping".into()),
input_type: Some(".svc.v1.PingReq".into()),
output_type: Some(".svc.v1.PingResp".into()),
..Default::default()
};
let service = ServiceDescriptorProto {
name: Some("Alpha".into()),
method: vec![method],
..Default::default()
};
FileDescriptorProto {
name: Some("alpha.proto".into()),
package: Some("svc.v1".into()),
service: vec![service],
message_type: vec![
DescriptorProto {
name: Some("PingReq".into()),
..Default::default()
},
DescriptorProto {
name: Some("PingResp".into()),
..Default::default()
},
],
..Default::default()
}
};
let file_b = {
let method = MethodDescriptorProto {
name: Some("Pong".into()),
input_type: Some(".svc.v1.PongReq".into()),
output_type: Some(".svc.v1.PongResp".into()),
..Default::default()
};
let service = ServiceDescriptorProto {
name: Some("Beta".into()),
method: vec![method],
..Default::default()
};
FileDescriptorProto {
name: Some("beta.proto".into()),
package: Some("svc.v1".into()),
service: vec![service],
message_type: vec![
DescriptorProto {
name: Some("PongReq".into()),
..Default::default()
},
DescriptorProto {
name: Some("PongResp".into()),
..Default::default()
},
],
..Default::default()
}
};
let files = vec![file_a, file_b];
let config = buffa_codegen::CodeGenConfig::default();
let targets = vec!["alpha.proto".to_string(), "beta.proto".to_string()];
let resolver = TypeResolver::new(&files, &targets, &config, false);
let mut batch = BatchState {
colliding_aliases: collect_alias_collisions(&files, &targets),
..BatchState::default()
};
let code_a = generate_connect_services(&files[0], &resolver, &mut batch).unwrap();
let code_b = generate_connect_services(&files[1], &resolver, &mut batch).unwrap();
let formatted_a = format_token_stream(&code_a).unwrap();
let formatted_b = format_token_stream(&code_b).unwrap();
syn::parse_str::<syn::File>(&formatted_a).expect("service A should parse independently");
syn::parse_str::<syn::File>(&formatted_b).expect("service B should parse independently");
let combined = format!("{formatted_a}\n{formatted_b}");
syn::parse_str::<syn::File>(&combined)
.expect("combined services should parse without E0252");
assert_no_top_level_use(&formatted_a, "service A");
assert_no_top_level_use(&formatted_b, "service B");
}
#[test]
fn generate_spec_consts_per_method() {
use buffa_codegen::generated::descriptor::MethodOptions;
let m = |name: &str, cs: bool, ss: bool, idem: Option<IdempotencyLevel>| {
MethodDescriptorProto {
name: Some(name.into()),
input_type: Some(".pkg.Req".into()),
output_type: Some(".pkg.Resp".into()),
client_streaming: Some(cs),
server_streaming: Some(ss),
options: MethodOptions {
idempotency_level: idem,
..Default::default()
}
.into(),
..Default::default()
}
};
let service = ServiceDescriptorProto {
name: Some("EchoService".into()),
method: vec![
m("Say", false, false, Some(IdempotencyLevel::NO_SIDE_EFFECTS)),
m("Subscribe", false, true, Some(IdempotencyLevel::IDEMPOTENT)),
m("Upload", true, false, None),
m("Chat", true, true, None),
],
..Default::default()
};
assert_eq!(
method_spec_const_ident(&service, "Say").to_string(),
"ECHO_SERVICE_SAY_SPEC"
);
let consts = generate_spec_consts("pkg.EchoService", &service);
assert_eq!(consts.len(), 4, "one const per method");
let render = |ts: &TokenStream| {
let file = syn::parse2::<syn::File>(ts.clone()).expect("const should parse");
prettyplease::unparse(&file)
};
let say = render(&consts[0]);
assert!(say.contains("pub const ECHO_SERVICE_SAY_SPEC"), "{say}");
assert!(say.contains(r#""/pkg.EchoService/Say""#), "{say}");
assert!(say.contains("StreamType::Unary"), "{say}");
assert!(say.contains("IdempotencyLevel::NoSideEffects"), "{say}");
let subscribe = render(&consts[1]);
assert!(
subscribe.contains("StreamType::ServerStream"),
"{subscribe}"
);
assert!(
subscribe.contains("IdempotencyLevel::Idempotent"),
"{subscribe}"
);
let upload = render(&consts[2]);
assert!(upload.contains("StreamType::ClientStream"), "{upload}");
assert!(upload.contains("IdempotencyLevel::Unknown"), "{upload}");
let chat = render(&consts[3]);
assert!(chat.contains("StreamType::BidiStream"), "{chat}");
}
}