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 SAMPLE: &str = include_str!("../fixtures/sample_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 sample_manifest_derives_only_route_posture() {
115        let sample = manifest(SAMPLE);
116        assert_eq!(sample.derived_features(), BTreeSet::from([ROUTE_POSTURE]));
117        assert_eq!(sample.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 loans ();".into(),
161            no_transaction: false,
162        }
163    }
164
165    fn stream() -> crate::StreamDefinition {
166        serde_json::from_value(serde_json::json!({
167            "name": "LOANS", "subjects": ["loans.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 private_table() -> TableDefinition {
180        serde_json::from_value(serde_json::json!({
181            "name": "loans", "managed": false,
182            "columns": [{"name": "body", "column_type": "text"}]
183        }))
184        .expect("table")
185    }
186
187    /// One case per R3 field: where the field lands in the manifest JSON,
188    /// the manifest before it is set, the same manifest with only that
189    /// field set, and the features it demands.
190    fn r3_fields() -> Vec<(
191        &'static str,
192        ServiceManifest,
193        ServiceManifest,
194        Vec<&'static str>,
195    )> {
196        use crate::{
197            AckMode, ConsumerNaming, HandlerType, JobConfig, MigrationHistory, Pagination, RetryOn,
198            SchemaOwnership,
199        };
200        let base = || manifest(SAMPLE);
201        let subscribed = || {
202            let mut m = base();
203            m.subscriptions.insert(
204                0,
205                SubscriptionDefinition::new("dw.{tenant}.items.created", "h", HandlerType::Wasm),
206            );
207            m
208        };
209        let service =
210            |pointer, after: ServiceManifest, features| (pointer, base(), after, features);
211        let table = |pointer, edit: fn(&mut TableDefinition), features| {
212            let mut after = base();
213            edit(&mut after.tables[0]);
214            (pointer, base(), after, features)
215        };
216        let route = |pointer, edit: fn(&mut crate::CustomRouteDefinition), features| {
217            let mut after = base();
218            edit(&mut after.custom_routes[0]);
219            (pointer, base(), after, features)
220        };
221        let subscription = |pointer, edit: fn(&mut SubscriptionDefinition), features| {
222            let mut after = subscribed();
223            edit(&mut after.subscriptions[0]);
224            (pointer, subscribed(), after, features)
225        };
226        vec![
227            service(
228                "/consumers",
229                ServiceManifest {
230                    consumers: ConsumerNaming::Scoped,
231                    ..base()
232                },
233                vec![NATS_CONSUMER_CONFIG],
234            ),
235            service(
236                "/streams",
237                ServiceManifest {
238                    streams: vec![stream()],
239                    ..base()
240                },
241                vec![STREAMS],
242            ),
243            service(
244                "/schedules",
245                ServiceManifest {
246                    schedules: vec![schedule()],
247                    ..base()
248                },
249                vec![SCHEDULES],
250            ),
251            service(
252                "/schedules/0/job/retry_on",
253                ServiceManifest {
254                    schedules: vec![crate::ScheduleDefinition {
255                        job: Some(JobConfig {
256                            retry_on: vec![RetryOn::Timeout],
257                            ..Default::default()
258                        }),
259                        ..schedule()
260                    }],
261                    ..base()
262                },
263                vec![SCHEDULES, JOBS_V2],
264            ),
265            service(
266                "/schema",
267                ServiceManifest {
268                    schema: SchemaOwnership::Adopted,
269                    ..base()
270                },
271                vec![SCHEMA_ADOPTED],
272            ),
273            service(
274                "/schema_name",
275                ServiceManifest {
276                    schema_name: Some("loan_service".into()),
277                    ..base()
278                },
279                vec![SCHEMA_ADOPTED],
280            ),
281            service(
282                "/migrations",
283                ServiceManifest {
284                    migrations: vec![sql_migration()],
285                    ..base()
286                },
287                vec![SQL_MIGRATIONS],
288            ),
289            service(
290                "/extensions",
291                ServiceManifest {
292                    extensions: vec!["google.token".into()],
293                    ..base()
294                },
295                vec![EXT_CALL],
296            ),
297            service(
298                "/provides",
299                ServiceManifest {
300                    mode: crate::ServiceMode::Native,
301                    provides: vec![crate::ProvidedExtension {
302                        name: "google.token".into(),
303                        callers: vec!["reports-service".into()],
304                    }],
305                    ..base()
306                },
307                vec![NATIVE_SERVICES],
308            ),
309            service(
310                "/migration_history",
311                ServiceManifest {
312                    migration_history: Some(MigrationHistory::default()),
313                    ..base()
314                },
315                vec![],
316            ),
317            subscription(
318                "/subscriptions/0/ack",
319                |s| s.ack = AckMode::OnSuccess,
320                vec![NATS_CONSUMER_CONFIG],
321            ),
322            subscription(
323                "/subscriptions/0/consumer",
324                |s| s.consumer = Some(Default::default()),
325                vec![NATS_CONSUMER_CONFIG],
326            ),
327            subscription(
328                "/subscriptions/0/timeout_secs",
329                |s| s.timeout_secs = Some(30),
330                vec![NATS_CONSUMER_CONFIG],
331            ),
332            table(
333                "/tables/0/managed",
334                |t| t.managed = false,
335                vec![SCHEMA_ADOPTED],
336            ),
337            table(
338                "/tables/0/checks",
339                |t| {
340                    t.checks.push(crate::CheckConstraint {
341                        name: "c".into(),
342                        expr: "a > 0".into(),
343                    })
344                },
345                vec![COLUMN_TYPES_V2],
346            ),
347            table(
348                "/tables/0/search",
349                |t| t.search = Some(Default::default()),
350                vec![COLUMN_TYPES_V2],
351            ),
352            table(
353                "/tables/0/indexes/0/predicate",
354                |t| {
355                    t.indexes.insert(
356                        0,
357                        crate::IndexDefinition {
358                            name: "i".into(),
359                            columns: vec!["id".into()],
360                            predicate: Some("id IS NOT NULL".into()),
361                            ..Default::default()
362                        },
363                    )
364                },
365                vec![COLUMN_TYPES_V2],
366            ),
367            table(
368                "/tables/0/indexes/0/method",
369                |t| {
370                    t.indexes.insert(
371                        0,
372                        crate::IndexDefinition {
373                            name: "i".into(),
374                            columns: vec!["id".into()],
375                            method: crate::IndexMethod::Hash,
376                            ..Default::default()
377                        },
378                    )
379                },
380                vec![COLUMN_TYPES_V2],
381            ),
382            table(
383                "/tables/0/audit_redact",
384                |t| t.audit_redact.push("dob".into()),
385                vec![AUDIT_REDACTION],
386            ),
387            table(
388                "/tables/0/sensitive",
389                |t| t.sensitive = true,
390                vec![AUDIT_REDACTION],
391            ),
392            route(
393                "/custom_routes/0/audit_redact",
394                |r| r.audit_redact.push("card.number".into()),
395                vec![AUDIT_REDACTION],
396            ),
397            table(
398                "/tables/0/pagination",
399                |t| t.pagination = Pagination::Keyset,
400                vec![KEYSET_LISTS],
401            ),
402            table(
403                "/tables/0/columns/1/access",
404                |t| {
405                    t.columns[1].access = Some(crate::ColumnAccess {
406                        read: Some("internal".into()),
407                        write: None,
408                    })
409                },
410                vec![COLUMN_PERMISSIONS],
411            ),
412            service(
413                "/private_tables",
414                ServiceManifest {
415                    private_tables: vec![private_table()],
416                    ..base()
417                },
418                vec![PRIVATE_TABLES, SCHEMA_ADOPTED],
419            ),
420        ]
421    }
422
423    fn json(manifest: &ServiceManifest) -> Value {
424        serde_json::to_value(manifest).expect("serialize")
425    }
426
427    #[test]
428    fn every_r3_field_is_absent_until_it_is_set() {
429        for (pointer, before, after, _) in r3_fields() {
430            assert_eq!(json(&before).pointer(pointer), None, "{pointer}");
431            assert!(json(&after).pointer(pointer).is_some(), "{pointer}");
432        }
433    }
434
435    #[test]
436    fn every_r3_field_changes_the_hash_and_round_trips() {
437        for (pointer, before, after, _) in r3_fields() {
438            assert_ne!(
439                after.deploy_hash().expect("hash"),
440                before.deploy_hash().expect("hash"),
441                "{pointer}"
442            );
443            let wire = json(&after);
444            let reparsed: ServiceManifest = serde_json::from_value(wire.clone()).expect("parse");
445            assert_eq!(json(&reparsed), wire, "{pointer}");
446        }
447    }
448
449    #[test]
450    fn every_r3_field_demands_its_features() {
451        for (pointer, before, after, demanded) in r3_fields() {
452            assert_eq!(
453                before.derived_features(),
454                BTreeSet::from([ROUTE_POSTURE]),
455                "{pointer}"
456            );
457            let expected: BTreeSet<&str> = demanded.into_iter().chain([ROUTE_POSTURE]).collect();
458            assert_eq!(after.derived_features(), expected, "{pointer}");
459        }
460    }
461
462    #[test]
463    fn deploy_hash_unchanged_for_legacy_manifest() {
464        for (json, deploy, schema) in [
465            (
466                SAMPLE,
467                "32b79ba0e4b4697a505007ae19a1ab55a1f4fbefb26fe8fcd86a53847eccba28",
468                "422f205e16c949bd532f63707b78d8f6ccc4c9ba4eda0b2bd275c479a8d998f2",
469            ),
470            (
471                TODO,
472                "927dfafc66a426e311dc92eee91f2964e6ad450f7d57266a8cab4300a1359bab",
473                "aa0463d8a7e1fd9e3c6bca62fa0480e4ee6ce4083d17441f02061dca699e9907",
474            ),
475            (
476                BLOG,
477                "9d931c651c3bff3d9be5105b7867ffae01913757bdd8db0a13b1337dd17b7ae5",
478                "98cd636af82779af1d42ccc7f7daff5b1fd0e70cb553a15233ce4a4e3655e43f",
479            ),
480            (
481                LEGACY_SHAPES,
482                "6bcd77c75c7980173facd21b6032b82636e6ec4119c0c6636acd1a67a1f2343a",
483                "11dc0ab100791653dc63e43a52d5c870ccf2a9d6997f64775664887400107439",
484            ),
485        ] {
486            let m = manifest(json);
487            assert_eq!(m.deploy_hash().expect("hash"), deploy, "{}", m.name);
488            assert_eq!(m.schema_hash().expect("schema hash"), schema, "{}", m.name);
489        }
490    }
491
492    #[test]
493    fn a_manifest_without_private_tables_serialises_as_before() {
494        for json in [SAMPLE, TODO, BLOG, LEGACY_SHAPES] {
495            let stored: Value = serde_json::from_str(json).expect("fixture json");
496            let m = manifest(json);
497            assert!(m.private_tables.is_empty(), "{}", m.name);
498            let wire = serde_json::to_value(&m).expect("serialize");
499            assert_eq!(wire.get("private_tables"), None, "{}", m.name);
500            assert_eq!(wire, stored, "{}", m.name);
501            assert_eq!(m.declared_tables().count(), m.tables.len(), "{}", m.name);
502        }
503    }
504
505    #[test]
506    fn private_tables_demand_their_feature() {
507        let mut m = sample_manifest();
508        assert!(m.derived_features().is_empty());
509        m.private_tables = vec![private_table()];
510        assert_eq!(
511            m.derived_features(),
512            BTreeSet::from([PRIVATE_TABLES, SCHEMA_ADOPTED])
513        );
514        m.private_tables[0].columns[0].column_type = crate::ColumnType::JsonbArray;
515        assert_eq!(
516            m.derived_features(),
517            BTreeSet::from([COLUMN_TYPES_V2, PRIVATE_TABLES, SCHEMA_ADOPTED])
518        );
519    }
520
521    #[test]
522    fn legacy_manifest_json_round_trips_unchanged() {
523        for json in [SAMPLE, TODO, BLOG, LEGACY_SHAPES] {
524            let stored: Value = serde_json::from_str(json).expect("fixture json");
525            let reserialized = serde_json::to_value(manifest(json)).expect("serialize");
526            assert_eq!(reserialized, stored);
527        }
528    }
529
530    #[test]
531    fn a_legacy_manifest_demands_what_it_demanded_of_v0_27_0() {
532        assert_eq!(
533            manifest(LEGACY_SHAPES).derived_features(),
534            BTreeSet::from([
535                "api_key_posture",
536                "error_profile",
537                "internal_posture",
538                "route_limits",
539                "route_matching",
540                "route_posture",
541                "scoped_permissions",
542                "scoped_posture",
543                "sse_bridge",
544            ])
545        );
546    }
547}