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