Skip to main content

cufflink_types/
features.rs

1//! Platform capability names: what `/api/version` advertises in `supports`
2//! and a manifest may demand in `required_platform_features`.
3
4use 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    /// Features this manifest's declared shape requires, sorted and
54    /// deduplicated. A manifest that needs a capability always demands it, so
55    /// a platform too old to provide it rejects the deploy instead of silently
56    /// running the service without it.
57    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    /// Every shape v0.27.0 accepted, canonicalised and hashed by v0.27.0.
91    /// It wrote a subscription's `config` in an order that changed from
92    /// one process to the next; the hashes are those of the runs that
93    /// wrote it in the order of its keys, as every run does now.
94    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    /// One case per R3 field: where the field lands in the manifest JSON,
190    /// the manifest before it is set, the same manifest with only that
191    /// field set, and the features it demands.
192    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}