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