connectrpc 0.9.0

A Tower-based Rust implementation of the ConnectRPC protocol
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Static RPC method metadata.
//!
//! [`Spec`] describes a single RPC procedure independent of any particular
//! request: its fully-qualified path, stream type, idempotency level, and
//! whether the artifact carrying the spec sits on the client or server side
//! of the wire. Code generation emits one `Spec` constant per method; the
//! runtime threads it through to handlers and (in a later release) to RPC
//! interceptors so they can label spans, route, and gate behaviour without
//! re-parsing the request URL.
//!
//! `Spec` deliberately carries only **registration-time** facts. Per-request
//! state — negotiated protocol, codec, deadline — lives on
//! [`RequestContext`](crate::RequestContext). This mirrors the split in
//! `connect-go`, where `Spec` describes the method and `Peer` describes the
//! connection.

use crate::router::MethodKind;

/// The shape of an RPC: how many messages flow in each direction.
///
/// This is the interceptor-facing equivalent of [`MethodKind`] and uses the
/// `connect-go` naming so cross-runtime interceptor logic ports cleanly.
/// Convert with [`From`] in either direction.
///
/// `StreamType` is intentionally exhaustive — the four shapes are fixed by
/// the gRPC and Connect protocols. [`MethodKind`] is the routing-table
/// equivalent used by [`Router`](crate::Router) registration; prefer
/// `StreamType` in code that consumes a [`Spec`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum StreamType {
    /// One request message, one response message.
    Unary,
    /// A stream of request messages, one response message.
    ClientStream,
    /// One request message, a stream of response messages.
    ServerStream,
    /// Streams of request and response messages.
    BidiStream,
}

impl From<MethodKind> for StreamType {
    fn from(kind: MethodKind) -> Self {
        match kind {
            MethodKind::Unary => Self::Unary,
            MethodKind::ClientStreaming => Self::ClientStream,
            MethodKind::ServerStreaming => Self::ServerStream,
            MethodKind::BidiStreaming => Self::BidiStream,
        }
    }
}

impl From<StreamType> for MethodKind {
    fn from(st: StreamType) -> Self {
        match st {
            StreamType::Unary => Self::Unary,
            StreamType::ClientStream => Self::ClientStreaming,
            StreamType::ServerStream => Self::ServerStreaming,
            StreamType::BidiStream => Self::BidiStreaming,
        }
    }
}

/// The idempotency contract a method declares via
/// `option idempotency_level` in its proto definition.
///
/// Connect uses this to decide whether a unary call may be retried or sent
/// over an HTTP `GET` request. Interceptors can use it to make the same
/// decision — for example, a retry interceptor should only retry calls that
/// declare [`NoSideEffects`](IdempotencyLevel::NoSideEffects) or
/// [`Idempotent`](IdempotencyLevel::Idempotent).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum IdempotencyLevel {
    /// The method makes no idempotency guarantee. This is the proto default.
    #[default]
    Unknown,
    /// The method is read-only and safe to retry or send via `GET`.
    NoSideEffects,
    /// The method may have side effects, but repeating it with the same
    /// request is safe.
    Idempotent,
}

/// Which generated artifact produced a [`Spec`].
///
/// `Spec` constants are emitted into both the server-side dispatcher
/// (`FooServiceServer<T>`) and the generated client (`FooServiceClient<T>`).
/// `SpecOrigin` records which artifact a particular `Spec` value came from,
/// so an interceptor that runs on both sides can distinguish — e.g. open a
/// `client` span on one side and a `server` span on the other, or inject
/// trace-context headers only when [`Client`](SpecOrigin::Client).
///
/// This is an enum rather than a `bool` (`is_client`) because the domain is
/// closed and two-valued: the variant name carries the meaning at the read
/// site (`spec.origin == SpecOrigin::Client` reads better than
/// `spec.is_client`), and codegen constructs the right value via
/// [`Spec::server`] / [`Spec::client`] without a builder.
///
/// `SpecOrigin` is intentionally exhaustive — RPC artifacts are either a
/// client or a server. It is **unrelated to the HTTP `Origin` header** or
/// CORS; the name carries the `Spec` prefix to keep the distinction clear.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SpecOrigin {
    /// The `Spec` was emitted by a generated server-side dispatcher.
    Server,
    /// The `Spec` was emitted by a generated client.
    Client,
}

