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";
48
49impl ServiceManifest {
50 pub fn derived_features(&self) -> BTreeSet<&'static str> {
55 self.custom_routes
56 .iter()
57 .flat_map(CustomRouteDefinition::derived_features)
58 .chain(
59 self.tables
60 .iter()
61 .flat_map(TableDefinition::derived_features),
62 )
63 .chain(
64 self.subscriptions
65 .iter()
66 .flat_map(SubscriptionDefinition::derived_features),
67 )
68 .chain(
69 self.schedules
70 .iter()
71 .flat_map(ScheduleDefinition::derived_features),
72 )
73 .chain(self.service_features())
74 .collect()
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use crate::manifest::fixtures::sample_manifest;
82 use crate::RoutePosture;
83 use serde_json::Value;
84
85 const STARMOIRE: &str = include_str!("../fixtures/starmoire_manifest.json");
86 const TODO: &str = include_str!("../fixtures/todo_manifest.json");
87 const BLOG: &str = include_str!("../fixtures/blog_manifest.json");
88 const LEGACY_SHAPES: &str = include_str!("../fixtures/legacy_shapes_manifest.json");
93
94 fn manifest(json: &str) -> ServiceManifest {
95 serde_json::from_str(json).expect("fixture manifest")
96 }
97
98 fn route(handler: &str, posture: Option<RoutePosture>) -> CustomRouteDefinition {
99 CustomRouteDefinition {
100 method: "GET".into(),
101 path: format!("/{handler}"),
102 handler: handler.into(),
103 job: None,
104 posture,
105 forward_auth: false,
106 ..Default::default()
107 }
108 }
109
110 #[test]
111 fn starmoire_manifest_derives_only_route_posture() {
112 let starmoire = manifest(STARMOIRE);
113 assert_eq!(
114 starmoire.derived_features(),
115 BTreeSet::from([ROUTE_POSTURE])
116 );
117 assert_eq!(starmoire.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 notes ();".into(),
161 no_transaction: false,
162 }
163 }
164
165 fn stream() -> crate::StreamDefinition {
166 serde_json::from_value(serde_json::json!({
167 "name": "NOTES", "subjects": ["notes.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 r3_fields() -> Vec<(
183 &'static str,
184 ServiceManifest,
185 ServiceManifest,
186 Vec<&'static str>,
187 )> {
188 use crate::{
189 AckMode, ConsumerNaming, HandlerType, JobConfig, MigrationHistory, Pagination, RetryOn,
190 SchemaOwnership,
191 };
192 let base = || manifest(STARMOIRE);
193 let subscribed = || {
194 let mut m = base();
195 m.subscriptions.insert(
196 0,
197 SubscriptionDefinition::new("dw.{tenant}.items.created", "h", HandlerType::Wasm),
198 );
199 m
200 };
201 let service =
202 |pointer, after: ServiceManifest, features| (pointer, base(), after, features);
203 let table = |pointer, edit: fn(&mut TableDefinition), features| {
204 let mut after = base();
205 edit(&mut after.tables[0]);
206 (pointer, base(), after, features)
207 };
208 let route = |pointer, edit: fn(&mut crate::CustomRouteDefinition), features| {
209 let mut after = base();
210 edit(&mut after.custom_routes[0]);
211 (pointer, base(), after, features)
212 };
213 let subscription = |pointer, edit: fn(&mut SubscriptionDefinition), features| {
214 let mut after = subscribed();
215 edit(&mut after.subscriptions[0]);
216 (pointer, subscribed(), after, features)
217 };
218 vec![
219 service(
220 "/consumers",
221 ServiceManifest {
222 consumers: ConsumerNaming::Scoped,
223 ..base()
224 },
225 vec![NATS_CONSUMER_CONFIG],
226 ),
227 service(
228 "/streams",
229 ServiceManifest {
230 streams: vec![stream()],
231 ..base()
232 },
233 vec![STREAMS],
234 ),
235 service(
236 "/schedules",
237 ServiceManifest {
238 schedules: vec![schedule()],
239 ..base()
240 },
241 vec![SCHEDULES],
242 ),
243 service(
244 "/schedules/0/job/retry_on",
245 ServiceManifest {
246 schedules: vec![crate::ScheduleDefinition {
247 job: Some(JobConfig {
248 retry_on: vec![RetryOn::Timeout],
249 ..Default::default()
250 }),
251 ..schedule()
252 }],
253 ..base()
254 },
255 vec![SCHEDULES, JOBS_V2],
256 ),
257 service(
258 "/schema",
259 ServiceManifest {
260 schema: SchemaOwnership::Adopted,
261 ..base()
262 },
263 vec![SCHEMA_ADOPTED],
264 ),
265 service(
266 "/schema_name",
267 ServiceManifest {
268 schema_name: Some("note_service".into()),
269 ..base()
270 },
271 vec![SCHEMA_ADOPTED],
272 ),
273 service(
274 "/migrations",
275 ServiceManifest {
276 migrations: vec![sql_migration()],
277 ..base()
278 },
279 vec![SQL_MIGRATIONS],
280 ),
281 service(
282 "/extensions",
283 ServiceManifest {
284 extensions: vec!["google.token".into()],
285 ..base()
286 },
287 vec![EXT_CALL],
288 ),
289 service(
290 "/provides",
291 ServiceManifest {
292 mode: crate::ServiceMode::Native,
293 provides: vec![crate::ProvidedExtension {
294 name: "google.token".into(),
295 callers: vec!["reports-service".into()],
296 }],
297 ..base()
298 },
299 vec![NATIVE_SERVICES],
300 ),
301 service(
302 "/migration_history",
303 ServiceManifest {
304 migration_history: Some(MigrationHistory::default()),
305 ..base()
306 },
307 vec![],
308 ),
309 subscription(
310 "/subscriptions/0/ack",
311 |s| s.ack = AckMode::OnSuccess,
312 vec![NATS_CONSUMER_CONFIG],
313 ),
314 subscription(
315 "/subscriptions/0/consumer",
316 |s| s.consumer = Some(Default::default()),
317 vec![NATS_CONSUMER_CONFIG],
318 ),
319 subscription(
320 "/subscriptions/0/timeout_secs",
321 |s| s.timeout_secs = Some(30),
322 vec![NATS_CONSUMER_CONFIG],
323 ),
324 table(
325 "/tables/0/managed",
326 |t| t.managed = false,
327 vec![SCHEMA_ADOPTED],
328 ),
329 table(
330 "/tables/0/checks",
331 |t| {
332 t.checks.push(crate::CheckConstraint {
333 name: "c".into(),
334 expr: "a > 0".into(),
335 })
336 },
337 vec![COLUMN_TYPES_V2],
338 ),
339 table(
340 "/tables/0/search",
341 |t| t.search = Some(Default::default()),
342 vec![COLUMN_TYPES_V2],
343 ),
344 table(
345 "/tables/0/indexes/0/predicate",
346 |t| {
347 t.indexes.insert(
348 0,
349 crate::IndexDefinition {
350 name: "i".into(),
351 columns: vec!["id".into()],
352 predicate: Some("id IS NOT NULL".into()),
353 ..Default::default()
354 },
355 )
356 },
357 vec![COLUMN_TYPES_V2],
358 ),
359 table(
360 "/tables/0/indexes/0/method",
361 |t| {
362 t.indexes.insert(
363 0,
364 crate::IndexDefinition {
365 name: "i".into(),
366 columns: vec!["id".into()],
367 method: crate::IndexMethod::Hash,
368 ..Default::default()
369 },
370 )
371 },
372 vec![COLUMN_TYPES_V2],
373 ),
374 table(
375 "/tables/0/audit_redact",
376 |t| t.audit_redact.push("dob".into()),
377 vec![AUDIT_REDACTION],
378 ),
379 table(
380 "/tables/0/sensitive",
381 |t| t.sensitive = true,
382 vec![AUDIT_REDACTION],
383 ),
384 route(
385 "/custom_routes/0/audit_redact",
386 |r| r.audit_redact.push("card.number".into()),
387 vec![AUDIT_REDACTION],
388 ),
389 table(
390 "/tables/0/pagination",
391 |t| t.pagination = Pagination::Keyset,
392 vec![KEYSET_LISTS],
393 ),
394 ]
395 }
396
397 fn json(manifest: &ServiceManifest) -> Value {
398 serde_json::to_value(manifest).expect("serialize")
399 }
400
401 #[test]
402 fn every_r3_field_is_absent_until_it_is_set() {
403 for (pointer, before, after, _) in r3_fields() {
404 assert_eq!(json(&before).pointer(pointer), None, "{pointer}");
405 assert!(json(&after).pointer(pointer).is_some(), "{pointer}");
406 }
407 }
408
409 #[test]
410 fn every_r3_field_changes_the_hash_and_round_trips() {
411 for (pointer, before, after, _) in r3_fields() {
412 assert_ne!(
413 after.deploy_hash().expect("hash"),
414 before.deploy_hash().expect("hash"),
415 "{pointer}"
416 );
417 let wire = json(&after);
418 let reparsed: ServiceManifest = serde_json::from_value(wire.clone()).expect("parse");
419 assert_eq!(json(&reparsed), wire, "{pointer}");
420 }
421 }
422
423 #[test]
424 fn every_r3_field_demands_its_features() {
425 for (pointer, before, after, demanded) in r3_fields() {
426 assert_eq!(
427 before.derived_features(),
428 BTreeSet::from([ROUTE_POSTURE]),
429 "{pointer}"
430 );
431 let expected: BTreeSet<&str> = demanded.into_iter().chain([ROUTE_POSTURE]).collect();
432 assert_eq!(after.derived_features(), expected, "{pointer}");
433 }
434 }
435
436 #[test]
437 fn deploy_hash_unchanged_for_legacy_manifest() {
438 for (json, deploy, schema) in [
439 (
440 STARMOIRE,
441 "32b79ba0e4b4697a505007ae19a1ab55a1f4fbefb26fe8fcd86a53847eccba28",
442 "422f205e16c949bd532f63707b78d8f6ccc4c9ba4eda0b2bd275c479a8d998f2",
443 ),
444 (
445 TODO,
446 "927dfafc66a426e311dc92eee91f2964e6ad450f7d57266a8cab4300a1359bab",
447 "aa0463d8a7e1fd9e3c6bca62fa0480e4ee6ce4083d17441f02061dca699e9907",
448 ),
449 (
450 BLOG,
451 "9d931c651c3bff3d9be5105b7867ffae01913757bdd8db0a13b1337dd17b7ae5",
452 "98cd636af82779af1d42ccc7f7daff5b1fd0e70cb553a15233ce4a4e3655e43f",
453 ),
454 (
455 LEGACY_SHAPES,
456 "6bcd77c75c7980173facd21b6032b82636e6ec4119c0c6636acd1a67a1f2343a",
457 "11dc0ab100791653dc63e43a52d5c870ccf2a9d6997f64775664887400107439",
458 ),
459 ] {
460 let m = manifest(json);
461 assert_eq!(m.deploy_hash().expect("hash"), deploy, "{}", m.name);
462 assert_eq!(m.schema_hash().expect("schema hash"), schema, "{}", m.name);
463 }
464 }
465
466 #[test]
467 fn legacy_manifest_json_round_trips_unchanged() {
468 for json in [STARMOIRE, TODO, BLOG, LEGACY_SHAPES] {
469 let stored: Value = serde_json::from_str(json).expect("fixture json");
470 let reserialized = serde_json::to_value(manifest(json)).expect("serialize");
471 assert_eq!(reserialized, stored);
472 }
473 }
474
475 #[test]
476 fn a_legacy_manifest_demands_what_it_demanded_of_v0_27_0() {
477 assert_eq!(
478 manifest(LEGACY_SHAPES).derived_features(),
479 BTreeSet::from([
480 "api_key_posture",
481 "error_profile",
482 "internal_posture",
483 "route_limits",
484 "route_matching",
485 "route_posture",
486 "scoped_permissions",
487 "scoped_posture",
488 "sse_bridge",
489 ])
490 );
491 }
492}