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}