Skip to main content

toolkit_contract/ir/
grpc.rs

1//! gRPC binding IR — transport-specific projection of a contract for gRPC.
2//!
3//! Mirrors [`crate::ir::binding::HttpBindingIr`] but encodes gRPC-specific
4//! metadata: package, service name, RPC names per method, streaming flags,
5//! `idempotency_level` proto3 method option.
6//!
7//! Lives in the SDK crate (provider-side) — like `HttpBindingIr`.
8
9use serde::{Deserialize, Serialize};
10
11use super::contract::ContractIr;
12use super::validation::ValidationError;
13
14/// gRPC binding projection for a contract.
15///
16/// Deliberately NOT `#[non_exhaustive]` (see
17/// [`super::contract::ContractIr`]'s doc): `#[toolkit::grpc_contract]` emits a
18/// struct-literal `GrpcBindingIr { .. }` into the SDK crate's generated
19/// binding function.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct GrpcBindingIr {
22    /// Proto package, e.g. `service_hub_demo.payment.v1`.
23    pub package: String,
24    /// gRPC service name, e.g. `PaymentApi` (`PascalCase`).
25    pub service: String,
26    /// Per-method gRPC bindings.
27    pub methods: Vec<GrpcMethodBindingIr>,
28}
29
30impl GrpcBindingIr {
31    /// Find the binding for a specific contract method by name.
32    #[must_use]
33    pub fn find_method(&self, method_name: &str) -> Option<&GrpcMethodBindingIr> {
34        self.methods.iter().find(|m| m.method_name == method_name)
35    }
36}
37
38/// gRPC binding for a single method.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[allow(
41    clippy::struct_excessive_bools,
42    reason = "Each bool maps to an independent proto3 method facet (streaming directions, retry policy, optional contract); collapsing them into an enum would conflate orthogonal axes and force serde renames across every consumer of this IR."
43)]
44pub struct GrpcMethodBindingIr {
45    /// Method name from the trait — matches `MethodIr.name` (`snake_case`).
46    pub method_name: String,
47    /// gRPC RPC name (`PascalCase`, e.g. `Charge`).
48    pub rpc_name: String,
49    /// `true` when the client streams a sequence of messages.
50    #[serde(default)]
51    pub client_streaming: bool,
52    /// `true` when the server streams a sequence of messages.
53    #[serde(default)]
54    pub server_streaming: bool,
55    /// proto3 `idempotency_level` method option.
56    pub idempotency_level: GrpcIdempotency,
57    /// Whether the client may auto-retry transient failures.
58    #[serde(default)]
59    pub retryable: bool,
60    /// Whether the underlying contract method has a default body
61    /// (peers MAY omit this RPC).
62    #[serde(default)]
63    pub optional: bool,
64}
65
66/// proto3 `idempotency_level` values.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[non_exhaustive]
69pub enum GrpcIdempotency {
70    /// `NO_SIDE_EFFECTS` — safe read.
71    NoSideEffects,
72    /// `IDEMPOTENT` — repeated calls produce the same result.
73    Idempotent,
74    /// `IDEMPOTENCY_UNKNOWN` (proto3 default) — non-idempotent write.
75    NotIdempotent,
76}
77
78impl GrpcIdempotency {
79    /// proto3 enum-variant identifier (used in generated `.proto` files).
80    #[must_use]
81    pub const fn proto_variant(self) -> &'static str {
82        match self {
83            GrpcIdempotency::NoSideEffects => "NO_SIDE_EFFECTS",
84            GrpcIdempotency::Idempotent => "IDEMPOTENT",
85            GrpcIdempotency::NotIdempotent => "IDEMPOTENCY_UNKNOWN",
86        }
87    }
88}
89
90/// Validate a gRPC binding IR against its corresponding contract IR.
91///
92/// Checks:
93/// - Package and service name must not be empty.
94/// - Every contract method must have a corresponding binding.
95/// - No extra bindings for methods not in the contract.
96/// - No duplicate `rpc_name` values.
97/// - `server_streaming` must agree with `MethodIr.kind == ServerStreaming`.
98///
99/// # Errors
100///
101/// Returns a vector of [`ValidationError`] when one or more checks fail.
102pub fn validate_grpc_binding(
103    contract: &ContractIr,
104    binding: &GrpcBindingIr,
105) -> Result<(), Vec<ValidationError>> {
106    use std::collections::HashSet;
107
108    let mut errors = Vec::new();
109
110    if binding.package.is_empty() {
111        errors.push(ValidationError {
112            location: "GrpcBindingIr".to_owned(),
113            message: "package must not be empty".to_owned(),
114        });
115    }
116    if binding.service.is_empty() {
117        errors.push(ValidationError {
118            location: "GrpcBindingIr".to_owned(),
119            message: "service must not be empty".to_owned(),
120        });
121    }
122
123    let contract_methods: HashSet<&str> =
124        contract.methods.iter().map(|m| m.name.as_str()).collect();
125    let mut binding_methods: HashSet<&str> = HashSet::new();
126    let mut binding_rpc_names: HashSet<&str> = HashSet::new();
127
128    for method_binding in &binding.methods {
129        let method_name = method_binding.method_name.as_str();
130        if !binding_methods.insert(method_name) {
131            errors.push(ValidationError {
132                location: format!("GrpcBindingIr.methods[{method_name}]"),
133                message: format!("duplicate binding for contract method: {method_name}"),
134            });
135        }
136        let rpc_name = method_binding.rpc_name.as_str();
137        if !binding_rpc_names.insert(rpc_name) {
138            errors.push(ValidationError {
139                location: format!("GrpcBindingIr.methods[{method_name}]"),
140                message: format!("duplicate rpc_name: {rpc_name}"),
141            });
142        }
143
144        if !contract_methods.contains(method_name) {
145            errors.push(ValidationError {
146                location: format!("GrpcBindingIr.methods[{method_name}]"),
147                message: format!("binding for unknown method not in contract: {method_name}"),
148            });
149            continue;
150        }
151
152        // server_streaming flag must agree with MethodIr.kind.
153        let Some(contract_method) = contract.methods.iter().find(|m| m.name == method_name) else {
154            // Unreachable: the `contract_methods.contains` check above continues for
155            // any binding whose method is not in the contract. Skip rather than panic.
156            continue;
157        };
158        let kind_streaming = matches!(
159            contract_method.kind,
160            super::contract::MethodKind::ServerStreaming
161        );
162        if kind_streaming != method_binding.server_streaming {
163            errors.push(ValidationError {
164                location: format!("GrpcBindingIr.methods[{method_name}]"),
165                message: format!(
166                    "server_streaming flag ({}) does not match contract MethodKind ({:?})",
167                    method_binding.server_streaming, contract_method.kind,
168                ),
169            });
170        }
171    }
172
173    for name in &contract_methods {
174        if !binding_methods.contains(name) {
175            errors.push(ValidationError {
176                location: format!("GrpcBindingIr.methods[{name}]"),
177                message: format!("missing binding for contract method: {name}"),
178            });
179        }
180    }
181
182    if errors.is_empty() {
183        Ok(())
184    } else {
185        Err(errors)
186    }
187}
188
189#[cfg(test)]
190#[cfg_attr(coverage_nightly, coverage(off))]
191#[allow(clippy::unwrap_used)]
192mod tests {
193    use super::*;
194    use crate::ir::contract::{
195        FieldIr, Idempotency, InputShape, MethodIr, MethodKind, PrimitiveType, ServiceIr, TypeRef,
196    };
197
198    fn sample_contract() -> ContractIr {
199        ServiceIr {
200            name: "PaymentApi".into(),
201            gear: "service-hub-demo".into(),
202            version: "v1".into(),
203            methods: vec![
204                MethodIr {
205                    name: "charge".into(),
206                    kind: MethodKind::Unary,
207                    input: InputShape {
208                        fields: vec![FieldIr {
209                            name: "req".into(),
210                            ty: TypeRef::Named("ChargeRequest".into()),
211                            optional: false,
212                            role: crate::ir::contract::FieldRole::Wire,
213                        }],
214                    },
215                    output: TypeRef::Named("ChargeResponse".into()),
216                    error: Some(TypeRef::Named("PaymentError".into())),
217                    idempotency: Idempotency::NonIdempotentWrite,
218                    optional: false,
219                },
220                MethodIr {
221                    name: "list_payments".into(),
222                    kind: MethodKind::ServerStreaming,
223                    input: InputShape {
224                        fields: vec![FieldIr {
225                            name: "filter".into(),
226                            ty: TypeRef::Primitive(PrimitiveType::String),
227                            optional: false,
228                            role: crate::ir::contract::FieldRole::Wire,
229                        }],
230                    },
231                    output: TypeRef::Named("PaymentSummary".into()),
232                    error: Some(TypeRef::Named("PaymentError".into())),
233                    idempotency: Idempotency::SafeRead,
234                    optional: false,
235                },
236            ],
237        }
238    }
239
240    fn sample_binding() -> GrpcBindingIr {
241        GrpcBindingIr {
242            package: "service_hub_demo.payment.v1".into(),
243            service: "PaymentApi".into(),
244            methods: vec![
245                GrpcMethodBindingIr {
246                    method_name: "charge".into(),
247                    rpc_name: "Charge".into(),
248                    client_streaming: false,
249                    server_streaming: false,
250                    idempotency_level: GrpcIdempotency::NotIdempotent,
251                    retryable: false,
252                    optional: false,
253                },
254                GrpcMethodBindingIr {
255                    method_name: "list_payments".into(),
256                    rpc_name: "ListPayments".into(),
257                    client_streaming: false,
258                    server_streaming: true,
259                    idempotency_level: GrpcIdempotency::NoSideEffects,
260                    retryable: false,
261                    optional: false,
262                },
263            ],
264        }
265    }
266
267    #[test]
268    fn validates_complete_binding() {
269        validate_grpc_binding(&sample_contract(), &sample_binding()).expect("valid");
270    }
271
272    #[test]
273    fn rejects_missing_method_binding() {
274        let mut binding = sample_binding();
275        binding.methods.pop();
276        let errs = validate_grpc_binding(&sample_contract(), &binding).unwrap_err();
277        assert!(
278            errs.iter()
279                .any(|e| e.message.contains("missing binding for contract method"))
280        );
281    }
282
283    #[test]
284    fn rejects_duplicate_rpc_name() {
285        let mut binding = sample_binding();
286        binding.methods[1].rpc_name = "Charge".into();
287        let errs = validate_grpc_binding(&sample_contract(), &binding).unwrap_err();
288        assert!(
289            errs.iter()
290                .any(|e| e.message.contains("duplicate rpc_name"))
291        );
292    }
293
294    #[test]
295    fn rejects_streaming_flag_mismatch() {
296        let mut binding = sample_binding();
297        binding.methods[0].server_streaming = true; // charge is unary
298        let errs = validate_grpc_binding(&sample_contract(), &binding).unwrap_err();
299        assert!(
300            errs.iter()
301                .any(|e| e.message.contains("server_streaming flag"))
302        );
303    }
304
305    #[test]
306    fn rejects_extra_binding() {
307        let mut binding = sample_binding();
308        binding.methods.push(GrpcMethodBindingIr {
309            method_name: "ghost".into(),
310            rpc_name: "Ghost".into(),
311            client_streaming: false,
312            server_streaming: false,
313            idempotency_level: GrpcIdempotency::NotIdempotent,
314            retryable: false,
315            optional: false,
316        });
317        let errs = validate_grpc_binding(&sample_contract(), &binding).unwrap_err();
318        assert!(
319            errs.iter()
320                .any(|e| e.message.contains("binding for unknown method"))
321        );
322    }
323
324    #[test]
325    fn empty_package_or_service_rejected() {
326        let mut binding = sample_binding();
327        binding.package = String::new();
328        let errs = validate_grpc_binding(&sample_contract(), &binding).unwrap_err();
329        assert!(
330            errs.iter()
331                .any(|e| e.message.contains("package must not be empty"))
332        );
333    }
334
335    #[test]
336    fn proto_variant_mapping() {
337        assert_eq!(
338            GrpcIdempotency::NoSideEffects.proto_variant(),
339            "NO_SIDE_EFFECTS"
340        );
341        assert_eq!(GrpcIdempotency::Idempotent.proto_variant(), "IDEMPOTENT");
342        assert_eq!(
343            GrpcIdempotency::NotIdempotent.proto_variant(),
344            "IDEMPOTENCY_UNKNOWN"
345        );
346    }
347}