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 ROUTE_CONCURRENCY: &str = "route_concurrency";
19pub const ERROR_PROFILE: &str = "error_profile";
20pub const INTERNAL_POSTURE: &str = "internal_posture";
21pub const API_KEY_POSTURE: &str = "api_key_posture";
22pub const SCOPED_PERMISSIONS: &str = "scoped_permissions";
23pub const SCOPED_POSTURE: &str = "scoped_posture";
24pub const TYPED_ROUTES: &str = "typed_routes";
25pub const NATS_CONSUMER_CONFIG: &str = "nats_consumer_config";
26pub const NATS_MESSAGE_META: &str = "nats_message_meta";
27pub const NATS_PUBLISH_V2: &str = "nats_publish_v2";
28pub const STREAMS: &str = "streams";
29pub const SCHEDULES: &str = "schedules";
30pub const JOBS_V2: &str = "jobs_v2";
31pub const DELIVERY_CONTROL: &str = "delivery_control";
32pub const HTTP_V2: &str = "http_v2";
33pub const STORAGE_V2: &str = "storage_v2";
34pub const UTIL_V2: &str = "util_v2";
35pub const SCHEMA_ADOPTED: &str = "schema_adopted";
36pub const SQL_MIGRATIONS: &str = "sql_migrations";
37pub const COLUMN_TYPES_V2: &str = "column_types_v2";
38pub const AUDIT_REDACTION: &str = "audit_redaction";
39pub const KEYSET_LISTS: &str = "keyset_lists";
40pub const OUTBOX: &str = "outbox";
41pub const DB_ROLES: &str = "db_roles";
42pub const SCHEMA_GUARD: &str = "schema_guard";
43pub const NAMESPACE_CLAIMS: &str = "namespace_claims";
44pub const SECRET_ENCRYPTION: &str = "secret_encryption";
45pub const CONSUMER_RECONCILER: &str = "consumer_reconciler";
46pub const ASYNC_DEPLOY: &str = "async_deploy";
47pub const EXT_CALL: &str = "ext_call";
48pub const NATIVE_SERVICES: &str = "native_services";
49pub const PRIVATE_TABLES: &str = "private_tables";
50pub const INACTIVE_DEPLOY: &str = "inactive_deploy";
51pub const CONFIG_APPLY: &str = "config_apply";
52pub const COLUMN_PERMISSIONS: &str = "column_permissions";
53pub const GUEST_MEMORY_BUDGET: &str = "guest_memory_budget";
54
55impl ServiceManifest {
56    /// Features this manifest's declared shape requires, sorted and
57    /// deduplicated. A manifest that needs a capability always demands it, so
58    /// a platform too old to provide it rejects the deploy instead of silently
59    /// running the service without it.
60    pub fn derived_features(&self) -> BTreeSet<&'static str> {
61        self.custom_routes
62            .iter()
63            .flat_map(CustomRouteDefinition::derived_features)
64            .chain(
65                self.declared_tables()
66                    .flat_map(TableDefinition::derived_features),
67            )
68            .chain(
69                self.subscriptions
70                    .iter()
71                    .flat_map(SubscriptionDefinition::derived_features),
72            )
73            .chain(
74                self.schedules
75                    .iter()
76                    .flat_map(ScheduleDefinition::derived_features),
77            )
78            .chain(self.service_features())
79            .collect()
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::manifest::fixtures::sample_manifest;
87    use crate::RoutePosture;
88    use serde_json::Value;
89
90    const SAMPLE: &str = include_str!("../fixtures/sample_manifest.json");
91    const TODO: &str = include_str!("../fixtures/todo_manifest.json");
92    const BLOG: &str = include_str!("../fixtures/blog_manifest.json");
93    /// Every shape v0.27.0 accepted, canonicalised and hashed by v0.27.0.
94    /// It wrote a subscription's `config` in an order that changed from
95    /// one process to the next; the hashes are those of the runs that
96    /// wrote it in the order of its keys, as every run does now.
97    const LEGACY_SHAPES: &str = include_str!("../fixtures/legacy_shapes_manifest.json");
98
99    fn manifest(json: &str) -> ServiceManifest {
100        serde_json::from_str(json).expect("fixture manifest")
101    }
102
103    fn route(handler: &str, posture: Option<RoutePosture>) -> CustomRouteDefinition {
104        CustomRouteDefinition {
105            method: "GET".into(),
106            path: format!("/{handler}"),
107            handler: handler.into(),
108            job: None,
109            posture,
110            forward_auth: false,
111            ..Default::default()
112        }
113    }
114
115    #[test]
116    fn sample_manifest_derives_only_route_posture() {
117        let sample = manifest(SAMPLE);
118        assert_eq!(sample.derived_features(), BTreeSet::from([ROUTE_POSTURE]));
119        assert_eq!(sample.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 loans ();".into(),
163            no_transaction: false,
164        }
165    }
166
167    fn stream() -> crate::StreamDefinition {
168        serde_json::from_value(serde_json::json!({
169            "name": "LOANS", "subjects": ["loans.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": "loans", "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(SAMPLE);
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("loan_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            route(
400                "/custom_routes/0/max_concurrent",
401                |r| r.max_concurrent = Some(4),
402                vec![ROUTE_CONCURRENCY],
403            ),
404            table(
405                "/tables/0/pagination",
406                |t| t.pagination = Pagination::Keyset,
407                vec![KEYSET_LISTS],
408            ),
409            table(
410                "/tables/0/columns/1/access",
411                |t| {
412                    t.columns[1].access = Some(crate::ColumnAccess {
413                        read: Some("internal".into()),
414                        write: None,
415                    })
416                },
417                vec![COLUMN_PERMISSIONS],
418            ),
419            service(
420                "/private_tables",
421                ServiceManifest {
422                    private_tables: vec![private_table()],
423                    ..base()
424                },
425                vec![PRIVATE_TABLES, SCHEMA_ADOPTED],
426            ),
427        ]
428    }
429
430    fn json(manifest: &ServiceManifest) -> Value {
431        serde_json::to_value(manifest).expect("serialize")
432    }
433
434    #[test]
435    fn every_r3_field_is_absent_until_it_is_set() {
436        for (pointer, before, after, _) in r3_fields() {
437            assert_eq!(json(&before).pointer(pointer), None, "{pointer}");
438            assert!(json(&after).pointer(pointer).is_some(), "{pointer}");
439        }
440    }
441
442    #[test]
443    fn every_r3_field_changes_the_hash_and_round_trips() {
444        for (pointer, before, after, _) in r3_fields() {
445            assert_ne!(
446                after.deploy_hash().expect("hash"),
447                before.deploy_hash().expect("hash"),
448                "{pointer}"
449            );
450            let wire = json(&after);
451            let reparsed: ServiceManifest = serde_json::from_value(wire.clone()).expect("parse");
452            assert_eq!(json(&reparsed), wire, "{pointer}");
453        }
454    }
455
456    #[test]
457    fn every_r3_field_demands_its_features() {
458        for (pointer, before, after, demanded) in r3_fields() {
459            assert_eq!(
460                before.derived_features(),
461                BTreeSet::from([ROUTE_POSTURE]),
462                "{pointer}"
463            );
464            let expected: BTreeSet<&str> = demanded.into_iter().chain([ROUTE_POSTURE]).collect();
465            assert_eq!(after.derived_features(), expected, "{pointer}");
466        }
467    }
468
469    #[test]
470    fn deploy_hash_unchanged_for_legacy_manifest() {
471        for (json, deploy, schema) in [
472            (
473                SAMPLE,
474                "eee68954141745273b4157ffa4da676f33db4a908f63949bc9a4d7a2a437a17e",
475                "c1bfff7742704c2d5c1849a6eb964cece595080d25bcddf9e899dbd137ce5978",
476            ),
477            (
478                TODO,
479                "927dfafc66a426e311dc92eee91f2964e6ad450f7d57266a8cab4300a1359bab",
480                "aa0463d8a7e1fd9e3c6bca62fa0480e4ee6ce4083d17441f02061dca699e9907",
481            ),
482            (
483                BLOG,
484                "9d931c651c3bff3d9be5105b7867ffae01913757bdd8db0a13b1337dd17b7ae5",
485                "98cd636af82779af1d42ccc7f7daff5b1fd0e70cb553a15233ce4a4e3655e43f",
486            ),
487            (
488                LEGACY_SHAPES,
489                "6bcd77c75c7980173facd21b6032b82636e6ec4119c0c6636acd1a67a1f2343a",
490                "11dc0ab100791653dc63e43a52d5c870ccf2a9d6997f64775664887400107439",
491            ),
492        ] {
493            let m = manifest(json);
494            assert_eq!(m.deploy_hash().expect("hash"), deploy, "{}", m.name);
495            assert_eq!(m.schema_hash().expect("schema hash"), schema, "{}", m.name);
496        }
497    }
498
499    #[test]
500    fn a_manifest_without_private_tables_serialises_as_before() {
501        for json in [SAMPLE, TODO, BLOG, LEGACY_SHAPES] {
502            let stored: Value = serde_json::from_str(json).expect("fixture json");
503            let m = manifest(json);
504            assert!(m.private_tables.is_empty(), "{}", m.name);
505            let wire = serde_json::to_value(&m).expect("serialize");
506            assert_eq!(wire.get("private_tables"), None, "{}", m.name);
507            assert_eq!(wire, stored, "{}", m.name);
508            assert_eq!(m.declared_tables().count(), m.tables.len(), "{}", m.name);
509        }
510    }
511
512    #[test]
513    fn private_tables_demand_their_feature() {
514        let mut m = sample_manifest();
515        assert!(m.derived_features().is_empty());
516        m.private_tables = vec![private_table()];
517        assert_eq!(
518            m.derived_features(),
519            BTreeSet::from([PRIVATE_TABLES, SCHEMA_ADOPTED])
520        );
521        m.private_tables[0].columns[0].column_type = crate::ColumnType::JsonbArray;
522        assert_eq!(
523            m.derived_features(),
524            BTreeSet::from([COLUMN_TYPES_V2, PRIVATE_TABLES, SCHEMA_ADOPTED])
525        );
526    }
527
528    #[test]
529    fn legacy_manifest_json_round_trips_unchanged() {
530        for json in [SAMPLE, TODO, BLOG, LEGACY_SHAPES] {
531            let stored: Value = serde_json::from_str(json).expect("fixture json");
532            let reserialized = serde_json::to_value(manifest(json)).expect("serialize");
533            assert_eq!(reserialized, stored);
534        }
535    }
536
537    #[test]
538    fn a_legacy_manifest_demands_what_it_demanded_of_v0_27_0() {
539        assert_eq!(
540            manifest(LEGACY_SHAPES).derived_features(),
541            BTreeSet::from([
542                "api_key_posture",
543                "error_profile",
544                "internal_posture",
545                "route_limits",
546                "route_matching",
547                "route_posture",
548                "scoped_permissions",
549                "scoped_posture",
550                "sse_bridge",
551            ])
552        );
553    }
554}