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