/// Static description of an RPC method.
///
/// One `Spec` value exists per generated method, emitted as a
/// `pub const … : Spec` in the generated service module and surfaced on
/// [`RequestContext::spec`](crate::RequestContext::spec) for handlers. It
/// names the method (`/package.Service/Method`), its stream shape, its
/// proto-declared idempotency contract, and which generated artifact
/// (server or client) produced it.
///
/// `Spec` is `Copy` and contains only `'static` data, so it can be stored
/// and captured in closures with no allocation. `PartialEq` and `Hash`
/// cover every field, including [`origin`](Spec::origin): the value a
/// client-side interceptor sees is *not* `==` to the generated
/// `FOO_SERVICE_BAR_SPEC` constant (origin `Server`), and a `HashMap` keyed
/// by the constants misses it. Compare method identity across sides with
/// [`same_method`](Spec::same_method).
///
/// Construct one with [`Spec::server`] or [`Spec::client`]. The struct is
/// `#[non_exhaustive]` so future fields can be added without a breaking
/// change; destructure with a trailing `..`
/// (e.g. `let Spec { procedure, stream_type, .. } = spec`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Spec {
    /// The fully-qualified procedure path, `"/package.Service/Method"`.
    ///
    /// Includes the leading slash to match the HTTP request URI and the
    /// OpenTelemetry `rpc.method` convention. The runtime strips the leading
    /// slash before [`Dispatcher::lookup`](crate::Dispatcher::lookup); use
    /// `procedure.trim_start_matches('/')` to compare against routing keys.
    pub procedure: &'static str,
    /// The message-flow shape of the method.
    pub stream_type: StreamType,
    /// Which generated artifact produced this `Spec`.
    ///
    /// Server-side dispatchers (`FooServiceServer<T>`) emit
    /// [`SpecOrigin::Server`]; generated clients emit
    /// [`SpecOrigin::Client`]. An interceptor registered on both sides
    /// reads this to pick the right span kind or trace-propagation
    /// direction.
    pub origin: SpecOrigin,
    /// The idempotency contract declared in the proto definition.
    ///
    /// This is the full three-valued proto enum. The boolean
    /// [`MethodDescriptor::idempotent`](crate::dispatcher::MethodDescriptor::idempotent)
    /// is a *derived* "Connect GET-eligible" flag that is only `true` for
    /// [`NoSideEffects`](IdempotencyLevel::NoSideEffects) — `Idempotent`
    /// methods are safe to retry but not GET-eligible.
    pub idempotency_level: IdempotencyLevel,
}

impl Spec {
    /// Construct a server-side `Spec` ([`SpecOrigin::Server`]) with the
    /// default `idempotency_level` ([`IdempotencyLevel::Unknown`]).
    ///
    /// Generated server-side dispatchers chain
    /// [`with_idempotency_level`](Spec::with_idempotency_level) onto this
    /// constructor in `const` position, so `Spec` constants live in
    /// `.rodata`.
    ///
    /// # Panics
    ///
    /// In debug builds, if `procedure` does not start with `/` or has no
    /// `/Service/Method` separator (a `const` fails at compile time), so a
    /// malformed fixture fails loudly rather than producing misleading
    /// [`service`](Spec::service) / [`method`](Spec::method) results.
    pub const fn server(procedure: &'static str, stream_type: StreamType) -> Self {
        debug_assert_well_formed(procedure);
        Self {
            procedure,
            stream_type,
            origin: SpecOrigin::Server,
            idempotency_level: IdempotencyLevel::Unknown,
        }
    }

    /// Construct a client-side `Spec` ([`SpecOrigin::Client`]) with the
    /// default `idempotency_level` ([`IdempotencyLevel::Unknown`]).
    ///
    /// Generated clients chain
    /// [`with_idempotency_level`](Spec::with_idempotency_level) onto this
    /// constructor in `const` position, so `Spec` constants live in
    /// `.rodata`.
    ///
    /// # Panics
    ///
    /// In debug builds, if `procedure` does not start with `/` or has no
    /// `/Service/Method` separator (a `const` fails at compile time). The
    /// client entry points repeat that check in every build, plus a check
    /// that the path is a valid URI path, and report `internal` instead.
    ///
    /// # Building one without generated code
    ///
    /// `procedure` is `&'static str` because a `Spec` is meant to be a
    /// per-method constant. A hand-written client with a fixed set of
    /// methods uses string literals. A truly dynamic caller (a proxy or CLI
    /// that learns method names at runtime) should **intern** each distinct
    /// procedure once — e.g. keep a `HashMap<String, Spec>` and `Box::leak`
    /// the string only on first sight — rather than leaking per call, which
    /// grows without bound.
    pub const fn client(procedure: &'static str, stream_type: StreamType) -> Self {
        debug_assert_well_formed(procedure);
        Self {
            procedure,
            stream_type,
            origin: SpecOrigin::Client,
            idempotency_level: IdempotencyLevel::Unknown,
        }
    }

