1use std::collections::HashMap;
27
28use crate::ir_nodes::IRFlow;
29
30pub fn bind_request(
54 flow: &IRFlow,
55 path: &HashMap<String, String>,
56 query: &HashMap<String, String>,
57 body: Option<&serde_json::Value>,
58) -> Vec<(String, String)> {
59 let body_fields: Option<&serde_json::Map<String, serde_json::Value>> = match body {
60 Some(serde_json::Value::Object(m)) => Some(m),
61 _ => None,
62 };
63
64 flow.parameters
65 .iter()
66 .filter_map(|param| {
67 if let Some(v) = path.get(¶m.name) {
73 return Some((param.name.clone(), v.clone()));
74 }
75 if let Some(v) = query.get(¶m.name) {
76 return Some((param.name.clone(), v.clone()));
77 }
78 if let Some(fields) = body_fields {
79 if let Some(value) = fields.get(¶m.name) {
80 return Some((param.name.clone(), binding_string(value)));
81 }
82 }
83 None
84 })
85 .collect()
86}
87
88pub fn bind_request_body(
100 flow: &IRFlow,
101 body: Option<&serde_json::Value>,
102) -> Vec<(String, String)> {
103 bind_request(flow, &HashMap::new(), &HashMap::new(), body)
104}
105
106fn binding_string(value: &serde_json::Value) -> String {
118 match value {
119 serde_json::Value::String(s) => s.clone(),
120 serde_json::Value::Null => String::new(),
121 other => other.to_string(),
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::ir_nodes::{IRFlow, IRParameter};
129
130 fn param(name: &str) -> IRParameter {
131 IRParameter {
132 node_type: "parameter",
133 source_line: 0,
134 source_column: 0,
135 name: name.into(),
136 type_name: "String".into(),
137 generic_param: String::new(),
138 optional: false,
139 }
140 }
141
142 fn flow_with_params(names: &[&str]) -> IRFlow {
143 IRFlow {
144 node_type: "flow",
145 source_line: 0,
146 source_column: 0,
147 name: "F".into(),
148 parameters: names.iter().map(|n| param(n)).collect(),
149 return_type_name: "Unit".into(),
150 return_type_generic: String::new(),
151 return_type_optional: false,
152 steps: Vec::new(),
153 edges: Vec::new(),
154 execution_levels: Vec::new(),
155 }
156 }
157
158 #[test]
159 fn binds_each_declared_parameter_by_name() {
160 let flow = flow_with_params(&["message", "tenant_id"]);
161 let body = serde_json::json!({
162 "message": "hello",
163 "tenant_id": "83d078e1-b372-42ba-9572-ff8dc521386e",
164 });
165 let bound = bind_request_body(&flow, Some(&body));
166 assert_eq!(
167 bound,
168 vec![
169 ("message".into(), "hello".into()),
170 (
171 "tenant_id".into(),
172 "83d078e1-b372-42ba-9572-ff8dc521386e".into()
173 ),
174 ],
175 "D1 — each declared parameter binds from its same-named body field"
176 );
177 }
178
179 #[test]
180 fn d4_an_undeclared_body_field_is_not_bound() {
181 let flow = flow_with_params(&["message"]);
182 let body = serde_json::json!({ "message": "hi", "extra": "ignored" });
183 let bound = bind_request_body(&flow, Some(&body));
184 assert_eq!(
185 bound,
186 vec![("message".into(), "hi".into())],
187 "D4 — a body field with no matching declared parameter is \
188 NOT bound; the contract stays tight"
189 );
190 }
191
192 #[test]
193 fn an_uncovered_parameter_simply_does_not_bind() {
194 let flow = flow_with_params(&["message", "session_id"]);
197 let body = serde_json::json!({ "message": "hi" });
198 let bound = bind_request_body(&flow, Some(&body));
199 assert_eq!(bound, vec![("message".into(), "hi".into())]);
200 }
201
202 #[test]
203 fn scalar_values_bind_as_their_string_form() {
204 let flow = flow_with_params(&["s", "n", "b", "z"]);
205 let body = serde_json::json!({
206 "s": "raw", "n": 42, "b": true, "z": null,
207 });
208 let bound = bind_request_body(&flow, Some(&body));
209 assert_eq!(
210 bound,
211 vec![
212 ("s".into(), "raw".into()), ("n".into(), "42".into()), ("b".into(), "true".into()), ("z".into(), String::new()), ]
217 );
218 }
219
220 #[test]
221 fn no_body_or_non_object_body_binds_nothing() {
222 let flow = flow_with_params(&["message"]);
223 assert!(bind_request_body(&flow, None).is_empty());
224 assert!(bind_request_body(&flow, Some(&serde_json::json!("bare"))).is_empty());
225 assert!(bind_request_body(&flow, Some(&serde_json::json!([1, 2]))).is_empty());
226 }
227
228 #[test]
229 fn a_flow_with_no_parameters_binds_nothing() {
230 let flow = flow_with_params(&[]);
231 let body = serde_json::json!({ "message": "hi" });
232 assert!(
233 bind_request_body(&flow, Some(&body)).is_empty(),
234 "D5 — a parameter-less flow is unaffected by any body"
235 );
236 }
237
238 fn map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
243 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
244 }
245
246 #[test]
247 fn d3_path_only_binding() {
248 let flow = flow_with_params(&["tenant_id", "secret_name"]);
249 let path = map(&[
250 ("tenant_id", "acme"),
251 ("secret_name", "api-key"),
252 ]);
253 let bound = bind_request(&flow, &path, &HashMap::new(), None);
254 assert_eq!(
255 bound,
256 vec![
257 ("tenant_id".into(), "acme".into()),
258 ("secret_name".into(), "api-key".into()),
259 ]
260 );
261 }
262
263 #[test]
264 fn d3_query_only_binding() {
265 let flow = flow_with_params(&["status", "limit"]);
266 let query = map(&[("status", "active"), ("limit", "50")]);
267 let bound = bind_request(&flow, &HashMap::new(), &query, None);
268 assert_eq!(
269 bound,
270 vec![
271 ("status".into(), "active".into()),
272 ("limit".into(), "50".into()),
273 ]
274 );
275 }
276
277 #[test]
278 fn d3_mixed_path_query_body() {
279 let flow = flow_with_params(&["tenant_id", "dry_run", "value"]);
280 let path = map(&[("tenant_id", "acme")]);
281 let query = map(&[("dry_run", "true")]);
282 let body = serde_json::json!({ "value": "secret-payload" });
283 let bound = bind_request(&flow, &path, &query, Some(&body));
284 assert_eq!(
285 bound,
286 vec![
287 ("tenant_id".into(), "acme".into()),
288 ("dry_run".into(), "true".into()),
289 ("value".into(), "secret-payload".into()),
290 ],
291 "D3 — each param resolves from its single declared source; \
292 order follows the flow parameter declaration order"
293 );
294 }
295
296 #[test]
297 fn d4_invariant_value_taken_from_earliest_source_in_precedence() {
298 let flow = flow_with_params(&["id"]);
304 let path = map(&[("id", "from-path")]);
305 let query = map(&[("id", "from-query")]);
306 let body = serde_json::json!({ "id": "from-body" });
307 let bound = bind_request(&flow, &path, &query, Some(&body));
308 assert_eq!(bound, vec![("id".into(), "from-path".into())]);
309 }
310
311 #[test]
312 fn d5_bind_request_body_legacy_delegate_byte_identical() {
313 let flow = flow_with_params(&["message", "tenant_id"]);
317 let body = serde_json::json!({
318 "message": "hi",
319 "tenant_id": "acme",
320 });
321 let via_legacy = bind_request_body(&flow, Some(&body));
322 let via_new = bind_request(
323 &flow,
324 &HashMap::new(),
325 &HashMap::new(),
326 Some(&body),
327 );
328 assert_eq!(via_legacy, via_new, "D5 — legacy delegate is byte-identical");
329 }
330
331 #[test]
332 fn d5_empty_inputs_yield_empty_binding() {
333 let flow = flow_with_params(&["x", "y"]);
334 let bound = bind_request(
335 &flow,
336 &HashMap::new(),
337 &HashMap::new(),
338 None,
339 );
340 assert!(bound.is_empty(), "D5 — empty everywhere ⇒ empty binding");
341 }
342
343 #[test]
344 fn d4_undeclared_path_or_query_keys_are_ignored() {
345 let flow = flow_with_params(&["needed"]);
348 let path = map(&[("needed", "v"), ("unrelated_path", "x")]);
349 let query = map(&[("unrelated_query", "y")]);
350 let bound = bind_request(&flow, &path, &query, None);
351 assert_eq!(bound, vec![("needed".into(), "v".into())]);
352 }
353
354 #[test]
355 fn kivi_end_to_end_runtime_binding() {
356 let flow = flow_with_params(&[
361 "tenant_id",
362 "secret_name",
363 "dry_run",
364 "overwrite",
365 "value",
366 ]);
367 let path = map(&[
368 ("tenant_id", "acme-corp"),
369 ("secret_name", "stripe-api-key"),
370 ]);
371 let query = map(&[
372 ("dry_run", "true"),
373 ("overwrite", "false"),
374 ]);
375 let body = serde_json::json!({
376 "value": "sk_live_xxxxx",
377 });
378 let bound = bind_request(&flow, &path, &query, Some(&body));
379 assert_eq!(
380 bound,
381 vec![
382 ("tenant_id".into(), "acme-corp".into()),
383 ("secret_name".into(), "stripe-api-key".into()),
384 ("dry_run".into(), "true".into()),
385 ("overwrite".into(), "false".into()),
386 ("value".into(), "sk_live_xxxxx".into()),
387 ]
388 );
389 }
390}