hecks 3.7.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
  3. data/lib/hecks/bluebook/meta_validator.rb +4 -2
  4. data/lib/hecks/corpus/routes.rb +1 -1
  5. data/lib/hecks/gate/stages.yml +11 -1
  6. data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
  7. data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
  8. data/lib/hecks/language/bluebook/vocabulary.bluebook +1 -1
  9. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +1 -1
  10. data/lib/hecks/projections/site/probe.rb +131 -0
  11. data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
  12. data/lib/hecks/runtime/value/validation.rb +34 -3
  13. data/lib/hecks/site/bluebook/site.bluebook +31 -0
  14. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  15. data/lib/hecks/version.rb +1 -1
  16. data/lib/hecks/vocabulary.rb +1 -1
  17. data/prebuilt/{verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json → verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json} +1 -1
  18. data/rust/codegen/src/fielded.rs +3 -3
  19. data/rust/codegen/src/json_codec.rs +2 -2
  20. data/rust/codegen/src/naming.rs +20 -0
  21. data/rust/host/Cargo.toml +4 -0
  22. data/rust/host/HECKS_RELEASE +1 -1
  23. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  24. data/rust/host/src/commerce_ir.rs +459 -0
  25. data/rust/host/src/extension.rs +100 -0
  26. data/rust/host/src/ir.rs +0 -448
  27. data/rust/host/src/journal.rs +1 -1
  28. data/rust/host/src/lib.rs +39 -0
  29. data/rust/host/src/main.rs +6 -45
  30. data/rust/host/src/mint.rs +1 -1
  31. data/rust/host/src/payments/tests.rs +8 -8
  32. data/rust/host/src/payments.rs +10 -10
  33. data/rust/host/src/rate_limit.rs +27 -41
  34. data/rust/host/src/web/commerce.rs +113 -0
  35. data/rust/host/src/web/newsletter.rs +2 -1
  36. data/rust/host/src/web/newsletter_send.rs +1 -1
  37. data/rust/host/src/web/registration_receipt.rs +2 -2
  38. data/rust/host/src/web/registrations.rs +49 -49
  39. data/rust/host/src/web/resend_webhook.rs +299 -0
  40. data/rust/host/src/web.rs +43 -47
  41. metadata +12 -5
  42. /data/prebuilt/{945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal → 4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal} +0 -0
  43. /data/prebuilt/{a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal → 911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal} +0 -0
@@ -30,7 +30,7 @@ pub fn emit_fielded_flat(exemplar: &Exemplar, struct_name: &str, attributes: &[J
30
30
  let scalar = scalar.unwrap();
31
31
  Some(exemplar.render(
32
32
  "fielded_arm_optional_scalar",
33
- &[("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident.clone()), ("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, "v").unwrap())],
33
+ &[("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident.clone()), ("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, &naming::deref_scalar(scalar, "v")).unwrap())],
34
34
  ))