    /// Set the idempotency level. Returns `self` for chaining in `const`
    /// position.
    #[must_use]
    pub const fn with_idempotency_level(mut self, idempotency_level: IdempotencyLevel) -> Self {
        self.idempotency_level = idempotency_level;
        self
    }

    /// Set which side this `Spec` describes. Returns `self` for chaining in
    /// `const` position.
    ///
    /// Code generation emits one constant per method (`FOO_SERVICE_BAR_SPEC`,
    /// [`SpecOrigin::Server`]); the generated client passes
    /// `FOO_SERVICE_BAR_SPEC.with_origin(SpecOrigin::Client)` to the runtime,
    /// so a client-side interceptor observes the same method facts with
    /// [`origin`](Spec::origin) flipped.
    #[must_use]
    pub const fn with_origin(mut self, origin: SpecOrigin) -> Self {
        self.origin = origin;
        self
    }

    /// Whether `self` and `other` name the same RPC method: compares
    /// [`procedure`](Spec::procedure) only, ignoring [`origin`](Spec::origin)
    /// (and the other fields, which are derived from the method).
    ///
    /// `Spec` derives `PartialEq` over *all* fields, so the value a client
    /// interceptor sees (origin `Client`) is **not** `==` to the generated
    /// `FOO_SERVICE_BAR_SPEC` constant (origin `Server`). Use this when
    /// asking "is this the `Bar` method?" regardless of side:
    /// `spec.same_method(FOO_SERVICE_BAR_SPEC)`.
    #[must_use]
    pub fn same_method(self, other: Spec) -> bool {
        self.procedure == other.procedure
    }

    /// The bare service name (`"package.Service"`) from
    /// [`procedure`](Spec::procedure), without the leading slash or trailing
    /// `/Method`.
    ///
    /// Returns the whole procedure (sans leading `/`) if it contains no
    /// method separator, which never happens for generated specs (the
    /// constructors `debug_assert!` on it).
    // TODO: make `const` once `str::rsplit_once` is const-stable.
    pub fn service(&self) -> &'static str {
        let p = self.procedure.trim_start_matches('/');
        p.rsplit_once('/').map(|(svc, _)| svc).unwrap_or(p)
    }

    /// The bare method name (`"Method"`) from [`procedure`](Spec::procedure).
    ///
    /// Returns the whole procedure (sans leading `/`) if it contains no
    /// method separator, which never happens for generated specs (the
    /// constructors `debug_assert!` on it).
    // TODO: make `const` once `str::rsplit_once` is const-stable.
    pub fn method(&self) -> &'static str {
        let p = self.procedure.trim_start_matches('/');
        p.rsplit_once('/').map(|(_, m)| m).unwrap_or(p)
    }
}

/// `const fn` debug assertion that a procedure path looks like
/// `"/package.Service/Method"`: leading slash and at least one interior
/// slash separating the service from the method.
///
/// This is a `const fn` so [`Spec::server`] / [`Spec::client`] stay
/// const-evaluable: a malformed procedure in a `const SPEC: Spec` will
/// surface as a *compile-time* panic on a debug build of the consuming
/// crate, not a silent mis-parse at runtime. Compiles to nothing in
/// release builds.
const fn debug_assert_well_formed(procedure: &str) {
    if cfg!(debug_assertions) {
        assert!(
            procedure_is_well_formed(procedure),
            "Spec procedure must start with '/' and contain a '/Service/Method' separator (e.g. \"/pkg.Service/Method\")"
        );
    }
}

