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}