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