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";
51
52impl ServiceManifest {
53 pub fn derived_features(&self) -> BTreeSet<&'static str> {
58 self.custom_routes
59 .iter()
60 .flat_map(CustomRouteDefinition::derived_features)
61 .chain(
62 self.declared_tables()
63 .flat_map(TableDefinition::derived_features),
64 )
65 .chain(
66 self.subscriptions
67 .iter()
68 .flat_map(SubscriptionDefinition::derived_features),
69 )
70 .chain(
71 self.schedules
72 .iter()
73 .flat_map(ScheduleDefinition::derived_features),
74 )
75 .chain(self.service_features())
76 .collect()
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83 use crate::manifest::fixtures::sample_manifest;
84 use crate::RoutePosture;
85 use serde_json::Value;
86
87 const STARMOIRE: &str = include_str!("../fixtures/starmoire_manifest.json");
88 const TODO: &str = include_str!("../fixtures/todo_manifest.json");
89 const BLOG: &str = include_str!("../fixtures/blog_manifest.json");
90 const LEGACY_SHAPES: &str = include_str!("../fixtures/legacy_shapes_manifest.json");
95
96 fn manifest(json: &str) -> ServiceManifest {
97 serde_json::from_str(json).expect("fixture manifest")
98 }
99
100 fn route(handler: &str, posture: Option<RoutePosture>) -> CustomRouteDefinition {
101 CustomRouteDefinition {
102 method: "GET".into(),
103 path: format!("/{handler}"),
104 handler: handler.into(),
105 job: None,
106 posture,
107 forward_auth: false,
108 ..Default::default()
109 }
110 }
111
112 #[test]
113 fn starmoire_manifest_derives_only_route_posture() {
114 let starmoire = manifest(STARMOIRE);
115 assert_eq!(
116 starmoire.derived_features(),
117 BTreeSet::from([ROUTE_POSTURE])
118 );
119 assert_eq!(starmoire.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 notes ();".into(),
163 no_transaction: false,
164 }
165 }
166
167 fn stream() -> crate::StreamDefinition {
168 serde_json::from_value(serde_json::json!({
169 "name": "NOTES", "subjects": ["notes.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": "notes", "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(STARMOIRE);
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("note_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 table(
400 "/tables/0/pagination",
401 |t| t.pagination = Pagination::Keyset,
402 vec![KEYSET_LISTS],
403 ),
404 service(
405 "/private_tables",
406 ServiceManifest {
407 private_tables: vec![private_table()],
408 ..base()
409 },
410 vec![PRIVATE_TABLES, SCHEMA_ADOPTED],
411 ),
412 ]
413 }
414
415 fn json(manifest: &ServiceManifest) -> Value {
416 serde_json::to_value(manifest).expect("serialize")
417 }
418
419 #[test]
420 fn every_r3_field_is_absent_until_it_is_set() {
421 for (pointer, before, after, _) in r3_fields() {
422 assert_eq!(json(&before).pointer(pointer), None, "{pointer}");
423 assert!(json(&after).pointer(pointer).is_some(), "{pointer}");
424 }
425 }
426
427 #[test]
428 fn every_r3_field_changes_the_hash_and_round_trips() {
429 for (pointer, before, after, _) in r3_fields() {
430 assert_ne!(
431 after.deploy_hash().expect("hash"),
432 before.deploy_hash().expect("hash"),
433 "{pointer}"
434 );
435 let wire = json(&after);
436 let reparsed: ServiceManifest = serde_json::from_value(wire.clone()).expect("parse");
437 assert_eq!(json(&reparsed), wire, "{pointer}");
438 }
439 }
440
441 #[test]
442 fn every_r3_field_demands_its_features() {
443 for (pointer, before, after, demanded) in r3_fields() {
444 assert_eq!(
445 before.derived_features(),
446 BTreeSet::from([ROUTE_POSTURE]),
447 "{pointer}"
448 );
449 let expected: BTreeSet<&str> = demanded.into_iter().chain([ROUTE_POSTURE]).collect();
450 assert_eq!(after.derived_features(), expected, "{pointer}");
451 }
452 }
453
454 #[test]
455 fn deploy_hash_unchanged_for_legacy_manifest() {
456 for (json, deploy, schema) in [
457 (
458 STARMOIRE,
459 "32b79ba0e4b4697a505007ae19a1ab55a1f4fbefb26fe8fcd86a53847eccba28",
460 "422f205e16c949bd532f63707b78d8f6ccc4c9ba4eda0b2bd275c479a8d998f2",
461 ),
462 (
463 TODO,
464 "927dfafc66a426e311dc92eee91f2964e6ad450f7d57266a8cab4300a1359bab",
465 "aa0463d8a7e1fd9e3c6bca62fa0480e4ee6ce4083d17441f02061dca699e9907",
466 ),
467 (
468 BLOG,
469 "9d931c651c3bff3d9be5105b7867ffae01913757bdd8db0a13b1337dd17b7ae5",
470 "98cd636af82779af1d42ccc7f7daff5b1fd0e70cb553a15233ce4a4e3655e43f",
471 ),
472 (
473 LEGACY_SHAPES,
474 "6bcd77c75c7980173facd21b6032b82636e6ec4119c0c6636acd1a67a1f2343a",
475 "11dc0ab100791653dc63e43a52d5c870ccf2a9d6997f64775664887400107439",
476 ),
477 ] {
478 let m = manifest(json);
479 assert_eq!(m.deploy_hash().expect("hash"), deploy, "{}", m.name);
480 assert_eq!(m.schema_hash().expect("schema hash"), schema, "{}", m.name);
481 }
482 }
483
484 #[test]
485 fn a_manifest_without_private_tables_serialises_as_before() {
486 for json in [STARMOIRE, TODO, BLOG, LEGACY_SHAPES] {
487 let stored: Value = serde_json::from_str(json).expect("fixture json");
488 let m = manifest(json);
489 assert!(m.private_tables.is_empty(), "{}", m.name);
490 let wire = serde_json::to_value(&m).expect("serialize");
491 assert_eq!(wire.get("private_tables"), None, "{}", m.name);
492 assert_eq!(wire, stored, "{}", m.name);
493 assert_eq!(m.declared_tables().count(), m.tables.len(), "{}", m.name);
494 }
495 }
496
497 #[test]
498 fn private_tables_demand_their_feature() {
499 let mut m = sample_manifest();
500 assert!(m.derived_features().is_empty());
501 m.private_tables = vec![private_table()];
502 assert_eq!(
503 m.derived_features(),
504 BTreeSet::from([PRIVATE_TABLES, SCHEMA_ADOPTED])
505 );
506 m.private_tables[0].columns[0].column_type = crate::ColumnType::JsonbArray;
507 assert_eq!(
508 m.derived_features(),
509 BTreeSet::from([COLUMN_TYPES_V2, PRIVATE_TABLES, SCHEMA_ADOPTED])
510 );
511 }
512
513 #[test]
514 fn legacy_manifest_json_round_trips_unchanged() {
515 for json in [STARMOIRE, TODO, BLOG, LEGACY_SHAPES] {
516 let stored: Value = serde_json::from_str(json).expect("fixture json");
517 let reserialized = serde_json::to_value(manifest(json)).expect("serialize");
518 assert_eq!(reserialized, stored);
519 }
520 }
521
522 #[test]
523 fn a_legacy_manifest_demands_what_it_demanded_of_v0_27_0() {
524 assert_eq!(
525 manifest(LEGACY_SHAPES).derived_features(),
526 BTreeSet::from([
527 "api_key_posture",
528 "error_profile",
529 "internal_posture",
530 "route_limits",
531 "route_matching",
532 "route_posture",
533 "scoped_permissions",
534 "scoped_posture",
535 "sse_bridge",
536 ])
537 );
538 }
539}