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