35
35
  } else if optional {
36
36
  if is_fielded_nested(crate::attr::type_name(attr), value_objects_by_name) {
@@ -97,7 +97,7 @@ pub fn emit_fielded_record(exemplar: &Exemplar, aggregate: &Json, value_objects_
97
97
  } else if let Some(scalar) = scalar {
98
98
  Some(exemplar.render(
99
99
  "fielded_arm_optional_scalar",
100
- &[("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident.clone()), ("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, "v").unwrap())],
100
+ &[("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident.clone()), ("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, &naming::deref_scalar(scalar, "v")).unwrap())],
101
101
  ))
102
102
  } else if is_fielded_nested(crate::attr::type_name(attr), value_objects_by_name) {
103
103
  Some(exemplar.render("fielded_arm_optional_nested", &[("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident)]))
@@ -184,7 +184,7 @@ fn items_arms(exemplar: &Exemplar, attributes: &[Json], value_objects_by_name: &
184
184
  let shape = format!("fielded_items_arm_list_{}{}", if optional(attr) { "optional_" } else { "" }, if scalar.is_some() { "scalar" } else { "nested" });
185
185
  let mut subs: Vec<(&str, String)> = vec![("\"tmpl_field\"", naming::ruby_inspect_string(&key)), ("tmpl_ident", ident)];
186
186
  if let Some(scalar) = scalar {
187
- subs.push(("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, "v").unwrap()));
187
+ subs.push(("tmpl_value_expr_placeholder(v)", naming::scalar_to_value(scalar, &naming::deref_scalar(scalar, "v")).unwrap()));
188
188
  }
189
189
  arms.push(format!(" {}", exemplar.render(&shape, &subs)));
190
190
  }
@@ -130,7 +130,7 @@ pub fn list_element_from_json_mapper(struct_name: &str, key: &str, attr: &Json,
130
130
  /// Inverse of `list_element_from_json_mapper`: scalar elements have no `.to_json()` method.
131
131
  pub fn list_element_to_json_expr(attr: &Json) -> String {
132
132
  match naming::effective_scalar_type(crate::attr::type_name(attr)) {
133
- Some(scalar) => scalar_to_json_expr(scalar, "x"),
133
+ Some(scalar) => scalar_to_json_expr(scalar, &naming::deref_scalar(scalar, "x")),
134
134
  None => "x.to_json()".to_string(),
135
135
  }
136
136
  }
@@ -392,7 +392,7 @@ pub fn emit_to_json_flat(exemplar: &Exemplar, struct_name: &str, attributes: &[J
392
392
  } else if list {
393
393
  format!("crate::kernel::Json::Array(self.{ident}.iter().map(|x| {}).collect())", elem_to_json.as_deref().unwrap())
394
394
  } else if field_optional && scalar.is_some() {
395
- format!("self.{ident}.as_ref().map(|v| {}).unwrap_or(crate::kernel::Json::Null)", scalar_to_json_expr(scalar.unwrap(), "v"))
395
+ format!("self.{ident}.as_ref().map(|v| {}).unwrap_or(crate::kernel::Json::Null)", scalar_to_json_expr(scalar.unwrap(), &naming::deref_scalar(scalar.unwrap(), "v")))
396
396
  } else if field_optional {
397
397
  format!("self.{ident}.as_ref().map(|v| v.to_json()).unwrap_or(crate::kernel::Json::Null)")
398
398
  } else if let Some(scalar) = scalar {
@@ -248,6 +248,16 @@ pub fn screaming_snake(name: &str) -> String {
248
248
  out.to_uppercase()
249
249
  }
250
250
 
251
+ /// The expression for a scalar reached through a reference (`v` in `as_ref().map(|v| ..)`, `x` in
252
+ /// `iter().map(|x| ..)`): the copy types are dereferenced, since `Value::Int(v)` takes an `i64`, not an
253
+ /// `&i64`; a `String` is left to the `.clone()` the scalar helpers already apply.
254
+ pub fn deref_scalar(type_name: &str, ident: &str) -> String {
255
+ match type_name {
256
+ "Integer" | "Float" | "TrueClass" | "FalseClass" => format!("*{ident}"),
257
+ _ => ident.to_string(),
258
+ }
259
+ }
260
+
251
261
  pub fn scalar_to_value(type_name: &str, rust_expr: &str) -> Option<String> {
252
262
  match type_name {
253
263
  "String" => Some(format!("Value::Str({rust_expr}.clone())")),
@@ -316,6 +326,16 @@ pub fn ruby_inspect_string(s: &str) -> String {
316
326
  mod tests {
317
327
  use super::*;
318
328
 
329
+ #[test]
330
+ fn a_scalar_reached_through_a_reference_is_dereferenced_when_it_is_a_copy_type() {
331
+ // `self.level.as_ref().map(|v| Field::Value(Value::Int(v)))` does not compile: `v` is `&i64`.
332
+ assert_eq!(scalar_to_value("Integer", &deref_scalar("Integer", "v")).as_deref(), Some("Value::Int(*v)"));
333
+ assert_eq!(scalar_to_value("Float", &deref_scalar("Float", "v")).as_deref(), Some("Value::Float(*v)"));
334
+ assert_eq!(scalar_to_value("TrueClass", &deref_scalar("TrueClass", "v")).as_deref(), Some("Value::Bool(*v)"));
335
+ // A String is cloned, so the reference is left alone.
336
+ assert_eq!(scalar_to_value("String", &deref_scalar("String", "v")).as_deref(), Some("Value::Str(v.clone())"));
337
+ }
338
+
319
339
  #[test]
320
340
  fn refuses_an_aggregate_whose_module_name_is_a_rust_keyword() {
321
341
  let refusal = reserved_name_refusal("shop", "shop", &["Pizza", "Match", "Type"]).expect("refused");
data/rust/host/Cargo.toml CHANGED
@@ -6,6 +6,10 @@ edition = "2021"
6
6
  # Empty table: makes this crate its own workspace root instead of joining `rust`'s.
7
7
  [workspace]
8
8
 
9
+ [lib]
10
+ name = "rust_host"
11
+ path = "src/lib.rs"
12
+
9
13
  [[bin]]
10
14
  name = "bootstrap"
11
15
  path = "src/main.rs"
@@ -1 +1 @@
1
- 3.7.0
1
+ 3.9.0
@@ -3,6 +3,7 @@
3
3
  //! a panic.
4
4
 
5
5
  use super::*;
6
+ use crate::commerce_ir::*;
6
7
  use crate::fuzz_support::*;
7
8
  use serde_json::json;
8
9
 
@@ -0,0 +1,459 @@
1
+ // The commerce capability bindings (newsletter, payments, registrations, payment connection), read
2
+ // from `ir.json`'s own keys. Split out of `ir.rs` so the commerce modules that use them move as one.
3
+
4
+ use crate::ir::ir;
5
+ use serde_json::Value;
6
+
7
+ /// The chapter that answers the guest newsletter signup, read from
8
+ /// `ir.json`'s own `newsletter` key. `None` when nothing provides it.
9
+ #[derive(Debug, Clone, PartialEq)]
10
+ pub struct NewsletterProvider {
11
+ /// Chapter name, e.g. `Newsletter`.
12
+ pub provider: String,
13
+ /// Qualified subscribe command, e.g. `Newsletter::Subscriber.Subscribe`.
14
+ pub subscribe: String,
15
+ /// Qualified add-name command, e.g. `Newsletter::Subscriber.AddName`.
16
+ pub add_name: String,
17
+ /// Qualified confirm command, e.g. `Newsletter::Subscriber.Confirm`.
18
+ pub confirm: String,
19
+ /// Qualified unsubscribe command, e.g. `Newsletter::Subscriber.Unsubscribe`.
20
+ pub unsubscribe: String,
21
+ /// Qualified subscribing aggregate, e.g. `Newsletter::Subscriber`.
22
+ pub aggregate: String,
23
+ }
24
+
25
+ impl NewsletterProvider {
26
+ /// The prefix every subscriber's key in a `dispatch::read` `instances`
27
+ /// map starts with, e.g. `Newsletter::Subscriber#`.
28
+ pub fn instance_prefix(&self) -> String {
29
+ format!("{}#", self.aggregate)
30
+ }
31
+ }
32
+
33
+ /// Whether the domain's own `aggregate`'s `command` declares an argument
34
+ /// named `field` — so a route only sends an argument the command declares.
35
+ pub fn command_declares(domain_ir: &Value, aggregate: &str, command: &str, field: &str) -> bool {
36
+ let named = |value: &Value, name: &str| value.get("name").and_then(|n| n.as_str()) == Some(name);
37
+ let Some(aggregates) = domain_ir.get("aggregates").and_then(|a| a.as_array()) else {
38
+ return false;
39
+ };
40
+ aggregates
41
+ .iter()
42
+ .filter(|a| named(a, aggregate))
43
+ .filter_map(|a| a.get("commands").and_then(|c| c.as_array()))
44
+ .flatten()
45
+ .filter(|c| named(c, command))
46
+ .filter_map(|c| c.get("attributes").and_then(|a| a.as_array()))
47
+ .flatten()
48
+ .any(|attribute| named(attribute, field))
49
+ }
50
+
51
+ pub fn newsletter_provider(domain_ir: &Value) -> Option<NewsletterProvider> {
52
+ let fact = domain_ir.get("newsletter")?;
53
+ Some(NewsletterProvider {
54
+ provider: fact.get("provider")?.as_str()?.to_string(),
55
+ subscribe: fact.get("subscribe")?.as_str()?.to_string(),
56
+ add_name: fact.get("add_name")?.as_str()?.to_string(),
57
+ confirm: fact.get("confirm")?.as_str()?.to_string(),
58
+ unsubscribe: fact.get("unsubscribe")?.as_str()?.to_string(),
59
+ aggregate: fact.get("aggregate")?.as_str()?.to_string(),
60
+ })
61
+ }
62
+
63
+ /// The chapter that declares `provides "newsletter_issues"`, read from
64
+ /// `ir.json`'s own `newsletter_issues` key. Omitted when nothing declares it.
65
+ #[derive(Debug, Clone, PartialEq)]
66
+ pub struct NewsletterIssuesProvider {
67
+ /// Qualified send command, e.g. `Newsletter::Issue.Send`.
68
+ pub send_issue: String,
69
+ /// Qualified delivery-record command, e.g. `Newsletter::Delivery.Record`.
70
+ pub record_delivery: String,
71
+ /// Qualified issue aggregate, e.g. `Newsletter::Issue`.
72
+ pub issue_aggregate: String,
73
+ }
74
+
75
+ impl NewsletterIssuesProvider {
76
+ /// The prefix every issue's key in a `dispatch::read` `instances` map
77
+ /// starts with, e.g. `Newsletter::Issue#`.
78
+ pub fn issue_prefix(&self) -> String {
79
+ format!("{}#", self.issue_aggregate)
80
+ }
81
+ }
82
+
83
+ /// The declared issue-sending verbs, or `None` when the IR has no
84
+ /// `newsletter_issues` key.
85
+ pub fn newsletter_issues_provider(domain_ir: &Value) -> Option<NewsletterIssuesProvider> {
86
+ let fact = domain_ir.get("newsletter_issues")?;
87
+ Some(NewsletterIssuesProvider {
88
+ send_issue: fact.get("send_issue")?.as_str()?.to_string(),
89
+ record_delivery: fact.get("record_delivery")?.as_str()?.to_string(),
90
+ issue_aggregate: fact.get("issue_aggregate")?.as_str()?.to_string(),
91
+ })
92
+ }
93
+
94
+
95
+ /// The chapter that takes payments, read from `ir.json`'s own `payments`
96
+ /// key. `None` when nothing provides it.
97
+ #[derive(Debug, Clone, PartialEq)]
98
+ pub struct PaymentsProvider {
99
+ /// Chapter name, e.g. `Payments`.
100
+ pub provider: String,
101
+ /// Qualified initiate command, e.g. `Payments::Payment.Initiate`.
102
+ pub initiate: String,
103
+ /// Qualified port operation the processor's success report dispatches,
104
+ /// e.g. `Payments::Payment.PaymentGateway.Succeeded`.
105
+ pub succeeded: String,
106
+ /// Qualified port operation the processor's failure report dispatches,
107
+ /// e.g. `Payments::Payment.PaymentGateway.Failed`.
108
+ pub failed: String,
109
+ /// Qualified paying aggregate, e.g. `Payments::Payment`.
110
+ pub aggregate: String,
111
+ }
112
+
113
+ impl PaymentsProvider {
114
+ /// The prefix every payment's key in a `dispatch::read` `instances` map
115
+ /// starts with, e.g. `Payments::Payment#`.
116
+ pub fn instance_prefix(&self) -> String {
117
+ format!("{}#", self.aggregate)
118
+ }
119
+ }
120
+
121
+ pub fn payments_provider(domain_ir: &Value) -> Option<PaymentsProvider> {
122
+ let fact = domain_ir.get("payments")?;
123
+ Some(PaymentsProvider {
124
+ provider: fact.get("provider")?.as_str()?.to_string(),
125
+ initiate: fact.get("initiate")?.as_str()?.to_string(),
126
+ succeeded: fact.get("succeeded")?.as_str()?.to_string(),
127
+ failed: fact.get("failed")?.as_str()?.to_string(),
128
+ aggregate: fact.get("aggregate")?.as_str()?.to_string(),
129
+ })
130
+ }
131
+
132
+ /// The chapter that answers scheduling sessions and taking registrations,
133
+ /// read from `ir.json`'s own `registrations` key. `None` when nothing provides it.
134
+ #[derive(Debug, Clone, PartialEq)]
135
+ pub struct RegistrationsProvider {
136
+ /// Chapter name, e.g. `Studio`.
137
+ pub provider: String,
138
+ /// Qualified schedule command, e.g. `Studio::Event.Schedule`.
139
+ pub schedule: String,
140
+ /// Qualified request command, e.g. `Studio::Registration.Request`.
141
+ pub request: String,
142
+ /// Qualified event aggregate, e.g. `Studio::Event`.
143
+ pub event_aggregate: String,
144
+ /// Qualified registration aggregate, e.g. `Studio::Registration`.
145
+ pub registration_aggregate: String,
146
+ }
147
+
148
+ impl RegistrationsProvider {
149
+ /// The conventional names for a domain that declares no
150
+ /// `provides "registrations"` — `Event`/`Registration` under the domain itself.
151
+ pub fn conventional(domain: &str) -> Self {
152
+ Self {
153
+ provider: domain.to_string(),
154
+ schedule: format!("{domain}::Event.Schedule"),
155
+ request: format!("{domain}::Registration.Request"),
156
+ event_aggregate: format!("{domain}::Event"),
157
+ registration_aggregate: format!("{domain}::Registration"),
158
+ }
159
+ }
160
+
161
+ /// The prefix every event's key in a `dispatch::read` `instances` map
162
+ /// starts with, e.g. `Studio::Event#`.
163
+ pub fn event_prefix(&self) -> String {
164
+ format!("{}#", self.event_aggregate)
165
+ }
166
+
167
+ /// The prefix every registration's key starts with, e.g.
168
+ /// `Studio::Registration#`.
169
+ pub fn registration_prefix(&self) -> String {
170
+ format!("{}#", self.registration_aggregate)
171
+ }
172
+
173
+ /// The request command's own aggregate and command name without the
174
+ /// chapter qualifier, e.g. `("Registration", "Request")`, as `command_declares` expects.
175
+ pub fn request_target(&self) -> Option<(&str, &str)> {
176
+ self.request.rsplit("::").next()?.split_once('.')
177
+ }
178
+ }
179
+
180
+ pub fn registrations_provider(domain_ir: &Value) -> Option<RegistrationsProvider> {
181
+ let fact = domain_ir.get("registrations")?;
182
+ Some(RegistrationsProvider {
183
+ provider: fact.get("provider")?.as_str()?.to_string(),
184
+ schedule: fact.get("schedule")?.as_str()?.to_string(),
185
+ request: fact.get("request")?.as_str()?.to_string(),
186
+ event_aggregate: fact.get("event_aggregate")?.as_str()?.to_string(),
187
+ registration_aggregate: fact.get("registration_aggregate")?.as_str()?.to_string(),
188
+ })
189
+ }
190
+
191
+ /// The registrations binding of the IR this host loaded, or the
192
+ /// conventional names for `domain` when the IR declares none.
193
+ pub fn registrations_binding(domain: &str) -> RegistrationsProvider {
194
+ ir().and_then(registrations_provider).unwrap_or_else(|| RegistrationsProvider::conventional(domain))
195
+ }
196
+
197
+ /// One command of the payment-processor connection, by the role it plays —
198
+ /// resolved through `PaymentConnectionProvider::verb`.
199
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
200
+ pub enum ConnectionVerb {
201
+ Connect,
202
+ Reconnect,
203
+ Disconnect,
204
+ Suspend,
205
+ Resume,
206
+ Enable,
207
+ Disable,
208
+ }
209
+
210
+ /// The chapter that owns the business's payment-processor connection, read
211
+ /// from `ir.json`'s own `payment_connection` key. `None` when nothing provides it.
212
+ #[derive(Debug, Clone, PartialEq)]
213
+ pub struct PaymentConnectionProvider {
214
+ /// Chapter name, e.g. `Studio`.
215
+ pub provider: String,
216
+ pub connect: String,
217
+ pub reconnect: String,
218
+ pub disconnect: String,
219
+ pub suspend: String,
220
+ pub resume: String,
221
+ pub enable: String,
222
+ pub disable: String,
223
+ /// Qualified connection aggregate, e.g. `Studio::PaymentConnection`.
224
+ pub aggregate: String,
225
+ }
226
+
227
+ impl PaymentConnectionProvider {
228
+ /// The conventional names for a domain that declares no
229
+ /// `provides "payment_connection"` — `PaymentConnection` under the domain itself.
230
+ pub fn conventional(domain: &str) -> Self {
231
+ let qualified = |command: &str| format!("{domain}::PaymentConnection.{command}");
232
+ Self {
233
+ provider: domain.to_string(),
234
+ connect: qualified("Connect"),
235
+ reconnect: qualified("Reconnect"),
236
+ disconnect: qualified("Disconnect"),
237
+ suspend: qualified("Suspend"),
238
+ resume: qualified("Resume"),
239
+ enable: qualified("EnablePayments"),
240
+ disable: qualified("DisablePayments"),
241
+ aggregate: format!("{domain}::PaymentConnection"),
242
+ }
243
+ }
244
+
245
+ /// The prefix every connection's key in a `dispatch::read` `instances`
246
+ /// map starts with, e.g. `Studio::PaymentConnection#`.
247
+ pub fn instance_prefix(&self) -> String {
248
+ format!("{}#", self.aggregate)
249
+ }
250
+
251
+ /// The qualified command that plays `which`.
252
+ pub fn verb(&self, which: ConnectionVerb) -> &str {
253
+ match which {
254
+ ConnectionVerb::Connect => &self.connect,
255
+ ConnectionVerb::Reconnect => &self.reconnect,
256
+ ConnectionVerb::Disconnect => &self.disconnect,
257
+ ConnectionVerb::Suspend => &self.suspend,
258
+ ConnectionVerb::Resume => &self.resume,
259
+ ConnectionVerb::Enable => &self.enable,
260
+ ConnectionVerb::Disable => &self.disable,
261
+ }
262
+ }
263
+ }
264
+
265
+ pub fn payment_connection_provider(domain_ir: &Value) -> Option<PaymentConnectionProvider> {
266
+ let fact = domain_ir.get("payment_connection")?;
267
+ let text = |key: &str| Some(fact.get(key)?.as_str()?.to_string());
268
+ Some(PaymentConnectionProvider {
269
+ provider: text("provider")?,
270
+ connect: text("connect")?,
271
+ reconnect: text("reconnect")?,
272
+ disconnect: text("disconnect")?,
273
+ suspend: text("suspend")?,
274
+ resume: text("resume")?,
275
+ enable: text("enable")?,
276
+ disable: text("disable")?,
277
+ aggregate: text("aggregate")?,
278
+ })
279
+ }
280
+
281
+ /// The payment-connection binding of the IR this host loaded, or the
282
+ /// conventional names for `domain` when the IR declares none.
283
+ pub fn payment_connection_binding(domain: &str) -> PaymentConnectionProvider {
284
+ ir().and_then(payment_connection_provider).unwrap_or_else(|| PaymentConnectionProvider::conventional(domain))
285
+ }
286
+
287
+ /// The checkout fixture's own payments binding, read from its committed
288
+ /// `ir.json` — what the checkout routes' tests dispatch against.
289
+ #[cfg(test)]
290
+ pub fn fixture_ir() -> Value {
291
+ let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/generated/checkout_fixture/ir.json");
292
+ serde_json::from_str(&std::fs::read_to_string(path).expect("checkout_fixture ir.json")).expect("valid json")
293
+ }
294
+
295
+ /// The checkout fixture's own payments binding.
296
+ #[cfg(test)]
297
+ pub fn fixture_payments() -> PaymentsProvider {
298
+ payments_provider(&fixture_ir()).expect("the checkout fixture provides payments")
299
+ }
300
+
301
+ #[cfg(test)]
302
+ mod tests {
303
+ use super::*;
304
+
305
+ #[test]
306
+ fn newsletter_provider_reads_the_declared_capability() {
307
+ let ir = serde_json::json!({
308
+ "name": "Studio",
309
+ "newsletter": {
310
+ "provider": "Newsletter",
311
+ "subscribe": "Newsletter::Subscriber.Subscribe",
312
+ "add_name": "Newsletter::Subscriber.AddName",
313
+ "confirm": "Newsletter::Subscriber.Confirm",
314
+ "unsubscribe": "Newsletter::Subscriber.Unsubscribe",
315
+ "aggregate": "Newsletter::Subscriber"
316
+ }
317
+ });
318
+ let provider = newsletter_provider(&ir).expect("should find newsletter");
319
+ assert_eq!(provider.provider, "Newsletter");
320
+ assert_eq!(provider.subscribe, "Newsletter::Subscriber.Subscribe");
321
+ assert_eq!(provider.add_name, "Newsletter::Subscriber.AddName");
322
+ assert_eq!(provider.confirm, "Newsletter::Subscriber.Confirm");
323
+ assert_eq!(provider.unsubscribe, "Newsletter::Subscriber.Unsubscribe");
324
+ assert_eq!(provider.instance_prefix(), "Newsletter::Subscriber#");
325
+ assert!(newsletter_provider(&serde_json::json!({ "name": "Pizzas" })).is_none());
326
+ }
327
+
328
+ #[test]
329
+ fn newsletter_issues_provider_reads_the_declared_capability() {
330
+ let ir = serde_json::json!({
331
+ "name": "Studio",
332
+ "newsletter_issues": {
333
+ "provider": "Newsletter",
334
+ "send_issue": "Newsletter::Issue.Send",
335
+ "record_delivery": "Newsletter::Delivery.Record",
336
+ "issue_aggregate": "Newsletter::Issue",
337
+ "delivery_aggregate": "Newsletter::Delivery"
338
+ }
339
+ });
340
+ let provider = newsletter_issues_provider(&ir).expect("should find newsletter_issues");
341
+ assert_eq!(provider.send_issue, "Newsletter::Issue.Send");
342
+ assert_eq!(provider.record_delivery, "Newsletter::Delivery.Record");
343
+ assert_eq!(provider.issue_prefix(), "Newsletter::Issue#");
344
+ assert!(newsletter_issues_provider(&serde_json::json!({ "name": "Pizzas" })).is_none());
345
+ }
346
+
347
+ #[test]
348
+ fn payments_provider_reads_the_declared_capability() {
349
+ let ir = serde_json::json!({
350
+ "name": "Studio",
351
+ "payments": {
352
+ "provider": "Payments",
353
+ "initiate": "Payments::Payment.Initiate",
354
+ "succeeded": "Payments::Payment.PaymentGateway.Succeeded",
355
+ "failed": "Payments::Payment.PaymentGateway.Failed",
356
+ "aggregate": "Payments::Payment"
357
+ }
358
+ });
359
+ let provider = payments_provider(&ir).expect("should find payments");
360
+ assert_eq!(provider.provider, "Payments");
361
+ assert_eq!(provider.initiate, "Payments::Payment.Initiate");
362
+ assert_eq!(provider.succeeded, "Payments::Payment.PaymentGateway.Succeeded");
363
+ assert_eq!(provider.failed, "Payments::Payment.PaymentGateway.Failed");
364
+ assert_eq!(provider.instance_prefix(), "Payments::Payment#");
365
+ assert!(payments_provider(&serde_json::json!({ "name": "Pizzas" })).is_none());
366
+ }
367
+
368
+ #[test]
369
+ fn registrations_provider_reads_the_declared_capability() {
370
+ let provider = registrations_provider(&fixture_ir()).expect("the checkout fixture provides registrations");
371
+ assert_eq!(provider.schedule, "CheckoutFixture::Event.Schedule");
372
+ assert_eq!(provider.request, "CheckoutFixture::Registration.Request");
373
+ assert_eq!(provider.event_prefix(), "CheckoutFixture::Event#");
374
+ assert_eq!(provider.registration_prefix(), "CheckoutFixture::Registration#");
375
+ assert_eq!(provider.request_target(), Some(("Registration", "Request")));
376
+ }
377
+
378
+ #[test]
379
+ fn the_conventional_registrations_names_match_what_the_fixture_declares() {
380
+ assert_eq!(registrations_provider(&fixture_ir()), Some(RegistrationsProvider::conventional("CheckoutFixture")));
381
+ }
382
+
383
+ #[test]
384
+ fn a_declared_registrations_binding_names_whatever_the_chapter_calls_things() {
385
+ let ir = serde_json::json!({"registrations": {
386
+ "provider": "Bookings",
387
+ "schedule": "Bookings::Session.Plan",
388
+ "request": "Bookings::Seat.Claim",
389
+ "event_aggregate": "Bookings::Session",
390
+ "registration_aggregate": "Bookings::Seat"
391
+ }});
392
+ let provider = registrations_provider(&ir).expect("declared");
393
+ assert_eq!(provider.event_prefix(), "Bookings::Session#");
394
+ assert_eq!(provider.registration_prefix(), "Bookings::Seat#");
395
+ assert_eq!(provider.request_target(), Some(("Seat", "Claim")));
396
+ }
397
+
398
+ #[test]
399
+ fn registrations_provider_is_none_without_the_key() {
400
+ assert_eq!(registrations_provider(&serde_json::json!({"name": "Pizzas"})), None);
401
+ }
402
+
403
+ #[test]
404
+ fn registrations_binding_falls_back_to_the_conventional_names_when_no_ir_is_loaded() {
405
+ assert_eq!(registrations_binding("Shop"), RegistrationsProvider::conventional("Shop"));
406
+ }
407
+
408
+ #[test]
409
+ fn payment_connection_provider_reads_the_declared_capability() {
410
+ let provider = payment_connection_provider(&fixture_ir()).expect("the checkout fixture provides payment_connection");
411
+ assert_eq!(provider.verb(ConnectionVerb::Connect), "CheckoutFixture::PaymentConnection.Connect");
412
+ assert_eq!(provider.verb(ConnectionVerb::Enable), "CheckoutFixture::PaymentConnection.EnablePayments");
413
+ assert_eq!(provider.verb(ConnectionVerb::Disable), "CheckoutFixture::PaymentConnection.DisablePayments");
414
+ assert_eq!(provider.instance_prefix(), "CheckoutFixture::PaymentConnection#");
415
+ }
416
+
417
+ #[test]
418
+ fn the_conventional_payment_connection_names_match_what_the_fixture_declares() {
419
+ assert_eq!(payment_connection_provider(&fixture_ir()), Some(PaymentConnectionProvider::conventional("CheckoutFixture")));
420
+ }
421
+
422
+ #[test]
423
+ fn a_declared_payment_connection_binding_names_whatever_the_chapter_calls_things() {
424
+ let ir = serde_json::json!({"payment_connection": {
425
+ "provider": "Billing",
426
+ "connect": "Billing::Link.Open", "reconnect": "Billing::Link.Reopen",
427
+ "disconnect": "Billing::Link.Close", "suspend": "Billing::Link.Pause",
428
+ "resume": "Billing::Link.Unpause", "enable": "Billing::Link.TurnOn",
429
+ "disable": "Billing::Link.TurnOff", "aggregate": "Billing::Link"
430
+ }});
431
+ let provider = payment_connection_provider(&ir).expect("declared");
432
+ assert_eq!(provider.verb(ConnectionVerb::Suspend), "Billing::Link.Pause");
433
+ assert_eq!(provider.verb(ConnectionVerb::Resume), "Billing::Link.Unpause");
434
+ assert_eq!(provider.instance_prefix(), "Billing::Link#");
435
+ }
436
+
437
+ #[test]
438
+ fn payment_connection_binding_falls_back_to_the_conventional_names_when_no_ir_is_loaded() {
439
+ assert_eq!(payment_connection_binding("Shop"), PaymentConnectionProvider::conventional("Shop"));
440
+ }
441
+
442
+ #[test]
443
+ fn command_declares_finds_an_argument_on_the_named_command_only() {
444
+ let ir = serde_json::json!({
445
+ "aggregates": [
446
+ {"name": "Registration", "commands": [
447
+ {"name": "Request", "attributes": [{"name": "email"}, {"name": "news_signup"}]},
448
+ {"name": "Cancel", "attributes": [{"name": "reason"}]}
449
+ ]},
450
+ {"name": "Event", "commands": [{"name": "Request", "attributes": [{"name": "slug"}]}]}
451
+ ]
452
+ });
453
+ assert!(command_declares(&ir, "Registration", "Request", "news_signup"));
454
+ assert!(!command_declares(&ir, "Registration", "Cancel", "news_signup"));
455
+ assert!(!command_declares(&ir, "Event", "Request", "news_signup"));
456
+ assert!(!command_declares(&ir, "Registration", "Request", "phone"));
457
+ assert!(!command_declares(&serde_json::json!({"name": "Pizzas"}), "Registration", "Request", "news_signup"));
458
+ }
459
+ }
@@ -0,0 +1,100 @@
1
+ //! The seam a host extension plugs into: extra routes, a boot check and boot-time secrets, added by
2
+ //! a crate that depends on this library instead of living in it. Commerce (payments, checkout,
3
+ //! newsletter, registrations, mail) is the first extension; `default_extensions` names what the
4
+ //! `bootstrap` binary installs when nothing else was.
5
+
6
+ use crate::ir::ir;
7
+ use crate::journal::LineageConfig;
8
+ use crate::lambda_client::LambdaInvoker;
9
+ use crate::secrets::AwsSecretFetcher;
10
+ use async_trait::async_trait;
11
+ use serde_json::Value;
12
+ use std::collections::HashMap;
13
+ use std::path::Path;
14
+ use std::sync::{Arc, OnceLock};
15
+ use tokio::sync::Mutex;
16
+ use tokio_postgres::Client;
17
+
18
+ /// One request, as an extension sees it. `domain_ir` is `None` in the guest phase when this
19
+ /// process has no IR loaded; `event` is the raw Function-URL event (guest phase only) for headers an extension needs.
20
+ pub struct Ctx<'a> {
21
+ pub event: Option<&'a Value>,
22
+ pub method: &'a str,
23
+ pub path: &'a str,
24
+ pub query: &'a HashMap<String, String>,
25
+ pub raw_body: &'a str,
26
+ pub cookies: &'a HashMap<String, String>,
27
+ pub domain_ir: Option<&'a Value>,
28
+ pub client: &'a Mutex<Client>,
29
+ pub wasm_path: &'a Path,
30
+ pub config: &'a LineageConfig,
31
+ pub invoker: &'a dyn LambdaInvoker,
32
+ }
33
+
34
+ /// A public write an extension wants limited per client: `limit_env` names the budget setting.
35
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36
+ pub struct RateRule {
37
+ pub name: &'static str,
38
+ pub method: &'static str,
39
+ pub path: &'static str,
40
+ pub limit_env: &'static str,
41
+ pub default_limit: usize,
42
+ }
43
+
44
+ #[async_trait]
45
+ pub trait HostExtension: Send + Sync {
46
+ /// Answers a request before the account gate: public forms, webhooks. `None` falls through.
47
+ async fn guest_route(&self, _ctx: &Ctx<'_>) -> Option<Value> {
48
+ None
49
+ }
50
+
51
+ /// Answers a request after the host's own sign-in routes, with the signed-in cookies and the
52
+ /// domain IR in hand (`ctx.domain_ir` is always `Some`). `None` falls through.
53
+ async fn account_route(&self, _ctx: &Ctx<'_>) -> Option<Value> {
54
+ None
55
+ }
56
+
57
+ /// The public writes to limit per client, each with its own budget.
58
+ fn rate_rules(&self) -> Vec<RateRule> {
59
+ Vec::new()
60
+ }
61
+
62
+ /// Refuses the boot before the database is touched, for a domain the extension cannot serve.
63
+ fn boot_check(&self, _domain: &str) -> Result<(), String> {
64
+ Ok(())
65
+ }
66
+
67
+ /// Reads the secrets the extension needs into the environment, at cold start.
68
+ async fn boot_secrets(&self, _fetcher: &AwsSecretFetcher) {}
69
+ }
70
+
71
+ static INSTALLED: OnceLock<Vec<Arc<dyn HostExtension>>> = OnceLock::new();
72
+
73
+ /// Installs the extensions for this process; the first call wins, and it must run before any request.
74
+ pub fn install(extensions: Vec<Arc<dyn HostExtension>>) {
75
+ let _ = INSTALLED.set(extensions);
76
+ }
77
+
78
+ /// The installed extensions, or `default_extensions` when none were installed.
79
+ pub fn installed() -> &'static [Arc<dyn HostExtension>] {
80
+ INSTALLED.get_or_init(default_extensions)
81
+ }
82
+
83
+ fn default_extensions() -> Vec<Arc<dyn HostExtension>> {
84
+ vec![Arc::new(crate::web::Commerce)]
85
+ }
86
+
87
+ /// The loaded domain IR, for an extension that needs it in the guest phase.
88
+ pub fn loaded_ir() -> Option<&'static Value> {
89
+ ir()
90
+ }
91
+
92
+ /// Every installed extension's rate rules.
93
+ pub fn rate_rules() -> Vec<RateRule> {
94
+ installed().iter().flat_map(|extension| extension.rate_rules()).collect()
95
+ }
96
+
97
+ /// The rule that limits this request, if any.
98
+ pub fn rate_rule_for(method: &str, path: &str) -> Option<RateRule> {
99
+ rate_rules().into_iter().find(|rule| rule.method == method && rule.path == path)
100
+ }