Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,15 @@ and the three client suites.

## Checked-In Generated Code

Four directories contain checked-in `buf generate` output and **must be
Five directories contain checked-in `buf generate` output and **must be
regenerated** whenever `connectrpc-codegen` output changes (or the buffa
dependency is bumped):

- `conformance/src/generated/`
- `examples/eliza/src/generated/`
- `examples/multiservice/src/generated/`
- `benches/rpc/src/generated/`
- `connectrpc-health/src/generated/`

Regenerate all of them with:

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["connectrpc", "connectrpc-codegen", "connectrpc-build", "conformance", "examples/eliza", "examples/middleware", "examples/mtls-identity", "examples/multiservice", "examples/streaming-tour", "examples/wasm-client", "tests/streaming", "benches/rpc", "benches/rpc-tonic"]
members = ["connectrpc", "connectrpc-codegen", "connectrpc-build", "connectrpc-health", "conformance", "examples/eliza", "examples/middleware", "examples/mtls-identity", "examples/multiservice", "examples/streaming-tour", "examples/wasm-client", "tests/streaming", "benches/rpc", "benches/rpc-tonic"]
resolver = "2"

[workspace.package]
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ connectrpc provides:
- **`connectrpc`** — A Tower-based runtime library implementing the Connect protocol
- **`protoc-gen-connect-rust`** — A `protoc` plugin that generates service traits, clients, and message types
- **`connectrpc-build`** — `build.rs` integration for generating code at build time
- **`connectrpc-health`** — The standard `grpc.health.v1.Health` service, for `grpc_health_probe` / kubelet gRPC probes / service-mesh health checks

