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