Skip to main content

axon/
request_binding.rs

1//! §Fase 37 — The Request Binding Contract (runtime delivery).
2//!
3//! An `axonendpoint` declares `body: T` (the typed request body) and
4//! `execute: F` (a flow with declared parameters). The contract: the
5//! request body's fields populate F's parameters — BY NAME (D1).
6//!
7//! §Fase 37.y (v1.38.5) extends the binding-source set: path
8//! placeholders (`/api/users/{id}`) and query params declared via
9//! `query: { name: Type? }` join the body as canonical binding
10//! sources. The compile-time D3 + D4 check (extending Fase 37 D2)
11//! guarantees every flow parameter resolves to EXACTLY ONE source —
12//! collisions are `axon-T901` compile errors — so the runtime merge
13//! order is semantically irrelevant by construction.
14//!
15//! Only DECLARED flow parameters bind (D4): a body field that matches
16//! no parameter is NOT silently injected into the interpolation
17//! scope, so the compile-time totality check (37.c / D2) stays the
18//! single gate on what a `${x}` can resolve to — a typo'd `${tenat}`
19//! is a missing binding, never a silently-empty surprise.
20//!
21//! This module is the runtime delivery, consumed by BOTH execution
22//! paths — the streaming dispatcher (`DispatchCtx.let_bindings`) and
23//! the synchronous runner (`ExecContext`) — so an `axonendpoint`'s
24//! `transport: sse` and `transport: json` routes bind identically.
25
26use std::collections::HashMap;
27
28use crate::ir_nodes::IRFlow;
29
30/// §Fase 37.y — Bind a request to a flow's declared parameters across
31/// THREE binding sources: path placeholders (URL captures), query
32/// string params, and a parsed JSON body.
33///
34/// For each parameter `p` of `flow`, the binder searches the three
35/// maps in declaration-source precedence (D4 guarantees there is
36/// AT MOST ONE source via compile-time `axon-T901`):
37///
38///  1. `path` — `HashMap<String, String>` (URL path placeholder
39///     captures; values are URL-decoded raw text per HTTP convention).
40///  2. `query` — `HashMap<String, String>` (URL query string; the
41///     adopter passes the first value for multi-value keys per
42///     v1.38.5 honest-scope semantics).
43///  3. `body` — `Option<&Value>` (the parsed JSON body; the v1.36.0
44///     surface, unchanged).
45///
46/// The result is ordered by the flow's parameter declaration order —
47/// deterministic for tests and the 37.g property pass.
48///
49/// Empty `path` + empty `query` + `None` body is a no-op (D5
50/// backwards-compat: callers that didn't pass path/query before
51/// v1.38.5 use `bind_request_body` which delegates here with empty
52/// maps; the result is byte-identical to the pre-37.y behavior).
53pub 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            // Source precedence (D4 invariant — by construction the
68            // value is in AT MOST one source; the lookup order is
69            // documentation, not semantics). Path values are already
70            // text; query values are already text; body values
71            // stringify per `binding_string`.
72            if let Some(v) = path.get(&param.name) {
73                return Some((param.name.clone(), v.clone()));
74            }
75            if let Some(v) = query.get(&param.name) {
76                return Some((param.name.clone(), v.clone()));
77            }
78            if let Some(fields) = body_fields {
79                if let Some(value) = fields.get(&param.name) {
80                    return Some((param.name.clone(), binding_string(value)));
81                }
82            }
83            None
84        })
85        .collect()
86}
87
88/// §Fase 37 — Legacy body-only binder. Delegates to [`bind_request`]
89/// with empty path + empty query maps. Preserved for source
90/// backwards-compat with v1.36.0-style callers (test code,
91/// non-axon-server programmatic consumers); D5 absolute guarantees
92/// the return is byte-identical to the v1.36.0 implementation when
93/// path + query are empty.
94///
95/// `body` is `None` (or a non-object JSON value) for a request with
96/// no body, or a body that is a bare scalar / array — in every such
97/// case the binding is empty and the flow runs with whatever bindings
98/// its own `let` statements and step outputs produce.
99pub 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
106/// Stringify a JSON value for the `String`-valued interpolation map
107/// (`${name}` substitution is textual).
108///
109/// A JSON string binds to its raw contents (no surrounding quotes —
110/// the value, not its JSON literal); `null` binds to the empty
111/// string; a number / boolean binds to its canonical JSON form
112/// (`42`, `true`). An array / object binds to its compact JSON form —
113/// a structured parameter is honest future scope (the 37.c totality
114/// check names a structured parameter explicitly rather than this
115/// path binding it silently), but binding the compact JSON keeps the
116/// function total and panic-free over every parsed body.
117fn 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        // D2 (37.c) makes this a compile error; at runtime the binding
195        // is just absent — never a panic.
196        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()),   // string: no quotes
213                ("n".into(), "42".into()),    // number: canonical
214                ("b".into(), "true".into()),  // bool: canonical
215                ("z".into(), String::new()),  // null: empty
216            ]
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    // ═══════════════════════════════════════════════════════════════
239    //  §Fase 37.y — new 3-source `bind_request` tests
240    // ═══════════════════════════════════════════════════════════════
241
242    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        // The compile-time D4 check makes multi-source declaration a
299        // build error (axon-T901). At runtime, even if a caller
300        // accidentally provided overlapping maps, the binder picks
301        // path > query > body. This test documents the order; in
302        // practice the maps cannot overlap by construction.
303        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        // The legacy `bind_request_body` MUST produce the exact same
314        // result as the v1.36.0 implementation — empty path + empty
315        // query maps means the new binder reduces to the old one.
316        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        // A caller passing extra keys NOT in the flow signature: those
346        // keys are silently ignored. Mirrors the body-side D4 invariant.
347        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        // The kivi corpus at runtime: tenant_id + secret_name from
357        // URL path captures, dry_run + overwrite from query, value
358        // from body. Five declared flow params, three binding
359        // sources, no collisions.
360        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}