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