cufflink-types 0.11.0

Shared type definitions for the Cufflink CRUD microservice platform
Documentation
//! Platform capability names: what `/api/version` advertises in `supports`
//! and a manifest may demand in `required_platform_features`.

use crate::{CustomRouteDefinition, ServiceManifest, TableDefinition};
use std::collections::BTreeSet;

pub const ROUTE_POSTURE: &str = "route_posture";
pub const DB_V2: &str = "db_v2";
pub const CACHE: &str = "cache";
pub const SERVICE_CALL: &str = "service_call";
pub const RESPONSE_V2: &str = "response_v2";
pub const SSE_BRIDGE: &str = "sse_bridge";
pub const ROUTE_MATCHING: &str = "route_matching";
pub const ROUTE_LIMITS: &str = "route_limits";
pub const ERROR_PROFILE: &str = "error_profile";
pub const INTERNAL_POSTURE: &str = "internal_posture";
pub const API_KEY_POSTURE: &str = "api_key_posture";
pub const SCOPED_PERMISSIONS: &str = "scoped_permissions";
pub const SCOPED_POSTURE: &str = "scoped_posture";
pub const TYPED_ROUTES: &str = "typed_routes";

impl ServiceManifest {
    /// Features this manifest's declared shape requires, sorted and
    /// deduplicated. A manifest that needs a capability always demands it, so
    /// a platform too old to provide it rejects the deploy instead of silently
    /// running the service without it.
    pub fn derived_features(&self) -> BTreeSet<&'static str> {
        self.custom_routes
            .iter()
            .flat_map(CustomRouteDefinition::derived_features)
            .chain(
                self.tables
                    .iter()
                    .flat_map(TableDefinition::derived_features),
            )
            .chain(self.service_features())
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::manifest::fixtures::sample_manifest;
    use crate::RoutePosture;
    use serde_json::Value;

    const STARMOIRE: &str = include_str!("../fixtures/starmoire_manifest.json");
    const TODO: &str = include_str!("../fixtures/todo_manifest.json");
    const BLOG: &str = include_str!("../fixtures/blog_manifest.json");

    fn manifest(json: &str) -> ServiceManifest {
        serde_json::from_str(json).expect("fixture manifest")
    }

    fn route(handler: &str, posture: Option<RoutePosture>) -> CustomRouteDefinition {
        CustomRouteDefinition {
            method: "GET".into(),
            path: format!("/{handler}"),
            handler: handler.into(),
            job: None,
            posture,
            forward_auth: false,
            ..Default::default()
        }
    }

    #[test]
    fn starmoire_manifest_derives_only_route_posture() {
        let starmoire = manifest(STARMOIRE);
        assert_eq!(
            starmoire.derived_features(),
            BTreeSet::from([ROUTE_POSTURE])
        );
        assert_eq!(starmoire.required_platform_features, vec![ROUTE_POSTURE]);
        for crud_only in [TODO, BLOG] {
            assert!(manifest(crud_only).derived_features().is_empty());
        }
    }

    #[test]
    fn route_posture_is_derived_once_from_any_declared_posture() {
        let mut m = sample_manifest();
        assert!(m.derived_features().is_empty());
        m.custom_routes.push(route("legacy", None));
        assert!(m.derived_features().is_empty());
        m.custom_routes
            .push(route("me", Some(RoutePosture::Authenticated)));
        m.custom_routes.push(route(
            "hook",
            Some(RoutePosture::Public {
                reason: "signed webhook".into(),
            }),
        ));
        assert_eq!(m.derived_features(), BTreeSet::from([ROUTE_POSTURE]));
    }

    #[test]
    fn strict_routing_demands_route_matching() {
        let mut m = sample_manifest();
        m.routing = crate::RoutingMode::Strict;
        assert_eq!(m.derived_features(), BTreeSet::from([ROUTE_MATCHING]));
    }

    #[test]
    fn an_error_profile_demands_error_profile() {
        let mut m = sample_manifest();
        m.errors = Some(crate::ErrorProfile {
            key: "error_message".into(),
        });
        assert_eq!(m.derived_features(), BTreeSet::from([ERROR_PROFILE]));
    }

    #[test]
    fn deploy_hash_unchanged_for_legacy_manifest() {
        for (json, deploy, schema) in [
            (
                STARMOIRE,
                "32b79ba0e4b4697a505007ae19a1ab55a1f4fbefb26fe8fcd86a53847eccba28",
                "422f205e16c949bd532f63707b78d8f6ccc4c9ba4eda0b2bd275c479a8d998f2",
            ),
            (
                TODO,
                "927dfafc66a426e311dc92eee91f2964e6ad450f7d57266a8cab4300a1359bab",
                "aa0463d8a7e1fd9e3c6bca62fa0480e4ee6ce4083d17441f02061dca699e9907",
            ),
            (
                BLOG,
                "9d931c651c3bff3d9be5105b7867ffae01913757bdd8db0a13b1337dd17b7ae5",
                "98cd636af82779af1d42ccc7f7daff5b1fd0e70cb553a15233ce4a4e3655e43f",
            ),
        ] {
            let m = manifest(json);
            assert_eq!(m.deploy_hash().expect("hash"), deploy, "{}", m.name);
            assert_eq!(m.schema_hash().expect("schema hash"), schema, "{}", m.name);
        }
    }

    #[test]
    fn legacy_manifest_json_round_trips_unchanged() {
        for json in [STARMOIRE, TODO, BLOG] {
            let stored: Value = serde_json::from_str(json).expect("fixture json");
            let reserialized = serde_json::to_value(manifest(json)).expect("serialize");
            assert_eq!(reserialized, stored);
        }
    }
}