Skip to content

Architecture

This page provides a conceptual overview of how connectrpc-axum works internally.

Overview

connectrpc-axum bridges the Connect protocol with Axum's handler model through multiple crates:

CratePurpose
connectrpc-axumServer runtime library - layers, extractors, response types
connectrpc-axum-clientClient library - HTTP client, streaming, interceptors
connectrpc-axum-coreShared protocol types - compression, error codes, envelope framing
connectrpc-axum-buildBuild-time code generation from proto files

The core modules in the client library:

ModulePurpose
client.rsConnectClient<I> - main client type, generic over interceptor chain
builder.rsClientBuilder<I> - fluent API for client configuration
config/interceptor.rsUnified interceptor system (Interceptor, MessageInterceptor)
transport/HTTP transport abstraction (HyperTransport)
request.rsRequest encoding, FrameEncoder for streaming
response.rsResponse types, Streaming<T>, FrameDecoder

The core modules in the server runtime library:

ModulePurpose
context/Protocol detection, compression config, message limits, timeouts
message/{request,response}.rsRequest/response primitives (decode, encode, compress)
layer/Middleware layers (BridgeLayer, ConnectLayer)
message/ConnectRequest<T> extractor and ConnectResponse<T> wrapper
handler.rsHandler wrappers that implement axum::handler::Handler
tonic/Optional gRPC interop and extractor support

Request Lifecycle

Layer Stack

Requests flow through a three-layer middleware stack:

HTTP Request

┌─────────────────────────────────────────────┐
│              BridgeLayer                    │  ← Size limits, streaming detection
│  ┌───────────────────────────────────────┐  │
│  │     Tower CompressionLayer            │  │  ← HTTP body compression (unary only)
│  │  ┌─────────────────────────────────┐  │  │
│  │  │         ConnectLayer            │  │  │  ← Protocol detection, context
│  │  │  ┌───────────────────────────┐  │  │  │
│  │  │  │          Handler          │  │  │  │  ← Your RPC handlers
│  │  │  └───────────────────────────┘  │  │  │
│  │  └─────────────────────────────────┘  │  │
│  └───────────────────────────────────────┘  │
└─────────────────────────────────────────────┘

HTTP Response

BridgeLayer (outermost) - see layer/bridge.rs:

  • Checks Content-Length against receive size limits (on compressed body)
  • Detects Connect streaming requests (application/connect+*)
  • For streaming: prevents Tower compression by setting identity encoding

Tower CompressionLayer (middle):

  • Standard HTTP body compression for unary RPCs
  • Uses Accept-Encoding/Content-Encoding headers

ConnectLayer (innermost) - see layer/connect.rs:

  • Validates content-type and returns HTTP 415 for unsupported types
  • Parses Content-Type to determine encoding (JSON/Protobuf)
  • Parses ?encoding= query param for GET requests
  • Validates Connect-Protocol-Version header when required
  • Parses timeout from Connect-Timeout-Ms header
  • Builds ConnectContext and stores it in request extensions

Compression Paths

The Connect protocol uses different compression mechanisms for unary vs streaming RPCs:

Unary RPCs - HTTP body compression:

Request → BridgeLayer (size check) → Tower decompress → ConnectLayer → Handler

Response ← BridgeLayer ← Tower compress ← ConnectLayer ← Handler response
  • Uses standard Accept-Encoding / Content-Encoding headers
  • Handled entirely by Tower's CompressionLayer
  • BridgeLayer checks compressed body size before decompression

Streaming RPCs - per-envelope compression:

Request → BridgeLayer (bypass Tower) → ConnectLayer → Handler

                                    Each message envelope compressed individually
  • Uses Connect-Accept-Encoding / Connect-Content-Encoding headers
  • BridgeLayer sets Accept-Encoding: identity to prevent Tower from interfering
  • context/envelope_compression.rs provides codec implementations

Code Structure

The request/response processing follows this module hierarchy:

context/           ← Configuration and protocol state
    protocol.rs        RequestProtocol enum, detection
    envelope_compression.rs    Per-message compression
    limit.rs           Message size limits
    timeout.rs         Request timeout

