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::{CustomRouteDefinition, ServiceManifest, TableDefinition};
5use std::collections::BTreeSet;
6
7pub const ROUTE_POSTURE: &str = "route_posture";
8pub const DB_V2: &str = "db_v2";
9pub const CACHE: &str = "cache";
10pub const SERVICE_CALL: &str = "service_call";
11pub const RESPONSE_V2: &str = "response_v2";
12pub const SSE_BRIDGE: &str = "sse_bridge";
13pub const ROUTE_MATCHING: &str = "route_matching";
14pub const ROUTE_LIMITS: &str = "route_limits";
15pub const ERROR_PROFILE: &str = "error_profile";
16pub const INTERNAL_POSTURE: &str = "internal_posture";
17pub const API_KEY_POSTURE: &str = "api_key_posture";
18pub const SCOPED_PERMISSIONS: &str = "scoped_permissions";
19pub const SCOPED_POSTURE: &str = "scoped_posture";
20pub const TYPED_ROUTES: &str = "typed_routes";
21
22impl ServiceManifest {
23    /// Features this manifest's declared shape requires, sorted and
24    /// deduplicated. A manifest that needs a capability always demands it, so
25    /// a platform too old to provide it rejects the deploy instead of silently
26    /// running the service without it.
27    pub fn derived_features(&self) -> BTreeSet<&'static str> {
28        self.custom_routes
29            .iter()
30            .flat_map(CustomRouteDefinition::derived_features)
31            .chain(
32                self.tables
33                    .iter()
34                    .flat_map(TableDefinition::derived_features),
35            )
36            .chain(self.service_features())
37            .collect()
38    }
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44    use crate::manifest::fixtures::sample_manifest;
45    use crate::RoutePosture;
46    use serde_json::Value;
47
48    const STARMOIRE: &str = include_str!("../fixtures/starmoire_manifest.json");
49    const TODO: &str = include_str!("../fixtures/todo_manifest.json");
50    const BLOG: &str = include_str!("../fixtures/blog_manifest.json");
51
52    fn manifest(json: &str) -> ServiceManifest {
53        serde_json::from_str(json).expect("fixture manifest")
54    }
55
56    fn route(handler: &str, posture: Option<RoutePosture>) -> CustomRouteDefinition {
57        CustomRouteDefinition {
58            method: "GET".into(),
59            path: format!("/{handler}"),
60            handler: handler.into(),
61            job: None,
62            posture,
63            forward_auth: false,
64            ..Default::default()
65        }
66    }
67
68    #[test]
69    fn starmoire_manifest_derives_only_route_posture() {
70        let starmoire = manifest(STARMOIRE);
71        assert_eq!(
72            starmoire.derived_features(),
73            BTreeSet::from([ROUTE_POSTURE])
74        );
75        assert_eq!(starmoire.required_platform_features, vec![ROUTE_POSTURE]);
76        for crud_only in [TODO, BLOG] {
77            assert!(manifest(crud_only).derived_features().is_empty());
78        }
79    }
80
81    #[test]
82    fn route_posture_is_derived_once_from_any_declared_posture() {
83        let mut m = sample_manifest();
84        assert!(m.derived_features().is_empty());
85        m.custom_routes.push(route("legacy", None));
86        assert!(m.derived_features().is_empty());
87        m.custom_routes
88            .push(route("me", Some(RoutePosture::Authenticated)));
89        m.custom_routes.push(route(
90            "hook",
91            Some(RoutePosture::Public {
92                reason: "signed webhook".into(),
93            }),
94        ));
95        assert_eq!(m.derived_features(), BTreeSet::from([ROUTE_POSTURE]));
96    }
97
98    #[test]
99    fn strict_routing_demands_route_matching() {
100        let mut m = sample_manifest();
101        m.routing = crate::RoutingMode::Strict;
102        assert_eq!(m.derived_features(), BTreeSet::from([ROUTE_MATCHING]));
103    }
104
105    #[test]
106    fn an_error_profile_demands_error_profile() {
107        let mut m = sample_manifest();
108        m.errors = Some(crate::ErrorProfile {
109            key: "error_message".into(),
110        });
111        assert_eq!(m.derived_features(), BTreeSet::from([ERROR_PROFILE]));
112    }
113
114    #[test]
115    fn deploy_hash_unchanged_for_legacy_manifest() {
116        for (json, deploy, schema) in [
117            (
118                STARMOIRE,
119                "32b79ba0e4b4697a505007ae19a1ab55a1f4fbefb26fe8fcd86a53847eccba28",
120                "422f205e16c949bd532f63707b78d8f6ccc4c9ba4eda0b2bd275c479a8d998f2",
121            ),
122            (
123                TODO,
124                "927dfafc66a426e311dc92eee91f2964e6ad450f7d57266a8cab4300a1359bab",
125                "aa0463d8a7e1fd9e3c6bca62fa0480e4ee6ce4083d17441f02061dca699e9907",
126            ),
127            (
128                BLOG,
129                "9d931c651c3bff3d9be5105b7867ffae01913757bdd8db0a13b1337dd17b7ae5",
130                "98cd636af82779af1d42ccc7f7daff5b1fd0e70cb553a15233ce4a4e3655e43f",
131            ),
132        ] {
133            let m = manifest(json);
134            assert_eq!(m.deploy_hash().expect("hash"), deploy, "{}", m.name);
135            assert_eq!(m.schema_hash().expect("schema hash"), schema, "{}", m.name);
136        }
137    }
138
139    #[test]
140    fn legacy_manifest_json_round_trips_unchanged() {
141        for json in [STARMOIRE, TODO, BLOG] {
142            let stored: Value = serde_json::from_str(json).expect("fixture json");
143            let reserialized = serde_json::to_value(manifest(json)).expect("serialize");
144            assert_eq!(reserialized, stored);
145        }
146    }
147}