hecks 3.7.0 → 3.9.1
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.
- checksums.yaml +4 -4
- data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
- data/lib/hecks/bluebook/meta_validator.rb +4 -2
- data/lib/hecks/corpus/routes.rb +1 -1
- data/lib/hecks/gate/stages.yml +11 -1
- data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
- data/lib/hecks/language/bluebook/vocabulary.bluebook +1 -1
- data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +1 -1
- data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
- data/lib/hecks/projections/site/probe.rb +131 -0
- data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
- data/lib/hecks/runtime/value/validation.rb +34 -3
- data/lib/hecks/site/bluebook/site.bluebook +31 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -1
- data/prebuilt/{verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json → verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json} +1 -1
- data/rust/codegen/src/fielded.rs +3 -3
- data/rust/codegen/src/json_codec.rs +2 -2
- data/rust/codegen/src/naming.rs +20 -0
- data/rust/host/Cargo.toml +4 -0
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/boot.rs +443 -0
- data/rust/host/src/boundary_fuzz/ir.rs +1 -0
- data/rust/host/src/commerce_ir.rs +459 -0
- data/rust/host/src/extension.rs +100 -0
- data/rust/host/src/ir.rs +0 -448
- data/rust/host/src/journal.rs +1 -1
- data/rust/host/src/lib.rs +40 -0
- data/rust/host/src/main.rs +4 -477
- data/rust/host/src/mint.rs +1 -1
- data/rust/host/src/payments/tests.rs +8 -8
- data/rust/host/src/payments.rs +10 -10
- data/rust/host/src/rate_limit.rs +27 -41
- data/rust/host/src/secrets.rs +15 -0
- data/rust/host/src/web/commerce.rs +121 -0
- data/rust/host/src/web/newsletter.rs +2 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/host/src/web/registration_receipt.rs +2 -2
- data/rust/host/src/web/registrations.rs +49 -49
- data/rust/host/src/web/resend_webhook.rs +300 -0
- data/rust/host/src/web.rs +43 -47
- metadata +13 -5
- /data/prebuilt/{a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal → 0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal} +0 -0
- /data/prebuilt/{945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal → 6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal} +0 -0
|
@@ -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
|
+
}
|