1use serde::{Deserialize, Serialize};
10
11use super::contract::ContractIr;
12use super::validation::ValidationError;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct GrpcBindingIr {
22 pub package: String,
24 pub service: String,
26 pub methods: Vec<GrpcMethodBindingIr>,
28}
29
30impl GrpcBindingIr {
31 #[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#[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 pub method_name: String,
47 pub rpc_name: String,
49 #[serde(default)]
51 pub client_streaming: bool,
52 #[serde(default)]
54 pub server_streaming: bool,
55 pub idempotency_level: GrpcIdempotency,
57 #[serde(default)]
59 pub retryable: bool,
60 #[serde(default)]
63 pub optional: bool,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[non_exhaustive]
69pub enum GrpcIdempotency {
70 NoSideEffects,
72 Idempotent,
74 NotIdempotent,
76}
77
78impl GrpcIdempotency {
79 #[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
90pub 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 let Some(contract_method) = contract.methods.iter().find(|m| m.name == method_name) else {
154 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; 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}