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