1use crate::{
5 CustomRouteDefinition, ScheduleDefinition, ServiceManifest, SubscriptionDefinition,
6 TableDefinition,
7};
8use std::collections::BTreeSet;
9
10pub const ROUTE_POSTURE: &str = "route_posture";
11pub const DB_V2: &str = "db_v2";
12pub const CACHE: &str = "cache";
13pub const SERVICE_CALL: &str = "service_call";
14pub const RESPONSE_V2: &str = "response_v2";
15pub const RESPONSE_RAW_JSON: &str = "response_raw_json";
16pub const SSE_BRIDGE: &str = "sse_bridge";
17pub const ROUTE_MATCHING: &str = "route_matching";
18pub const ROUTE_LIMITS: &str = "route_limits";
19pub const ROUTE_CONCURRENCY: &str = "route_concurrency";
20pub const ERROR_PROFILE: &str = "error_profile";
21pub const INTERNAL_POSTURE: &str = "internal_posture";
22pub const API_KEY_POSTURE: &str = "api_key_posture";
23pub const SCOPED_PERMISSIONS: &str = "scoped_permissions";
24pub const SCOPED_POSTURE: &str = "scoped_posture";
25pub const TYPED_ROUTES: &str = "typed_routes";
26pub const NATS_CONSUMER_CONFIG: &str = "nats_consumer_config";
27pub const NATS_MESSAGE_META: &str = "nats_message_meta";
28pub const NATS_PUBLISH_V2: &str = "nats_publish_v2";
29pub const STREAMS: &str = "streams";
30pub const SCHEDULES: &str = "schedules";
31pub const JOBS_V2: &str = "jobs_v2";
32pub const DELIVERY_CONTROL: &str = "delivery_control";
33pub const HTTP_V2: &str = "http_v2";
34pub const STORAGE_V2: &str = "storage_v2";
35pub const STORAGE_ACL: &str = "storage_acl";
36pub const TENANT_BUCKETS: &str = "tenant_buckets";
37pub const UTIL_V2: &str = "util_v2";
38pub const SCHEMA_ADOPTED: &str = "schema_adopted";
39pub const SQL_MIGRATIONS: &str = "sql_migrations";
40pub const COLUMN_TYPES_V2: &str = "column_types_v2";
41pub const AUDIT_REDACTION: &str = "audit_redaction";
42pub const KEYSET_LISTS: &str = "keyset_lists";
43pub const OUTBOX: &str = "outbox";
44pub const DB_ROLES: &str = "db_roles";
45pub const SCHEMA_GUARD: &str = "schema_guard";
46pub const NAMESPACE_CLAIMS: &str = "namespace_claims";
47pub const SECRET_ENCRYPTION: &str = "secret_encryption";
48pub const CONSUMER_RECONCILER: &str = "consumer_reconciler";
49pub const ASYNC_DEPLOY: &str = "async_deploy";
50pub const EXT_CALL: &str = "ext_call";
51pub const NATIVE_SERVICES: &str = "native_services";
52pub const PRIVATE_TABLES: &str = "private_tables";
53pub const INACTIVE_DEPLOY: &str = "inactive_deploy";
54pub const CONFIG_APPLY: &str = "config_apply";
55pub const COLUMN_PERMISSIONS: &str = "column_permissions";
56pub const GUEST_MEMORY_BUDGET: &str = "guest_memory_budget";
57pub const GUEST_STATEMENT_TIMEOUT: &str = "guest_statement_timeout";
58pub const SQL_PARSE: &str = "sql_parse";
59pub const WEB_RELEASES: &str = "web_releases";
60
61impl ServiceManifest {
62 pub fn derived_features(&self) -> BTreeSet<&'static str> {
67 self.custom_routes
68 .iter()
69 .flat_map(CustomRouteDefinition::derived_features)
70 .chain(
71 self.declared_tables()
72 .flat_map(TableDefinition::derived_features),
73 )
74 .chain(
75 self.subscriptions
76 .iter()
77 .flat_map(SubscriptionDefinition::derived_features),
78 )
79 .chain(
80 self.schedules
81 .iter()
82 .flat_map(ScheduleDefinition::derived_features),
83 )
84 .chain(self.service_features())
85 .collect()
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use crate::manifest::fixtures::sample_manifest;
93 use crate::RoutePosture;
94 use serde_json::Value;
95
96 const SAMPLE: &str = include_str!("../fixtures/sample_manifest.json");
97 const TODO: &str = include_str!("../fixtures/todo_manifest.json");
98 const BLOG: &str = include_str!("../fixtures/blog_manifest.json");
99 const LEGACY_SHAPES: &str = include_str!("../fixtures/legacy_shapes_manifest.json");
104
105 fn manifest(json: &str) -> ServiceManifest {
106 serde_json::from_str(json).expect("fixture manifest")
107 }
108
109 fn route(handler: &str, posture: Option<RoutePosture>) -> CustomRouteDefinition {
110 CustomRouteDefinition {
111 method: "GET".into(),
112 path: format!("/{handler}"),
113 handler: handler.into(),
114 job: None,
115 posture,
116 forward_auth: false,
117 ..Default::default()
118 }
119 }
120
121 #[test]
122 fn sample_manifest_derives_only_route_posture() {
123 let sample = manifest(SAMPLE);
124 assert_eq!(sample.derived_features(), BTreeSet::from([ROUTE_POSTURE]));
125 assert_eq!(sample.required_platform_features, vec![ROUTE_POSTURE]);
126 for crud_only in [TODO, BLOG] {
127 assert!(manifest(crud_only).derived_features().is_empty());
128 }
129 }
130
131 #[test]
132 fn route_posture_is_derived_once_from_any_declared_posture() {
133 let mut m = sample_manifest();
134 assert!(m.derived_features().is_empty());
135 m.custom_routes.push(route("legacy", None));
136 assert!(m.derived_features().is_empty());
137 m.custom_routes
138 .push(route("me", Some(RoutePosture::Authenticated)));
139 m.custom_routes.push(route(
140 "hook",
141 Some(RoutePosture::Public {
142 reason: "signed webhook".into(),
143 }),
144 ));
145 assert_eq!(m.derived_features(), BTreeSet::from([ROUTE_POSTURE]));
146 }
147
148 #[test]
149 fn strict_routing_demands_route_matching() {
150 let mut m = sample_manifest();
151 m.routing = crate::RoutingMode::Strict;
152 assert_eq!(m.derived_features(), BTreeSet::from([ROUTE_MATCHING]));
153 }
154
155 #[test]
156 fn an_error_profile_demands_error_profile() {
157 let mut m = sample_manifest();
158 m.errors = Some(crate::ErrorProfile {
159 key: "error_message".into(),
160 });
161 assert_eq!(m.derived_features(), BTreeSet::from([ERROR_PROFILE]));
162 }
163
164 fn sql_migration() -> crate::SqlMigration {
165 crate::SqlMigration {
166 version: 1,
167 name: "initial_tables".into(),
168 sql: "CREATE TABLE loans ();".into(),
169 no_transaction: false,
170 }
171 }
172
173 fn stream() -> crate::StreamDefinition {
174 serde_json::from_value(serde_json::json!({
175 "name": "LOANS", "subjects": ["loans.created"], "retention": "limits"
176 }))
177 .expect("stream")
178 }
179
180 fn schedule() -> crate::ScheduleDefinition {
181 serde_json::from_value(serde_json::json!({
182 "name": "order_retry", "handler": "retry_orders", "spec": {"kind": "every", "secs": 30}
183 }))
184 .expect("schedule")
185 }
186
187 fn private_table() -> TableDefinition {
188 serde_json::from_value(serde_json::json!({
189 "name": "loans", "managed": false,
190 "columns": [{"name": "body", "column_type": "text"}]
191 }))
192 .expect("table")
193 }
194
195 fn r3_fields() -> Vec<(
199 &'static str,
200 ServiceManifest,
201 ServiceManifest,
202 Vec<&'static str>,
203 )> {
204 use crate::{
205 AckMode, ConsumerNaming, HandlerType, JobConfig, MigrationHistory, Pagination, RetryOn,
206 SchemaOwnership,
207 };
208 let base = || manifest(SAMPLE);
209 let subscribed = || {
210 let mut m = base();
211 m.subscriptions.insert(
212 0,
213 SubscriptionDefinition::new("dw.{tenant}.items.created", "h", HandlerType::Wasm),
214 );
215 m
216 };
217 let service =
218 |pointer, after: ServiceManifest, features| (pointer, base(), after, features);
219 let table = |pointer, edit: fn(&mut TableDefinition), features| {
220 let mut after = base();
221 edit(&mut after.tables[0]);
222 (pointer, base(), after, features)
223 };
224 let route = |pointer, edit: fn(&mut crate::CustomRouteDefinition), features| {
225 let mut after = base();
226 edit(&mut after.custom_routes[0]);
227 (pointer, base(), after, features)
228 };
229 let subscription = |pointer, edit: fn(&mut SubscriptionDefinition), features| {
230 let mut after = subscribed();
231 edit(&mut after.subscriptions[0]);
232 (pointer, subscribed(), after, features)
233 };
234 vec![
235 service(
236 "/consumers",
237 ServiceManifest {
238 consumers: ConsumerNaming::Scoped,
239 ..base()
240 },
241 vec![NATS_CONSUMER_CONFIG],
242 ),
243 service(
244 "/streams",
245 ServiceManifest {
246 streams: vec![stream()],
247 ..base()
248 },
249 vec![STREAMS],
250 ),
251 service(
252 "/schedules",
253 ServiceManifest {
254 schedules: vec![schedule()],
255 ..base()
256 },
257 vec![SCHEDULES],
258 ),
259 service(
260 "/schedules/0/job/retry_on",
261 ServiceManifest {
262 schedules: vec![crate::ScheduleDefinition {
263 job: Some(JobConfig {
264 retry_on: vec![RetryOn::Timeout],
265 ..Default::default()
266 }),
267 ..schedule()
268 }],
269 ..base()
270 },
271 vec![SCHEDULES, JOBS_V2],
272 ),
273 service(
274 "/schema",
275 ServiceManifest {
276 schema: SchemaOwnership::Adopted,
277 ..base()
278 },
279 vec![SCHEMA_ADOPTED],
280 ),
281 service(
282 "/schema_name",
283 ServiceManifest {
284 schema_name: Some("loan_service".into()),
285 ..base()
286 },
287 vec![SCHEMA_ADOPTED],
288 ),
289 service(
290 "/migrations",
291 ServiceManifest {
292 migrations: vec![sql_migration()],
293 ..base()
294 },
295 vec![SQL_MIGRATIONS],
296 ),
297 service(
298 "/extensions",
299 ServiceManifest {
300 extensions: vec!["google.token".into()],
301 ..base()
302 },
303 vec![EXT_CALL],
304 ),
305 service(
306 "/provides",
307 ServiceManifest {
308 mode: crate::ServiceMode::Native,
309 provides: vec![crate::ProvidedExtension {
310 name: "google.token".into(),
311 callers: vec!["reports-service".into()],
312 }],
313 ..base()
314 },
315 vec![NATIVE_SERVICES],
316 ),
317 service(
318 "/migration_history",
319 ServiceManifest {
320 migration_history: Some(MigrationHistory::default()),
321 ..base()
322 },
323 vec![],
324 ),
325 subscription(
326 "/subscriptions/0/ack",
327 |s| s.ack = AckMode::OnSuccess,
328 vec![NATS_CONSUMER_CONFIG],
329 ),
330 subscription(
331 "/subscriptions/0/consumer",
332 |s| s.consumer = Some(Default::default()),
333 vec![NATS_CONSUMER_CONFIG],
334 ),
335 subscription(
336 "/subscriptions/0/timeout_secs",
337 |s| s.timeout_secs = Some(30),
338 vec![NATS_CONSUMER_CONFIG],
339 ),
340 table(
341 "/tables/0/managed",
342 |t| t.managed = false,
343 vec![SCHEMA_ADOPTED],
344 ),
345 table(
346 "/tables/0/checks",
347 |t| {
348 t.checks.push(crate::CheckConstraint {
349 name: "c".into(),
350 expr: "a > 0".into(),
351 })
352 },
353 vec![COLUMN_TYPES_V2],
354 ),
355 table(
356 "/tables/0/search",
357 |t| t.search = Some(Default::default()),
358 vec![COLUMN_TYPES_V2],
359 ),
360 table(
361 "/tables/0/indexes/0/predicate",
362 |t| {
363 t.indexes.insert(
364 0,
365 crate::IndexDefinition {
366 name: "i".into(),
367 columns: vec!["id".into()],
368 predicate: Some("id IS NOT NULL".into()),
369 ..Default::default()
370 },
371 )
372 },
373 vec![COLUMN_TYPES_V2],
374 ),
375 table(
376 "/tables/0/indexes/0/method",
377 |t| {
378 t.indexes.insert(
379 0,
380 crate::IndexDefinition {
381 name: "i".into(),
382 columns: vec!["id".into()],
383 method: crate::IndexMethod::Hash,
384 ..Default::default()
385 },
386 )
387 },
388 vec![COLUMN_TYPES_V2],
389 ),
390 table(
391 "/tables/0/audit_redact",
392 |t| t.audit_redact.push("dob".into()),
393 vec![AUDIT_REDACTION],
394 ),
395 table(
396 "/tables/0/sensitive",
397 |t| t.sensitive = true,
398 vec![AUDIT_REDACTION],
399 ),
400 route(
401 "/custom_routes/0/audit_redact",
402 |r| r.audit_redact.push("card.number".into()),
403 vec![AUDIT_REDACTION],
404 ),
405 route(
406 "/custom_routes/0/max_concurrent",
407 |r| r.max_concurrent = Some(4),
408 vec![ROUTE_CONCURRENCY],
409 ),
410 table(
411 "/tables/0/pagination",
412 |t| t.pagination = Pagination::Keyset,
413 vec![KEYSET_LISTS],
414 ),
415 table(
416 "/tables/0/columns/1/access",
417 |t| {
418 t.columns[1].access = Some(crate::ColumnAccess {
419 read: Some("internal".into()),
420 write: None,
421 })
422 },
423 vec![COLUMN_PERMISSIONS],
424 ),
425 service(
426 "/private_tables",
427 ServiceManifest {
428 private_tables: vec![private_table()],
429 ..base()
430 },
431 vec![PRIVATE_TABLES, SCHEMA_ADOPTED],
432 ),
433 ]
434 }
435
436 fn json(manifest: &ServiceManifest) -> Value {
437 serde_json::to_value(manifest).expect("serialize")
438 }
439
440 #[test]
441 fn every_r3_field_is_absent_until_it_is_set() {
442 for (pointer, before, after, _) in r3_fields() {
443 assert_eq!(json(&before).pointer(pointer), None, "{pointer}");
444 assert!(json(&after).pointer(pointer).is_some(), "{pointer}");
445 }
446 }
447
448 #[test]
449 fn every_r3_field_changes_the_hash_and_round_trips() {
450 for (pointer, before, after, _) in r3_fields() {
451 assert_ne!(
452 after.deploy_hash().expect("hash"),
453 before.deploy_hash().expect("hash"),
454 "{pointer}"
455 );
456 let wire = json(&after);
457 let reparsed: ServiceManifest = serde_json::from_value(wire.clone()).expect("parse");
458 assert_eq!(json(&reparsed), wire, "{pointer}");
459 }
460 }
461
462 #[test]
463 fn every_r3_field_demands_its_features() {
464 for (pointer, before, after, demanded) in r3_fields() {
465 assert_eq!(
466 before.derived_features(),
467 BTreeSet::from([ROUTE_POSTURE]),
468 "{pointer}"
469 );
470 let expected: BTreeSet<&str> = demanded.into_iter().chain([ROUTE_POSTURE]).collect();
471 assert_eq!(after.derived_features(), expected, "{pointer}");
472 }
473 }
474
475 #[test]
476 fn deploy_hash_unchanged_for_legacy_manifest() {
477 for (json, deploy, schema) in [
478 (
479 SAMPLE,
480 "eee68954141745273b4157ffa4da676f33db4a908f63949bc9a4d7a2a437a17e",
481 "c1bfff7742704c2d5c1849a6eb964cece595080d25bcddf9e899dbd137ce5978",
482 ),
483 (
484 TODO,
485 "927dfafc66a426e311dc92eee91f2964e6ad450f7d57266a8cab4300a1359bab",
486 "aa0463d8a7e1fd9e3c6bca62fa0480e4ee6ce4083d17441f02061dca699e9907",
487 ),
488 (
489 BLOG,
490 "9d931c651c3bff3d9be5105b7867ffae01913757bdd8db0a13b1337dd17b7ae5",
491 "98cd636af82779af1d42ccc7f7daff5b1fd0e70cb553a15233ce4a4e3655e43f",
492 ),
493 (
494 LEGACY_SHAPES,
495 "6bcd77c75c7980173facd21b6032b82636e6ec4119c0c6636acd1a67a1f2343a",
496 "11dc0ab100791653dc63e43a52d5c870ccf2a9d6997f64775664887400107439",
497 ),
498 ] {
499 let m = manifest(json);
500 assert_eq!(m.deploy_hash().expect("hash"), deploy, "{}", m.name);
501 assert_eq!(m.schema_hash().expect("schema hash"), schema, "{}", m.name);
502 }
503 }
504
505 #[test]
506 fn a_manifest_without_private_tables_serialises_as_before() {
507 for json in [SAMPLE, TODO, BLOG, LEGACY_SHAPES] {
508 let stored: Value = serde_json::from_str(json).expect("fixture json");
509 let m = manifest(json);
510 assert!(m.private_tables.is_empty(), "{}", m.name);
511 let wire = serde_json::to_value(&m).expect("serialize");
512 assert_eq!(wire.get("private_tables"), None, "{}", m.name);
513 assert_eq!(wire, stored, "{}", m.name);
514 assert_eq!(m.declared_tables().count(), m.tables.len(), "{}", m.name);
515 }
516 }
517
518 #[test]
519 fn private_tables_demand_their_feature() {
520 let mut m = sample_manifest();
521 assert!(m.derived_features().is_empty());
522 m.private_tables = vec![private_table()];
523 assert_eq!(
524 m.derived_features(),
525 BTreeSet::from([PRIVATE_TABLES, SCHEMA_ADOPTED])
526 );
527 m.private_tables[0].columns[0].column_type = crate::ColumnType::JsonbArray;
528 assert_eq!(
529 m.derived_features(),
530 BTreeSet::from([COLUMN_TYPES_V2, PRIVATE_TABLES, SCHEMA_ADOPTED])
531 );
532 }
533
534 #[test]
535 fn legacy_manifest_json_round_trips_unchanged() {
536 for json in [SAMPLE, TODO, BLOG, LEGACY_SHAPES] {
537 let stored: Value = serde_json::from_str(json).expect("fixture json");
538 let reserialized = serde_json::to_value(manifest(json)).expect("serialize");
539 assert_eq!(reserialized, stored);
540 }
541 }
542
543 #[test]
544 fn a_legacy_manifest_demands_what_it_demanded_of_v0_27_0() {
545 assert_eq!(
546 manifest(LEGACY_SHAPES).derived_features(),
547 BTreeSet::from([
548 "api_key_posture",
549 "error_profile",
550 "internal_posture",
551 "route_limits",
552 "route_matching",
553 "route_posture",
554 "scoped_permissions",
555 "scoped_posture",
556 "sse_bridge",
557 ])
558 );
559 }
560}