Skip to main content

connectrpc/
spec.rs

1//! Static RPC method metadata.
2//!
3//! [`Spec`] describes a single RPC procedure independent of any particular
4//! request: its fully-qualified path, stream type, idempotency level, and
5//! whether the artifact carrying the spec sits on the client or server side
6//! of the wire. Code generation emits one `Spec` constant per method; the
7//! runtime threads it through to handlers and (in a later release) to RPC
8//! interceptors so they can label spans, route, and gate behaviour without
9//! re-parsing the request URL.
10//!
11//! `Spec` deliberately carries only **registration-time** facts. Per-request
12//! state — negotiated protocol, codec, deadline — lives on
13//! [`RequestContext`](crate::RequestContext). This mirrors the split in
14//! `connect-go`, where `Spec` describes the method and `Peer` describes the
15//! connection.
16
17use crate::router::MethodKind;
18
19/// The shape of an RPC: how many messages flow in each direction.
20///
21/// This is the interceptor-facing equivalent of [`MethodKind`] and uses the
22/// `connect-go` naming so cross-runtime interceptor logic ports cleanly.
23/// Convert with [`From`] in either direction.
24///
25/// `StreamType` is intentionally exhaustive — the four shapes are fixed by
26/// the gRPC and Connect protocols. [`MethodKind`] is the routing-table
27/// equivalent used by [`Router`](crate::Router) registration; prefer
28/// `StreamType` in code that consumes a [`Spec`].
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30pub enum StreamType {
31    /// One request message, one response message.
32    Unary,
33    /// A stream of request messages, one response message.
34    ClientStream,
35    /// One request message, a stream of response messages.
36    ServerStream,
37    /// Streams of request and response messages.
38    BidiStream,
39}
40
41impl From<MethodKind> for StreamType {
42    fn from(kind: MethodKind) -> Self {
43        match kind {
44            MethodKind::Unary => Self::Unary,
45            MethodKind::ClientStreaming => Self::ClientStream,
46            MethodKind::ServerStreaming => Self::ServerStream,
47            MethodKind::BidiStreaming => Self::BidiStream,
48        }
49    }
50}
51
52impl From<StreamType> for MethodKind {
53    fn from(st: StreamType) -> Self {
54        match st {
55            StreamType::Unary => Self::Unary,
56            StreamType::ClientStream => Self::ClientStreaming,
57            StreamType::ServerStream => Self::ServerStreaming,
58            StreamType::BidiStream => Self::BidiStreaming,
59        }
60    }
61}
62
63/// The idempotency contract a method declares via
64/// `option idempotency_level` in its proto definition.
65///
66/// Connect uses this to decide whether a unary call may be retried or sent
67/// over an HTTP `GET` request. Interceptors can use it to make the same
68/// decision — for example, a retry interceptor should only retry calls that
69/// declare [`NoSideEffects`](IdempotencyLevel::NoSideEffects) or
70/// [`Idempotent`](IdempotencyLevel::Idempotent).
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
72pub enum IdempotencyLevel {
73    /// The method makes no idempotency guarantee. This is the proto default.
74    #[default]
75    Unknown,
76    /// The method is read-only and safe to retry or send via `GET`.
77    NoSideEffects,
78    /// The method may have side effects, but repeating it with the same
79    /// request is safe.
80    Idempotent,
81}
82
83/// Which generated artifact produced a [`Spec`].
84///
85/// `Spec` constants are emitted into both the server-side dispatcher
86/// (`FooServiceServer<T>`) and the generated client (`FooServiceClient<T>`).
87/// `SpecOrigin` records which artifact a particular `Spec` value came from,
88/// so an interceptor that runs on both sides can distinguish — e.g. open a
89/// `client` span on one side and a `server` span on the other, or inject
90/// trace-context headers only when [`Client`](SpecOrigin::Client).
91///
92/// This is an enum rather than a `bool` (`is_client`) because the domain is
93/// closed and two-valued: the variant name carries the meaning at the read
94/// site (`spec.origin == SpecOrigin::Client` reads better than
95/// `spec.is_client`), and codegen constructs the right value via
96/// [`Spec::server`] / [`Spec::client`] without a builder.
97///
98/// `SpecOrigin` is intentionally exhaustive — RPC artifacts are either a
99/// client or a server. It is **unrelated to the HTTP `Origin` header** or
100/// CORS; the name carries the `Spec` prefix to keep the distinction clear.
101#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
102pub enum SpecOrigin {
103    /// The `Spec` was emitted by a generated server-side dispatcher.
104    Server,
105    /// The `Spec` was emitted by a generated client.
106    Client,
107}
108
109/// Static description of an RPC method.
110///
111/// One `Spec` value exists per generated method, emitted as a
112/// `pub const … : Spec` in the generated service module and surfaced on
113/// [`RequestContext::spec`](crate::RequestContext::spec) for handlers. It
114/// names the method (`/package.Service/Method`), its stream shape, its
115/// proto-declared idempotency contract, and which generated artifact
116/// (server or client) produced it.
117///
118/// `Spec` is `Copy` and contains only `'static` data, so it can be stored,
119/// captured in closures, and compared freely with no allocation.
120///
121/// Construct one with [`Spec::server`] or [`Spec::client`]. The struct is
122/// `#[non_exhaustive]` so future fields can be added without a breaking
123/// change; destructure with a trailing `..`
124/// (e.g. `let Spec { procedure, stream_type, .. } = spec`).
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
126#[non_exhaustive]
127pub struct Spec {
128    /// The fully-qualified procedure path, `"/package.Service/Method"`.
129    ///
130    /// Includes the leading slash to match the HTTP request URI and the
131    /// OpenTelemetry `rpc.method` convention. The runtime strips the leading
132    /// slash before [`Dispatcher::lookup`](crate::Dispatcher::lookup); use
133    /// `procedure.trim_start_matches('/')` to compare against routing keys.
134    pub procedure: &'static str,
135    /// The message-flow shape of the method.
136    pub stream_type: StreamType,
137    /// Which generated artifact produced this `Spec`.
138    ///
139    /// Server-side dispatchers (`FooServiceServer<T>`) emit
140    /// [`SpecOrigin::Server`]; generated clients emit
141    /// [`SpecOrigin::Client`]. An interceptor registered on both sides
142    /// reads this to pick the right span kind or trace-propagation
143    /// direction.
144    pub origin: SpecOrigin,
145    /// The idempotency contract declared in the proto definition.
146    ///
147    /// This is the full three-valued proto enum. The boolean
148    /// [`MethodDescriptor::idempotent`](crate::dispatcher::MethodDescriptor::idempotent)
149    /// is a *derived* "Connect GET-eligible" flag that is only `true` for
150    /// [`NoSideEffects`](IdempotencyLevel::NoSideEffects) — `Idempotent`
151    /// methods are safe to retry but not GET-eligible.
152    pub idempotency_level: IdempotencyLevel,
153}
154
155impl Spec {
156    /// Construct a server-side `Spec` ([`SpecOrigin::Server`]) with the
157    /// default `idempotency_level` ([`IdempotencyLevel::Unknown`]).
158    ///
159    /// Generated server-side dispatchers chain
160    /// [`with_idempotency_level`](Spec::with_idempotency_level) onto this
161    /// constructor in `const` position, so `Spec` constants live in
162    /// `.rodata`.
163    ///
164    /// In debug builds, asserts that `procedure` starts with `/` and
165    /// contains a `/Service/Method` separator so a malformed test fixture
166    /// fails loudly rather than producing misleading [`service`](Spec::service)
167    /// / [`method`](Spec::method) accessor results.
168    pub const fn server(procedure: &'static str, stream_type: StreamType) -> Self {
169        debug_assert_well_formed(procedure);
170        Self {
171            procedure,
172            stream_type,
173            origin: SpecOrigin::Server,
174            idempotency_level: IdempotencyLevel::Unknown,
175        }
176    }
177
178    /// Construct a client-side `Spec` ([`SpecOrigin::Client`]) with the
179    /// default `idempotency_level` ([`IdempotencyLevel::Unknown`]).
180    ///
181    /// Generated clients chain
182    /// [`with_idempotency_level`](Spec::with_idempotency_level) onto this
183    /// constructor in `const` position, so `Spec` constants live in
184    /// `.rodata`.
185    ///
186    /// In debug builds, asserts that `procedure` starts with `/` and
187    /// contains a `/Service/Method` separator so a malformed test fixture
188    /// fails loudly rather than producing misleading [`service`](Spec::service)
189    /// / [`method`](Spec::method) accessor results.
190    pub const fn client(procedure: &'static str, stream_type: StreamType) -> Self {
191        debug_assert_well_formed(procedure);
192        Self {
193            procedure,
194            stream_type,
195            origin: SpecOrigin::Client,
196            idempotency_level: IdempotencyLevel::Unknown,
197        }
198    }
199
200    /// Set the idempotency level. Returns `self` for chaining in `const`
201    /// position.
202    #[must_use]
203    pub const fn with_idempotency_level(mut self, idempotency_level: IdempotencyLevel) -> Self {
204        self.idempotency_level = idempotency_level;
205        self
206    }
207
208    /// The bare service name (`"package.Service"`) from
209    /// [`procedure`](Spec::procedure), without the leading slash or trailing
210    /// `/Method`.
211    ///
212    /// Returns the whole procedure (sans leading `/`) if it contains no
213    /// method separator, which never happens for generated specs (the
214    /// constructors `debug_assert!` on it).
215    // TODO: make `const` once `str::rsplit_once` is const-stable.
216    pub fn service(&self) -> &'static str {
217        let p = self.procedure.trim_start_matches('/');
218        p.rsplit_once('/').map(|(svc, _)| svc).unwrap_or(p)
219    }
220
221    /// The bare method name (`"Method"`) from [`procedure`](Spec::procedure).
222    ///
223    /// Returns the whole procedure (sans leading `/`) if it contains no
224    /// method separator, which never happens for generated specs (the
225    /// constructors `debug_assert!` on it).
226    // TODO: make `const` once `str::rsplit_once` is const-stable.
227    pub fn method(&self) -> &'static str {
228        let p = self.procedure.trim_start_matches('/');
229        p.rsplit_once('/').map(|(_, m)| m).unwrap_or(p)
230    }
231}
232
233/// `const fn` debug assertion that a procedure path looks like
234/// `"/package.Service/Method"`: leading slash and at least one interior
235/// slash separating the service from the method.
236///
237/// This is a `const fn` so [`Spec::server`] / [`Spec::client`] stay
238/// const-evaluable: a malformed procedure in a `const SPEC: Spec` will
239/// surface as a *compile-time* panic on a debug build of the consuming
240/// crate, not a silent mis-parse at runtime. Compiles to nothing in
241/// release builds.
242const fn debug_assert_well_formed(procedure: &str) {
243    if cfg!(debug_assertions) {
244        let bytes = procedure.as_bytes();
245        // Must start with '/'.
246        assert!(
247            !bytes.is_empty() && bytes[0] == b'/',
248            "Spec procedure must start with '/' (e.g. \"/pkg.Service/Method\")"
249        );
250        // Must have a second '/' separating Service from Method.
251        let mut has_inner_slash = false;
252        let mut i = 1;
253        while i < bytes.len() {
254            if bytes[i] == b'/' {
255                has_inner_slash = true;
256                break;
257            }
258            i += 1;
259        }
260        assert!(
261            has_inner_slash,
262            "Spec procedure must contain a '/Service/Method' separator (e.g. \"/pkg.Service/Method\")"
263        );
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn stream_type_round_trips_method_kind() {
273        for kind in [
274            MethodKind::Unary,
275            MethodKind::ServerStreaming,
276            MethodKind::ClientStreaming,
277            MethodKind::BidiStreaming,
278        ] {
279            assert_eq!(MethodKind::from(StreamType::from(kind)), kind);
280        }
281    }
282
283    #[test]
284    fn spec_const_construction_and_accessors() {
285        const SPEC: Spec = Spec::server("/pkg.Greet/Say", StreamType::Unary)
286            .with_idempotency_level(IdempotencyLevel::NoSideEffects);
287        assert_eq!(SPEC.procedure, "/pkg.Greet/Say");
288        assert_eq!(SPEC.service(), "pkg.Greet");
289        assert_eq!(SPEC.method(), "Say");
290        assert_eq!(SPEC.stream_type, StreamType::Unary);
291        assert_eq!(SPEC.idempotency_level, IdempotencyLevel::NoSideEffects);
292        const { assert!(matches!(SPEC.origin, SpecOrigin::Server)) };
293    }
294
295    #[test]
296    fn spec_client_const_construction() {
297        const SPEC: Spec = Spec::client("/pkg.Greet/Say", StreamType::Unary);
298        assert_eq!(SPEC.origin, SpecOrigin::Client);
299        assert_eq!(SPEC.idempotency_level, IdempotencyLevel::Unknown);
300    }
301
302    #[test]
303    fn spec_defaults() {
304        let s = Spec::server("/a.B/C", StreamType::BidiStream);
305        assert_eq!(s.idempotency_level, IdempotencyLevel::Unknown);
306        assert_eq!(s.origin, SpecOrigin::Server);
307    }
308
309    #[test]
310    #[cfg_attr(
311        debug_assertions,
312        should_panic(expected = "Spec procedure must contain a '/Service/Method' separator")
313    )]
314    fn spec_malformed_path_no_method_separator_debug_asserts() {
315        let _ = Spec::server("/nopath", StreamType::Unary);
316    }
317
318    #[test]
319    #[cfg_attr(
320        debug_assertions,
321        should_panic(expected = "Spec procedure must start with '/'")
322    )]
323    fn spec_malformed_path_no_leading_slash_debug_asserts() {
324        let _ = Spec::server("pkg.Service/Method", StreamType::Unary);
325    }
326
327    #[test]
328    #[cfg(not(debug_assertions))]
329    fn spec_service_method_no_separator_release_fallback() {
330        // In release builds debug_assert_well_formed is a no-op, so this is
331        // the documented fallback behaviour.
332        let s = Spec::server("/nopath", StreamType::Unary);
333        assert_eq!(s.service(), "nopath");
334        assert_eq!(s.method(), "nopath");
335    }
336}