/// Whether `procedure` looks like `"/package.Service/Method"`: a leading
/// slash and at least one interior slash. The one definition of
/// "well-formed" shared by the constructors' debug assertion above and the
/// client entry points' release-mode check.
pub(crate) const fn procedure_is_well_formed(procedure: &str) -> bool {
    let bytes = procedure.as_bytes();
    if bytes.is_empty() || bytes[0] != b'/' {
        return false;
    }
    let mut i = 1;
    while i < bytes.len() {
        if bytes[i] == b'/' {
            return true;
        }
        i += 1;
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn stream_type_round_trips_method_kind() {
        for kind in [
            MethodKind::Unary,
            MethodKind::ServerStreaming,
            MethodKind::ClientStreaming,
            MethodKind::BidiStreaming,
        ] {
            assert_eq!(MethodKind::from(StreamType::from(kind)), kind);
        }
    }

    #[test]
    fn spec_const_construction_and_accessors() {
        const SPEC: Spec = Spec::server("/pkg.Greet/Say", StreamType::Unary)
            .with_idempotency_level(IdempotencyLevel::NoSideEffects);
        assert_eq!(SPEC.procedure, "/pkg.Greet/Say");
        assert_eq!(SPEC.service(), "pkg.Greet");
        assert_eq!(SPEC.method(), "Say");
        assert_eq!(SPEC.stream_type, StreamType::Unary);
        assert_eq!(SPEC.idempotency_level, IdempotencyLevel::NoSideEffects);
        const { assert!(matches!(SPEC.origin, SpecOrigin::Server)) };
    }

    #[test]
    fn procedure_well_formedness() {
        assert!(procedure_is_well_formed("/pkg.Svc/M"));
        assert!(procedure_is_well_formed("/Svc/M"));
        assert!(!procedure_is_well_formed("pkg.Svc/M"), "no leading slash");
        assert!(
            !procedure_is_well_formed("/pkg.SvcM"),
            "no method separator"
        );
        assert!(!procedure_is_well_formed(""));
        assert!(!procedure_is_well_formed("/"));
    }

    /// A constant and its `with_origin(Client)` form are not `==` (origin
    /// differs) but are `same_method`; different methods are not.
    #[test]
    fn same_method_ignores_origin() {
        const SERVER: Spec = Spec::server("/pkg.Greet/Say", StreamType::Unary)
            .with_idempotency_level(IdempotencyLevel::NoSideEffects);
        const CLIENT: Spec = SERVER.with_origin(SpecOrigin::Client);
        const OTHER: Spec = Spec::client("/pkg.Greet/Shout", StreamType::Unary);
        assert_eq!(
            CLIENT,
            Spec::client("/pkg.Greet/Say", StreamType::Unary)
                .with_idempotency_level(IdempotencyLevel::NoSideEffects)
        );
        assert_ne!(SERVER, CLIENT, "PartialEq includes origin");
        assert!(SERVER.same_method(CLIENT));
        assert!(CLIENT.same_method(SERVER));
        assert!(!CLIENT.same_method(OTHER));
    }

    #[test]
    fn spec_client_const_construction() {
        const SPEC: Spec = Spec::client("/pkg.Greet/Say", StreamType::Unary);
        assert_eq!(SPEC.origin, SpecOrigin::Client);
        assert_eq!(SPEC.idempotency_level, IdempotencyLevel::Unknown);
    }

    #[test]
    fn spec_defaults() {
        let s = Spec::server("/a.B/C", StreamType::BidiStream);
        assert_eq!(s.idempotency_level, IdempotencyLevel::Unknown);
        assert_eq!(s.origin, SpecOrigin::Server);
    }

    #[test]
    #[cfg_attr(
        debug_assertions,
        should_panic(expected = "contain a '/Service/Method' separator")
    )]
    fn spec_malformed_path_no_method_separator_debug_asserts() {
        let _ = Spec::server("/nopath", StreamType::Unary);
    }

    #[test]
    #[cfg_attr(
        debug_assertions,
        should_panic(expected = "Spec procedure must start with '/'")
    )]
    fn spec_malformed_path_no_leading_slash_debug_asserts() {
        let _ = Spec::server("pkg.Service/Method", StreamType::Unary);
    }

    #[test]
    #[cfg(not(debug_assertions))]
    fn spec_service_method_no_separator_release_fallback() {
        // In release builds debug_assert_well_formed is a no-op, so this is
        // the documented fallback behaviour.
        let s = Spec::server("/nopath", StreamType::Unary);
        assert_eq!(s.service(), "nopath");
        assert_eq!(s.method(), "nopath");
    }
}