Skip to content
Created by

Getting started

Connect for Rust turns a Protobuf schema into a service trait you implement and a client you call. The generated server is an ordinary tower::Service, so it mounts into Axum, hyper, or whatever Tower stack you already run, and it answers Connect, gRPC, and gRPC-Web callers on a single port.

This walkthrough builds a greeting service end to end, then calls it three ways: with cURL, with buf curl, and with the generated Rust client.

  • Rust 1.88 or newer. The crates use the 2024 edition, and 1.88 is the declared MSRV. See Install Rust if you don’t have a toolchain yet.
  • Either protoc or the Buf CLI. connectrpc-build shells out to one of them during cargo build, and we cover both.
  • cURL, to call the service without writing any code.
Terminal window
$ cargo new connect-rust-example
$ cd connect-rust-example

Edit Cargo.toml to add our dependencies. It should look like this after:

[package]
name = "connect-rust-example"
version = "0.1.0"
edition = "2024"
# We add a second binary (the client) further down, so pick which one
# a bare `cargo run` means.
default-run = "connect-rust-example"
[dependencies]
connectrpc = { version = "0.9", features = ["axum", "client"] }
buffa = { version = "0.9", features = ["json"] }
buffa-types = { version = "0.9", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
[build-dependencies]
connectrpc-build = "0.9"

connectrpc keeps its optional pieces behind Cargo features, so a server-only binary never compiles a client transport it doesn’t use. axum pulls in the integration we mount the router with, and client pulls in the hyper-backed transport the client half of this guide needs. json, gzip, and zstd are on by default.

The buffa and serde entries are there for the generated message types. buffa is the Protobuf implementation Connect for Rust generates against. It emits zero-copy view types, so string and bytes fields borrow out of the request buffer instead of being copied into owned Strings, and serde backs the JSON codec.

Terminal window
$ mkdir -p proto/greet/v1
$ touch proto/greet/v1/greet.proto

Open proto/greet/v1/greet.proto and add:

syntax = "proto3";
package greet.v1;
message GreetRequest {
string name = 1;
}
message GreetResponse {
string greeting = 1;
}
service GreetService {
rpc Greet(GreetRequest) returns (GreetResponse) {}
}

Connect derives routes from the schema, so this service is served at /greet.v1.GreetService/Greet. The names you pick here are used for the API endpoints.

Compile the schema from a build script, so there is nothing to install and nothing to check in. Add build.rs next to Cargo.toml:

build.rs
fn main() {
connectrpc_build::Config::new()
.files(&["proto/greet/v1/greet.proto"])
.includes(&["proto/"])
.include_file("_connectrpc.rs")
.compile()
.unwrap();
}

This writes into Cargo’s OUT_DIR and regenerates whenever a .proto changes. You pull the result into your crate with the include_generated! macro in the next step. For each service in the schema the generator emits a trait for the server and a client struct; for each message, an owned type and its zero-copy view.

This step needs protoc on your PATH, or the PROTOC environment variable pointing at it. connectrpc-build can drive buf or a precompiled descriptor set instead, which Generating code covers.

Build scripts are one of two supported workflows. If you’d rather commit generated code, split messages and services into separate module trees, or generate Rust alongside other languages from one schema, use the buf generate plugin workflow described in Generating code.

Marshaling, routing, and content negotiation are generated. What’s left is the greeting logic, which is one async method on the generated GreetService trait. Put this in src/main.rs:

use std::sync::Arc;
use connectrpc::{RequestContext, Response, Router, ServiceRequest, ServiceResult};
pub mod proto {
connectrpc::include_generated!();
}
use proto::greet::v1::*;
struct GreetServer;
impl GreetService for GreetServer {
async fn greet(
&self,
_ctx: RequestContext,
req: ServiceRequest<'_, GreetRequest>,
) -> ServiceResult<GreetResponse> {
Response::ok(GreetResponse {
greeting: format!("Hello, {}!", req.name),
..Default::default()
})
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let router = Router::new().add_service(Arc::new(GreetServer));
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
axum::serve(listener, router.into_axum_router()).await?;
Ok(())
}

req derefs to a view of the request, so req.name is a &str borrowed straight from the bytes the client sent, and reading it allocates nothing. That borrow is tied to the call, so reach for req.to_owned_message() when a value has to outlive it, such as anything you move into tokio::spawn. ServiceResult<T> is an alias for Result<Response<T>, ConnectError>, and Response::ok is its no-metadata constructor.

cargo build will warn about refining_impl_trait. That is expected here: the generated trait is deliberately more general than the impl you write, so that handlers can also return borrowed views. Implementing services covers what the bound buys you and how to allow the lint.

Terminal window
$ cargo run

In another terminal, the simplest call is an HTTP/1.1 POST carrying JSON:

Terminal window
$ curl \
--header "Content-Type: application/json" \
--data '{"name": "Jane"}' \
http://localhost:8080/greet.v1.GreetService/Greet
{"greeting": "Hello, Jane!"}

The same handler answers gRPC with no extra configuration:

Terminal window
$ buf curl \
--schema ./proto \
--protocol grpc \
--http2-prior-knowledge \
--data '{"name": "Jane"}' \
http://localhost:8080/greet.v1.GreetService/Greet
{
"greeting": "Hello, Jane!"
}

and the Connect protocol, which is what buf curl uses when you don’t name one:

Terminal window
$ buf curl \
--schema ./proto \
--data '{"name": "Jane"}' \
http://localhost:8080/greet.v1.GreetService/Greet

That same build script generated a GreetServiceClient. Add src/bin/client.rs:

use connectrpc::client::{ClientConfig, HttpClient};
// The same generated module the server uses.
pub mod proto {
connectrpc::include_generated!();
}
use proto::greet::v1::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let http = HttpClient::plaintext();
let config = ClientConfig::new("http://localhost:8080".parse()?);
let client = GreetServiceClient::new(http, config);
let res = client
.greet(GreetRequest {
name: "Jane".into(),
..Default::default()
})
.await?;
println!("{}", res.view().greeting);
Ok(())
}

HttpClient::plaintext() is a pooled hyper client that speaks cleartext http:// only, and HttpClient::with_tls(..) is its https:// counterpart. Each rejects the other’s scheme, so a misconfigured URL fails loudly instead of quietly downgrading. Responses are views as well, which is why the greeting is read through res.view(); into_owned() is there when you want the owned struct.

This is the second binary that default-run was for. With the server still running:

Terminal window
$ cargo run --bin client

You have already watched the server field all three protocols without being configured for any of them: curl spoke Connect, and the two buf curl calls spoke gRPC and Connect. Servers accept all three on one port. Clients pick one, and default to the Connect protocol.

To switch this client, ask the config for Protocol::Grpc. The transport has to change too: gRPC returns its status in HTTP trailers, so it needs HTTP/2, and cleartext has no ALPN to negotiate with. Swap in the prior-knowledge constructor:

use connectrpc::Protocol;
let http = HttpClient::plaintext_http2_only();
let config = ClientConfig::new("http://localhost:8080".parse()?)
.with_protocol(Protocol::Grpc);

This is the same constraint that made buf curl --protocol grpc need --http2-prior-knowledge above. Leave the transport on plaintext() and the request goes out over HTTP/1.1, where the server cannot write trailers, so the call fails with gRPC response missing grpc-status trailer. TLS clients are exempt, since with_tls(..) negotiates HTTP/2 over ALPN.

Terminal window
$ cargo run --bin client

The output is identical, but the call now goes over the wire as gRPC. The handler and the server are untouched.

A fifteen-line schema produced the message types, the server trait, and the client. You wrote a handler body, a Router, and a listener.