Skip to main content

bynk_emit/emitter/
contracts.rs

1//! `bynk-contracts.json` generation per Worker (v0.177, #643).
2//!
3//! The contract hash a context's Worker entry compares an incoming
4//! `X-Bynk-Contract` against, per `on call` service — the same constants the
5//! entry stamps, written where the **driver** can read them.
6//!
7//! Why the driver needs them: the runtime check fails closed, but it does so in
8//! *production*, on live traffic, after the skewed pair is already deployed.
9//! `deploy` can know sooner. It records each context's hashes when it pushes,
10//! so a later `deploy --context A` can compare A's compiled view of its
11//! dependencies against what those dependencies actually have live, and refuse
12//! before the push rather than let requests 409 (ADR 0193's D4 gate already
13//! checks a dependency *exists*; this extends it to *matches*).
14//!
15//! Why a file rather than an API: the driver has two compile paths, and under a
16//! `bynkc` override the compiler is a child process handing back an exit status
17//! — there is no in-memory model to consult. A fact the compiler knows must
18//! reach the driver in the build output, or not at all (ADR 0195 D5, the same
19//! reasoning as `bynk-secrets.json`).
20
21use std::collections::BTreeMap;
22
23use bynk_project::json_string;
24
25/// The file the driver reads, beside each Worker's `wrangler.toml`.
26pub const CONTRACTS_MANIFEST: &str = "bynk-contracts.json";
27
28/// The manifest schema version. Bumped only by a breaking shape change; the
29/// driver refuses a version it does not know rather than guessing, as the
30/// deploy ledger and the secrets manifest do.
31pub(crate) const MANIFEST_VERSION: u32 = 1;
32
33/// Render a context's contract manifest: what it **provides**, and what it
34/// **expects** of each context it consumes.
35///
36/// Both halves are needed, and they are not the same fact:
37///
38/// - `provides` is this context's own constant per `on call` service — what its
39///   entry enforces. `deploy` records it when the Worker is pushed, so the
40///   ledger knows what is *live*.
41/// - `expects` is this context's compiled view of each dependency's contract —
42///   the constant it stamps at each call site. `deploy` compares it against the
43///   ledger's `provides` for that dependency, which is exactly the runtime check
44///   moved earlier.
45///
46/// A gate built on `provides` alone could not work: it would compare a context
47/// against itself.
48///
49/// Absent rather than empty when a context neither exposes nor calls an `on
50/// call` service: a project that never crosses a context boundary must not grow
51/// a file into every worker directory for a feature it does not use — and "no
52/// file" is the same answer as "empty" to a driver that must tolerate a build
53/// tree from a compiler predating this file anyway.
54pub(crate) fn emit_contracts_manifest(
55    provides: &BTreeMap<String, String>,
56    expects: &BTreeMap<String, BTreeMap<String, String>>,
57) -> Option<String> {
58    if provides.is_empty() && expects.is_empty() {
59        return None;
60    }
61    // Hand-rendered rather than via serde: this crate does not depend on
62    // serde_json, and the shape is three fields.
63    let expects_body: Vec<String> = expects
64        .iter()
65        .map(|(ctx, svcs)| {
66            let inner: Vec<String> = svcs
67                .iter()
68                .map(|(svc, h)| format!("      {}: {}", json_string(svc), json_string(h)))
69                .collect();
70            format!(
71                "    {}: {{\n{}\n    }}",
72                json_string(ctx),
73                inner.join(",\n")
74            )
75        })
76        .collect();
77    Some(format!(
78        "{{\n  \"version\": {MANIFEST_VERSION},\n  \"provides\": {},\n  \"expects\": {}\n}}\n",
79        json_map(provides, 2),
80        if expects_body.is_empty() {
81            "{}".to_string()
82        } else {
83            format!("{{\n{}\n  }}", expects_body.join(",\n"))
84        }
85    ))
86}
87
88fn json_map(m: &BTreeMap<String, String>, indent: usize) -> String {
89    if m.is_empty() {
90        return "{}".to_string();
91    }
92    let pad = " ".repeat(indent + 2);
93    let close = " ".repeat(indent);
94    let entries: Vec<String> = m
95        .iter()
96        .map(|(k, v)| format!("{pad}{}: {}", json_string(k), json_string(v)))
97        .collect();
98    format!("{{\n{}\n{close}}}", entries.join(",\n"))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn absent_when_a_context_neither_provides_nor_consumes() {
107        assert_eq!(
108            emit_contracts_manifest(&BTreeMap::new(), &BTreeMap::new()),
109            None
110        );
111    }
112
113    #[test]
114    fn renders_sorted_service_hashes() {
115        let mut h = BTreeMap::new();
116        h.insert("whoami".to_string(), "317bdd3de84d2176".to_string());
117        h.insert("ask".to_string(), "0011223344556677".to_string());
118        let out = emit_contracts_manifest(&h, &BTreeMap::new()).unwrap();
119        assert!(out.contains("\"version\": 1"));
120        // BTreeMap ordering: the file is byte-stable across builds, which the
121        // golden fixtures depend on.
122        let ask = out.find("\"ask\"").unwrap();
123        let whoami = out.find("\"whoami\"").unwrap();
124        assert!(ask < whoami, "services render in sorted order:\n{out}");
125    }
126
127    #[test]
128    fn a_consumer_records_what_it_expects_of_each_dependency() {
129        let mut inner = BTreeMap::new();
130        inner.insert("whoami".to_string(), "317bdd3de84d2176".to_string());
131        let mut expects = BTreeMap::new();
132        expects.insert("app.b".to_string(), inner);
133        let out = emit_contracts_manifest(&BTreeMap::new(), &expects).unwrap();
134        assert!(out.contains("\"app.b\""), "{out}");
135        assert!(out.contains("\"whoami\": \"317bdd3de84d2176\""), "{out}");
136        assert!(out.contains("\"provides\": {}"), "{out}");
137    }
138}