message/{request,response}.rs   ← Low-level encode/decode functions

layer/             ← Middleware that builds context
    bridge.rs          BridgeLayer/BridgeService
    connect.rs         ConnectLayer/ConnectService

message/           ← Axum extractors and response types
    request.rs         ConnectRequest<T>, Streaming<T>
    response.rs        ConnectResponse<T>, StreamBody<S>

Handlers receive a ConnectRequest<T> extractor that reads the ConnectContext from request extensions, then uses message/request.rs functions to decode the message. Response encoding uses message/response.rs in the reverse path.

Axum Extractor Support in Tonic Handlers

When using tonic-compatible handlers, axum's FromRequestParts extractors need access to HTTP request parts. The challenge: tonic consumes the HTTP request before your handler runs.

The solution is FromRequestPartsLayer in tonic/parts.rs:

HTTP Request

FromRequestPartsLayer ← Clones method, uri, version, headers into extensions

Tonic gRPC Server    ← Consumes HTTP request, but extensions survive

Your Handler         ← Reconstructs RequestContext from:
                        - CapturedParts (from extensions)
                        - extensions (from tonic::Request)

Key insight: http::Extensions cannot be cloned, but it can be moved. The layer captures clonable parts (CapturedParts), and the handler later combines them with the owned extensions to build a complete RequestContext for extraction.

Code Generation

ConnectHandlerWrapper

The ConnectHandlerWrapper<F> type transforms user functions into axum-compatible handlers. It's a newtype wrapper with multiple impl Handler<T, S> blocks, each with different trait bounds:

User function: async fn(E1, E2, ..., ConnectRequest<Req>) -> ConnectResponse<Resp>
               where E1, E2, ... : FromRequestParts

            ConnectHandlerWrapper<F> implements Handler<T, S>

                        Axum can route to it

Handlers can include any types implementing FromRequestParts before the ConnectRequest<T> parameter, just like regular axum handlers. The compiler selects the appropriate impl based on the handler signature:

PatternRequest TypeResponse Type
UnaryConnectRequest<Req>ConnectResponse<Resp>
Server streamingConnectRequest<Req>ConnectResponse<StreamBody<St>>
Client streamingConnectRequest<Streaming<Req>>ConnectResponse<Resp>
Bidi streamingConnectRequest<Streaming<Req>>ConnectResponse<StreamBody<St>>

See handler.rs for the implementation.

Tonic-Compatible Handlers

For tonic-style handlers (trait-based), the library uses a factory pattern with boxed calls:

User trait impl: async fn method(&self, req: tonic::Request<Req>) -> Result<Response<Resp>, Status>

            IntoFactory trait converts to BoxedCall

            TonicCompatibleHandlerWrapper adapts to axum Handler

                        Axum can route to it

The "2-layer box" approach (same pattern axum uses for HandlerMethodRouter):

  1. Factory layer: IntoFactory trait produces BoxedCall<Req, Resp> - a type-erased callable
  2. Wrapper layer: TonicCompatibleHandlerWrapper implements Handler for the boxed call

One caveat: axum uses a trait for the factory layer, while we use closures. See this discussion for the design rationale.

This allows generated code to work with user-provided trait implementations without knowing concrete types at compile time. See tonic/handler.rs for the boxed call types and factory traits.

Multi-Stage Code Generation

Code generation uses staged passes to avoid type duplication while keeping serde and tonic output aligned:

Pass 1: Prost + Connect

proto files → prost_build → Message types (Rust structs)
                          → Connect service builders
                          → File descriptor set

Pass 1.5: pbjson serde generation

File descriptor set → pbjson_build → {package}.serde.rs
                                  → Appended into Pass 1 files

Pass 2: Tonic server (optional)

File descriptor set → tonic_build → Server traits/stubs
                                  → Uses extern_path to reference Pass 1 types

Pass 3: Tonic client (optional)

File descriptor set → tonic_build → Client stubs
                                  → Uses extern_path to reference Pass 1 types

