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