The runtime is built on [`tower::Service`](https://docs.rs/tower/latest/tower/trait.Service.html), making it framework-agnostic. It integrates with any tower-compatible HTTP framework including [Axum](https://docs.rs/axum), [Hyper](https://docs.rs/hyper), and others.

Expand Down Expand Up @@ -221,6 +222,10 @@ use std::sync::Arc;
let service = Arc::new(MyGreetService);
let connect = service.register(ConnectRouter::new());

// Plain HTTP liveness probe for `kubectl`'s httpGet style. For the
// standard gRPC Health protocol (grpc_health_probe, kubelet `grpc:`
// probes), mount `connectrpc_health::HealthService` on the Connect
// router instead — see docs/guide.md#health-checking.
let app = Router::new()
.route("/health", get(|| async { "OK" }))
.fallback_service(connect.into_axum_service());
Expand Down Expand Up @@ -301,6 +306,7 @@ The Quick Start above shows the unary path. For everything else, see the user gu
- **Interceptors** (typed, async per-RPC middleware for unary and streaming calls) - see [docs/guide.md#interceptors](docs/guide.md#interceptors). Interceptors see the resolved `Spec`, headers, deadline, and a lazily decoded message body, and can rewrite or short-circuit the call - the equivalent of `connect-go`'s `WithInterceptors`.
- **Tower middleware on the server** (gzip, raw header rewriting, generic HTTP concerns below the RPC layer) - see [docs/guide.md#tower-middleware](docs/guide.md#tower-middleware) and [`examples/middleware/`](examples/middleware) for a custom auth layer that stamps caller identity into request extensions.
- **TLS / mTLS** - see [docs/guide.md#tls](docs/guide.md#tls) and [`examples/eliza/README.md`](examples/eliza/README.md) for cert generation and `Server::with_tls` / `HttpClient::with_tls` patterns.
- **gRPC health checking** (`grpc.health.v1.Health`, used by `grpc_health_probe`, kubelet `grpc:` probes, and service meshes) - see [docs/guide.md#health-checking](docs/guide.md#health-checking) and the [`connectrpc-health`](connectrpc-health/) crate.

## Feature Flags

Expand Down Expand Up @@ -353,6 +359,49 @@ serde_json = "1"
http-body = "1"
```

### Optional: gate the client behind a Cargo feature

If you want a server-only build of your crate to drop the
`connectrpc/client` transport stack, opt in to the cfg gate. With
`buf generate`:

```yaml
# buf.gen.yaml
plugins:
- local: protoc-gen-connect-rust
out: src/gen/connect
opt: [buffa_module=crate::proto, gate_client_feature]
```

Or with `connectrpc-build` in `build.rs`:

```rust
// build.rs
connectrpc_build::Config::new()
.files(&["proto/greet.proto"])
.includes(&["proto/"])
.gate_client_feature(true)
.compile()?;
```

The codegen then prefixes every emitted `FooClient<T>` struct and its
`impl` block with `#[cfg(feature = "client")]`. Declare the feature in
your `Cargo.toml` to forward it through to the runtime dep:

```toml
[features]
default = ["client"]
client = ["connectrpc/client"]

[dependencies]
connectrpc = { version = "0.6", features = ["server"] } # no "client"
```

`cargo build --no-default-features` now leaves out the `FooClient` items
*and* drops `connectrpc/client` (the HTTP/2 transport stack) from the
dependency graph. See `connectrpc-health` for the minimal example. The
option is opt-in; the default emission is unconditional.

## Protocol Support

| Protocol | Status |
Expand Down
7 changes: 7 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ tasks:
cmds:
- buf generate

connectrpc-health:generate:
desc: Regenerate connectrpc-health Rust code from the vendored grpc.health.v1 proto
dir: "{{.ROOT_DIR}}/connectrpc-health"
cmds:
- buf generate

example:multiservice:server:
desc: Run the multi-service server (greet, math, well-known types)
cmds:
Expand Down Expand Up @@ -374,6 +380,7 @@ tasks:
- task: example:multiservice:generate
- task: conformance:generate
- task: bench:generate
- task: connectrpc-health:generate


# ===========================================================================
Expand Down
80 changes: 80 additions & 0 deletions connectrpc-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,23 @@ impl Config {
self
}

/// Prefix every generated `FooClient<T>` struct and its `impl` block
/// with `#[cfg(feature = "client")]` (default: `false`).
///
/// Opt in when you want a server-only build of your crate to drop
/// the `connectrpc/client` transport stack from its dependency
/// graph. The consumer crate then declares a `client` Cargo feature
/// that forwards to `connectrpc/client`; see the `# Client-side cfg
/// gate` section in [`connectrpc_codegen::codegen::generate`]'s
/// docs for the minimal pattern. With the option off (the default),
/// generated client items are unconditional — external consumers
/// don't have to declare any Cargo feature.
#[must_use]
pub fn gate_client_feature(mut self, enabled: bool) -> Self {
self.options.gate_client_feature = enabled;
self
}

/// Replace the underlying buffa [`CodeGenConfig`] wholesale.
///
/// Any buffa knob not surfaced as a builder method here can be set this
Expand Down Expand Up @@ -626,12 +643,14 @@ mod tests {
.strict_utf8_mapping(true)
.generate_json(false)
.emit_register_fn(false)
.gate_client_feature(true)
.include_file("_inc.rs");
assert_eq!(cfg.files.len(), 2);
assert_eq!(cfg.includes.len(), 1);
assert!(cfg.options.buffa.strict_utf8_mapping);
assert!(!cfg.options.buffa.generate_json);
assert!(!cfg.options.buffa.emit_register_fn);
assert!(cfg.options.gate_client_feature);
assert_eq!(cfg.include_file.as_deref(), Some("_inc.rs"));
}

Expand All @@ -641,10 +660,71 @@ mod tests {
assert!(!cfg.options.buffa.strict_utf8_mapping);
assert!(cfg.options.buffa.generate_json);
assert!(cfg.options.buffa.emit_register_fn);
// `gate_client_feature` defaults off — build.rs consumers don't
// have to declare a `client` Cargo feature unless they opt in.
assert!(!cfg.options.gate_client_feature);
assert!(cfg.emit_rerun_directives);
assert!(matches!(cfg.descriptor_source, DescriptorSource::Protoc));
}

/// End-to-end through `Config`: with `gate_client_feature(true)`,
/// the generated `__connect.rs` contains `#[cfg(feature = "client")]`
/// on the `EchoServiceClient` struct + impl. Without the opt-in, the
/// cfg attr is absent. Uses the same `echo.fds.bin` fixture as
/// [`compile_precompiled_descriptor_set`].
#[test]
fn compile_gate_client_feature_emits_cfg_attr() {
let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));

// Opt-in: cfg attrs present on the client items.
let out_with = tempfile::tempdir().unwrap();
Config::new()
.descriptor_set(&fixture)
.files(&["echo.proto"])
.out_dir(out_with.path())
.gate_client_feature(true)
.emit_rerun_directives(false)
.compile()
.expect("compile with gate_client_feature=true");
let gated = std::fs::read_to_string(out_with.path().join("echo.__connect.rs"))
.expect("read gated __connect.rs");
let cfg_count = gated.matches("#[cfg(feature = \"client\")]").count();
assert_eq!(
cfg_count, 2,
"expected exactly 2 cfg attrs (struct + impl) with \
gate_client_feature=true; got {cfg_count}:\n{gated}"
);
// Sanity: the server-side trait + ext trait must not be gated.
for marker in ["pub trait EchoService", "pub trait EchoServiceExt"] {
let idx = gated
.find(marker)
.unwrap_or_else(|| panic!("expected `{marker}` in output:\n{gated}"));
let prefix = &gated[..idx];
assert!(
!prefix.trim_end().ends_with("#[cfg(feature = \"client\")]"),
"`{marker}` must not be gated:\n{gated}"
);
}

// Opt-out (default): no cfg attrs anywhere in the same file.
let out_without = tempfile::tempdir().unwrap();
Config::new()
.descriptor_set(&fixture)
.files(&["echo.proto"])
.out_dir(out_without.path())
.emit_rerun_directives(false)
.compile()
.expect("compile with default options");
let ungated = std::fs::read_to_string(out_without.path().join("echo.__connect.rs"))
.expect("read default __connect.rs");
assert!(
!ungated.contains("#[cfg(feature ="),
"default emission must not emit any cfg attr — external \
consumers should not need to declare a `client` Cargo \
feature unless they opt in. Got:\n{ungated}"
);
}

#[test]
fn config_emit_rerun_directives_toggle() {
let cfg = Config::new().emit_rerun_directives(false);
Expand Down
Loading