The key is extern_path: tonic passes don't regenerate message types, they reference Pass 1 output. pbjson is a separate builder, so extern mappings for pbjson must be configured separately when needed (for example with with_pbjson_config).

See CompileBuilder in connectrpc-axum-build for the type-state pattern that enforces valid configurations at compile time.

MakeServiceBuilder

MakeServiceBuilder combines multiple services and applies cross-cutting infrastructure:

rust
let app = MakeServiceBuilder::new()
    .add_router(hello_service_router)      // Connect service
    .add_router(echo_service_router)       // Another Connect service
    .add_grpc_service(grpc_server)         // Tonic gRPC service
    .add_axum_router(health_router)        // Plain axum routes (bypass ConnectLayer)
    .build();

The builder handles:

  • Wrapping Connect routes with ConnectLayer for protocol handling
  • Wrapping routes with BridgeLayer for compression bridging
  • Adding Tower CompressionLayer for HTTP body compression
  • Routing gRPC services through ContentTypeSwitch (by Content-Type header)
  • Passing plain axum routes through without Connect processing
User provides:
    ├── Connect routers (from generated builders)
    ├── gRPC services (tonic)
    └── Plain axum routers

MakeServiceBuilder adds:
    ├── BridgeLayer
    ├── CompressionLayer
    ├── ConnectLayer (for Connect routes only)
    └── ContentTypeSwitch (routes gRPC vs Connect)

Output: Single axum Router

For mixed Connect/gRPC deployments, ContentTypeSwitch routes by Content-Type header:

  • application/grpc* → Tonic gRPC server
  • Otherwise → Axum routes (Connect protocol)

See service_builder.rs for the implementation.

Client Interceptor System

The client provides a unified interceptor system for cross-cutting concerns like authentication, logging, and message transformation.

Trait Hierarchy

Two user-facing traits, both internally unified:

TraitPurposeUse Case
InterceptorHeader-level access onlyAuth headers, trace IDs, logging procedure names
MessageInterceptorTyped message accessValidation, message transformation, per-message logging

Both traits are wrapped internally to InterceptorInternal via adapter types:

  • HeaderWrapper<I> - wraps Interceptor implementations
  • MessageWrapper<I> - wraps MessageInterceptor implementations

This enables zero-cost composition via Chain<A, B> without dynamic dispatch.

Context Types

Interceptors receive context objects with relevant information:

TypeFieldsUsed In
RequestContextprocedure, headers (mutable)on_request
ResponseContextprocedure, headers (read-only)on_response
StreamContextprocedure, stream_type, request_headers, response_headersStreaming methods

Builder API

ConnectClient and ClientBuilder use a single type parameter I for the interceptor chain (defaults to ()):

rust
// No interceptors
let client = ConnectClient::builder("http://localhost:3000")
    .build()?;

// With header-level interceptor
let client = ConnectClient::builder("http://localhost:3000")
    .with_interceptor(AuthInterceptor::new("Bearer token"))
    .build()?;

// With message-level interceptor
let client = ConnectClient::builder("http://localhost:3000")
    .with_message_interceptor(LoggingInterceptor)
    .build()?;

// Chaining multiple interceptors
let client = ConnectClient::builder("http://localhost:3000")
    .with_interceptor(AuthInterceptor::new("Bearer token"))
    .with_message_interceptor(ValidationInterceptor)
    .with_interceptor(TracingInterceptor)
    .build()?;

Each with_interceptor or with_message_interceptor call wraps the interceptor and composes it with the existing chain:

  • with_interceptor(i) returns ClientBuilder<Chain<I, HeaderWrapper<J>>>
  • with_message_interceptor(i) returns ClientBuilder<Chain<I, MessageWrapper<J>>>

Convenience Types

TypePurpose
HeaderInterceptorAdd a single header to all requests
ClosureInterceptorQuick header-level interception via closure

Execution Order

For requests, interceptors run in the order added (first added = first to run). For responses, interceptors run in reverse order (middleware unwinding pattern).

See config/interceptor.rs for the implementation.

Released under the MIT License.