toolkit_contract/grpc_repr.rs
1//! Compile-time proto-representability check.
2//!
3//! [`GrpcRepr`] and [`GrpcReprScalar`] are marker traits used by the
4//! `#[toolkit::grpc_contract]` macro to verify that every method parameter
5//! and return type can be represented in proto3 — without running the
6//! schema-to-proto generator.
7//!
8//! Opt-in for user DTOs: add `#[derive(toolkit::ProtoBridge)]`. The derive
9//! emits both `impl GrpcRepr for YourType {}` and
10//! `impl GrpcReprScalar for YourType {}`.
11//!
12//! Built-in primitive impls are provided here. Composite shapes
13//! (`Vec<T>`, `Option<T>`, `HashMap<String, V>`, `BTreeMap<String, V>`) are
14//! accepted automatically when their element type implements
15//! [`GrpcReprScalar`]. Nested maps and `Vec<Vec<_>>` are intentionally NOT
16//! representable — proto3 has no equivalent.
17//!
18//! This module is feature-gate-free so the macro can emit static assertions
19//! regardless of which features the downstream crate enables.
20
21use std::collections::{BTreeMap, HashMap};
22
23/// Marker for "any type that can appear in a gRPC method signature, either
24/// as a parameter or as the success type of `Result<T, E>` returned from a
25/// method." Composite shapes (`Vec<T>`, `Option<T>`, maps) are
26/// `GrpcRepr` when their inner type is [`GrpcReprScalar`].
27pub trait GrpcRepr {}
28
29/// Marker for "scalar" types — anything that can sit inside a `Vec<>`,
30/// `Option<>`, or as the value type of a `HashMap<String, V>`.
31///
32/// Maps and lists are deliberately NOT scalars: proto3 disallows nesting
33/// `repeated repeated` and `map<K, map<...>>`.
34pub trait GrpcReprScalar: GrpcRepr {}
35
36// --- primitive scalar impls -------------------------------------------------
37
38macro_rules! impl_primitive_repr {
39 ($($t:ty),* $(,)?) => {
40 $(
41 impl GrpcRepr for $t {}
42 impl GrpcReprScalar for $t {}
43 )*
44 };
45}
46
47// Numeric and string primitives that have a 1:1 proto3 mapping.
48// `String` ↔ `string`, `i32` ↔ `int32`, `i64` ↔ `int64`, `u32` ↔ `uint32`,
49// `u64` ↔ `uint64`, `f32` ↔ `float`, `f64` ↔ `double`, `bool` ↔ `bool`.
50//
51// `i8`, `i16`, `u8`, `u16` are intentionally NOT included: proto3 has no
52// narrower-than-32-bit integer types and silently widening would lose
53// validation. Use `i32`/`u32` explicitly.
54//
55// `i128`, `u128`, `f128`, `char`, `isize`, `usize` are NOT included: they
56// have no proto3 representation.
57impl_primitive_repr!(String, i32, i64, u32, u64, f32, f64, bool);
58
59// --- composite impls --------------------------------------------------------
60
61/// `Vec<T>` → `repeated T`. Disallows `Vec<Vec<T>>` because `Vec<T>` does
62/// not implement [`GrpcReprScalar`].
63impl<T: GrpcReprScalar> GrpcRepr for Vec<T> {}
64
65/// `Option<T>` → `optional T` (proto3). Disallows `Option<Vec<T>>` and
66/// `Option<HashMap<_,_>>` for the same reason maps and lists aren't scalar.
67impl<T: GrpcReprScalar> GrpcRepr for Option<T> {}
68
69/// `HashMap<String, V>` → `map<string, V>`. Restricted to string keys —
70/// proto3 also allows integer keys but the common Rust idiom is string
71/// keys, and admitting more would defeat the guard's clarity.
72impl<V: GrpcReprScalar, S: ::std::hash::BuildHasher> GrpcRepr for HashMap<String, V, S> {}
73
74/// `BTreeMap<String, V>` mirrors `HashMap<String, V>` for code that prefers
75/// deterministic iteration order in serialized output.
76impl<V: GrpcReprScalar> GrpcRepr for BTreeMap<String, V> {}
77
78// --- byte-buffer impls ------------------------------------------------------
79//
80// `Vec<u8>` is *not* a `GrpcReprScalar`: `u8` itself is not in the impl list
81// above (proto3 has no 8-bit integer), so `Vec<u8>` would fail anyway. Users
82// who want a `bytes` field should derive `ProtoBridge` on a wrapper struct
83// or annotate their DTO field — both flow through `#[derive(ProtoBridge)]`.
84
85// --- compile-time assert helper --------------------------------------------
86
87/// Static assertion helper used by `#[toolkit::grpc_contract]` to fail
88/// compilation when a method parameter or return type cannot be represented
89/// in proto3.
90///
91/// The macro emits a `const _: () = { ... };` block calling this for every
92/// non-context method parameter and the `Ok` half of every `Result<T, E>`
93/// return type.
94///
95/// Naming is intentionally awkward — this is not a public API; treat it as
96/// an internal helper that happens to live in `pub mod` so generated code
97/// can reach it.
98#[doc(hidden)]
99pub const fn assert_grpc_repr<T: GrpcRepr + ?Sized>() {}
100
101// ---------------------------------------------------------------------------
102// SecurityContext marker
103// ---------------------------------------------------------------------------
104
105/// Marker trait for "this type carries an in-process security context that
106/// must be projected onto gRPC metadata at the client and reconstructed on
107/// the server" — i.e. a parameter the wire payload synthesizer must skip.
108///
109/// `#[toolkit::grpc_contract]` and `#[toolkit::rest_contract]` detect such
110/// parameters by type name (`*SecurityContext`-suffixed) and emit a static
111/// assertion `assert_security_context::<T>()` so accidentally naming a DTO
112/// `SecurityContext` without implementing this marker fails to compile.
113///
114/// Lives in this module (always-on, feature-gate-free) so the macro can
115/// emit unconditional assertions regardless of which features downstream
116/// crates enable. The default impl for `toolkit_security::SecurityContext`
117/// lives in [`crate::grpc`] under the `grpc-client` feature.
118pub trait SecurityContextMarker {}
119
120/// References are transparent.
121///
122/// The projection macros classify `&SecurityContext` exactly as they classify
123/// `SecurityContext` — `projection::type_path_ends_with` recurses through
124/// `Type::Reference` on purpose, and DESIGN §2.2 permits either spelling. The
125/// guard they emit asserts on the parameter type *as written*, so without this
126/// impl the by-reference form fails the assertion for **both** planes while the
127/// by-value form passes. That asymmetry is invisible until someone writes the
128/// reference form, which is the shape the cluster contract uses.
129impl<T: SecurityContextMarker + ?Sized> SecurityContextMarker for &T {}
130
131/// Compile-time helper used by generated code. Calling
132/// `assert_security_context::<T>()` requires `T: SecurityContextMarker` —
133/// so any type the macro classifies as "security context" must explicitly
134/// opt into the marker trait.
135#[doc(hidden)]
136pub const fn assert_security_context<T: SecurityContextMarker + ?Sized>() {}
137
138/// Error returned by the generated `try_from_i32` inherent method on a
139/// `ProtoBridge` enum when the wire value does not correspond to any known
140/// Rust variant. The parallel infallible `From<i32>` impl silently falls
141/// back to `Default::default()` for unknown discriminants — use
142/// `try_from_i32` when callers need to detect the unknown-variant case.
143#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
144#[error("unknown enum discriminant: {0}")]
145pub struct UnknownEnumDiscriminant(pub i32);
146
147/// Error returned by the generated `try_from_proto` inherent method on a
148/// `ProtoBridge` struct when a `#[proto_bridge(via_string)]` field carries a
149/// value that fails to parse via `FromStr`. Because that parse can fail on
150/// peer-supplied input, a struct with such a field gets no infallible
151/// `From<Proto>` impl at all — `try_from_proto` is its only decode path, so
152/// wire input from peers cannot expose a remote-DoS surface.
153#[derive(Debug, thiserror::Error)]
154#[error("proto bridge: invalid `{field}` value (could not parse from string): {source}")]
155pub struct ViaStringParseError {
156 pub field: &'static str,
157 #[source]
158 pub source: Box<dyn std::error::Error + Send + Sync + 'static>,
159}
160
161/// Fallible proto → Rust conversion for inbound wire data.
162///
163/// `#[derive(ProtoBridge)]` always emits this fallible conversion, and emits an
164/// infallible `From<Proto>` alongside it *unless* the struct has a
165/// `#[proto_bridge(via_string)]` field — such a field decodes through `FromStr`,
166/// which a peer can make fail (a malformed UUID from a remote would take the
167/// process down), so no infallible path is generated for it. Generated gRPC
168/// clients and hand-written tonic servers convert inbound messages through this
169/// trait.
170///
171/// The derive implements it for structs and enums alike, so codegen can call it
172/// uniformly. A hand-written proto bridge used as an RPC response type must
173/// implement it too; the missing impl is a compile error at the call site
174/// rather than a silent fall-back to the panicking path.
175pub trait TryFromProto<P>: Sized {
176 /// Convert a proto message into its Rust representation.
177 ///
178 /// # Errors
179 /// Returns [`ProtoDecodeError::ViaString`] when a `via_string` field carries a
180 /// value its `FromStr` impl rejects, and [`ProtoDecodeError::MissingMessage`]
181 /// when a required nested message is absent on the wire.
182 fn try_from_proto_wire(proto: P) -> Result<Self, ProtoDecodeError>;
183}
184
185/// A required nested message was absent on the wire.
186///
187/// prost renders every proto3 message field as `Option<T>`, so "required" is a
188/// property of the Rust DTO rather than of the wire. A peer that omits such a field
189/// — an older build, a hand-written client, a corrupted frame — is a decode error
190/// here rather than a zeroed field that fails a predicate much later.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub struct MissingRequiredMessage {
193 /// The DTO field whose required nested message was missing.
194 pub field: &'static str,
195}
196
197impl ::std::fmt::Display for MissingRequiredMessage {
198 fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
199 write!(f, "required nested message `{}` is absent", self.field)
200 }
201}
202
203impl ::std::error::Error for MissingRequiredMessage {}
204
205/// Error returned by the fallible decode path
206/// ([`TryFromProto::try_from_proto_wire`] and the generated inherent
207/// `try_from_proto`).
208///
209/// An enum rather than a single boxed type so a caller can distinguish an absent
210/// required message from a malformed `via_string` field **by variant**, without
211/// downcasting a `Box<dyn Error>`. The two failures want different handling: a
212/// missing message is a wire-shape/version-skew problem, a bad string is a value
213/// problem.
214#[derive(Debug, thiserror::Error)]
215pub enum ProtoDecodeError {
216 /// A `#[proto_bridge(via_string)]` field carried a value its `FromStr`
217 /// rejected.
218 #[error(transparent)]
219 ViaString(#[from] ViaStringParseError),
220 /// A required nested message was absent on the wire.
221 #[error(transparent)]
222 MissingMessage(#[from] MissingRequiredMessage),
223}
224
225/// Logging hook called from generated `From<i32>` impls when the wire value
226/// does not correspond to any known Rust variant.
227///
228/// Centralized here (not inlined into the macro expansion) so SDK crates
229/// that derive `ProtoBridge` do not need a direct `tracing` dependency, and
230/// so the observability behavior can evolve in one place without
231/// re-expanding every macro consumer.
232#[doc(hidden)]
233pub fn log_unknown_enum_discriminant(discriminant: i32, rust_type: &'static str) {
234 tracing::warn!(
235 discriminant,
236 rust_type,
237 "proto bridge: unknown enum discriminant; falling back to Default. \
238 Use `try_from_i32` if the caller needs to detect this case."
239 );
240}