hydracache-client-protocol 0.49.0

Stable external client protocol primitives for HydraCache.
Documentation
use std::fs;
use std::path::PathBuf;

use hydracache_client_protocol::java_migration::{
    java_exception_mapping, JavaClientRuntimeConfig, JavaClientTopology, JavaCodecDescriptor,
    JavaCodecKind, JavaCodecRegistryContract, JavaExceptionKind, JavaMapOperation,
    JavaMapProtocolFamily, JavaMigrationContractError, SpringCacheMode,
    UnsupportedHazelcastApiManifest, JAVA_MIGRATION_CONTRACT_VERSION,
    SUPPORTED_SPRING_BOOT_GENERATIONS,
};
use hydracache_client_protocol::{ClientErrorCode, ClientErrorEnvelope};

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .canonicalize()
        .expect("repo root")
}

mod java_migration_contract {
    use super::*;

    #[test]
    fn protocol_errors_map_to_documented_java_exception_kinds() {
        let cases = [
            (
                ClientErrorCode::IncompatibleVersion,
                false,
                JavaExceptionKind::Protocol,
                "HydraCacheProtocolException",
            ),
            (
                ClientErrorCode::Unauthenticated,
                false,
                JavaExceptionKind::Authentication,
                "HydraCacheAuthenticationException",
            ),
            (
                ClientErrorCode::Unauthorized,
                false,
                JavaExceptionKind::Authorization,
                "HydraCacheAuthorizationException",
            ),
            (
                ClientErrorCode::TenantQuota,
                true,
                JavaExceptionKind::QuotaExceeded,
                "HydraCacheQuotaExceededException",
            ),
            (
                ClientErrorCode::RateLimited,
                true,
                JavaExceptionKind::RateLimited,
                "HydraCacheRateLimitedException",
            ),
            (
                ClientErrorCode::ResidencyDenied,
                false,
                JavaExceptionKind::ResidencyDenied,
                "HydraCacheResidencyDeniedException",
            ),
            (
                ClientErrorCode::TooLarge,
                false,
                JavaExceptionKind::PayloadTooLarge,
                "HydraCachePayloadTooLargeException",
            ),
            (
                ClientErrorCode::DeadlineExceeded,
                true,
                JavaExceptionKind::Timeout,
                "HydraCacheTimeoutException",
            ),
            (
                ClientErrorCode::Conflict,
                false,
                JavaExceptionKind::Conflict,
                "HydraCacheConflictException",
            ),
            (
                ClientErrorCode::BackendUnavailable,
                true,
                JavaExceptionKind::BackendUnavailable,
                "HydraCacheBackendUnavailableException",
            ),
            (
                ClientErrorCode::MalformedFrame,
                false,
                JavaExceptionKind::MalformedFrame,
                "HydraCacheMalformedFrameException",
            ),
        ];

        for (code, retryable, kind, class_name) in cases {
            let error =
                ClientErrorEnvelope::new(code, retryable, "redacted").with_retry_after_ms(25);
            let mapping = java_exception_mapping(&error);

            assert_eq!(mapping.kind, kind, "{code:?}");
            assert_eq!(mapping.class_name, class_name, "{code:?}");
            assert_eq!(mapping.retryable, retryable, "{code:?}");
            assert!(mapping.preserves_request_id, "{code:?}");
            assert!(mapping.preserves_retry_after, "{code:?}");
        }
    }

    #[test]
    fn codec_registry_contract_rejects_ambiguous_or_reflective_serializer() {
        let user_codec =
            JavaCodecDescriptor::explicit("user-profile-v1", "com.acme.UserProfile", 1).unwrap();
        let mut registry = JavaCodecRegistryContract::new();
        registry.register(user_codec.clone()).unwrap();

        assert_eq!(registry.len(), 1);
        assert_eq!(registry.get("user-profile-v1"), Some(&user_codec));

        let duplicate =
            JavaCodecDescriptor::explicit("user-profile-v1", "com.acme.OtherProfile", 1).unwrap();
        let duplicate_error = registry.register(duplicate).unwrap_err();
        assert_eq!(
            duplicate_error,
            JavaMigrationContractError::AmbiguousCodec {
                codec_id: "user-profile-v1".to_owned()
            }
        );

        let reflective = JavaCodecDescriptor::new(
            "profile-reflective",
            "com.acme.UserProfile",
            1,
            JavaCodecKind::ReflectiveFallback,
        )
        .unwrap();
        assert_eq!(
            JavaCodecRegistryContract::new()
                .register(reflective)
                .unwrap_err(),
            JavaMigrationContractError::UnsupportedSerializer("reflective-fallback")
        );

        let native = JavaCodecDescriptor::new(
            "profile-native",
            "com.acme.UserProfile",
            1,
            JavaCodecKind::JavaNativeSerialization,
        )
        .unwrap();
        assert_eq!(
            JavaCodecRegistryContract::new()
                .register(native)
                .unwrap_err(),
            JavaMigrationContractError::UnsupportedSerializer("java-native-serialization")
        );

        let bridge = JavaCodecDescriptor::new(
            "legacy-user-profile",
            "com.acme.LegacyUserProfile",
            1,
            JavaCodecKind::LegacySerializerBridge,
        )
        .unwrap();
        assert_eq!(
            JavaCodecRegistryContract::new()
                .register(bridge.clone())
                .unwrap_err(),
            JavaMigrationContractError::LegacySerializerBridgeDisabled(
                "legacy-user-profile".to_owned()
            )
        );

        let mut migration_registry =
            JavaCodecRegistryContract::new().with_legacy_serializer_bridge_enabled();
        migration_registry.register(bridge).unwrap();
        assert_eq!(migration_registry.len(), 1);
    }

    #[test]
    fn unsupported_hazelcast_api_surface_is_a_checked_in_manifest() {
        let manifest = UnsupportedHazelcastApiManifest::checked_in().unwrap();

        assert_eq!(manifest.version, JAVA_MIGRATION_CONTRACT_VERSION);
        assert!(manifest.entries.len() >= 8);
        for api in [
            "HazelcastInstance.getCPSubsystem",
            "HazelcastInstance.getExecutorService",
            "HazelcastInstance.getSql",
            "IMap.lock",
            "IMap.executeOnKey",
            "ReplicatedMap",
            "ReliableTopic",
            "FencedLock",
        ] {
            let entry = manifest.find(api).expect("manifest entry");
            assert!(
                !entry.migration_hint.trim().is_empty(),
                "{api} should have a migration hint"
            );
        }

        let future_error =
            UnsupportedHazelcastApiManifest::parse("version=2\nIMap.lock|hint").unwrap_err();
        assert_eq!(
            future_error,
            JavaMigrationContractError::UnsupportedManifestVersion {
                actual: 2,
                supported: JAVA_MIGRATION_CONTRACT_VERSION
            }
        );

        let root = repo_root();
        let manifest_file = fs::read_to_string(
            root.join("crates/hydracache-client-protocol/manifests/unsupported_hazelcast_apis.txt"),
        )
        .expect("unsupported Hazelcast manifest");
        let docs = fs::read_to_string(root.join("docs/integrations/java-migration.md"))
            .expect("java migration docs");
        let compat = fs::read_to_string(root.join("docs/COMPAT.md")).expect("compat");

        assert!(manifest_file.contains("IMap.lock|"));
        assert!(docs.contains("Java/Spring Migration Contract"));
        assert!(docs.contains("HydraCacheMap<String, UserProfile>"));
        assert!(docs.contains("mode: native"));
        assert!(compat.contains("Java migration toolkit contract"));
        assert!(compat.contains("unsupported_hazelcast_apis.txt"));
    }

    #[test]
    fn java_runtime_config_modes_and_facade_contract_are_reviewable() {
        assert_eq!(SUPPORTED_SPRING_BOOT_GENERATIONS, &[2, 3, 4]);

        let config = JavaClientRuntimeConfig::client_first(
            [
                "https://cache-a.internal:8443",
                "https://cache-b.internal:8443",
            ],
            "core",
            "gameservice",
        );
        config.validate().unwrap();
        assert!(config.smart_routing);
        assert!(config.customizer_hooks_enabled);
        assert_eq!(config.deadline_ms, 5_000);

        let mut member = config.clone();
        member.topology = JavaClientTopology::Member;
        assert_eq!(
            member.validate().unwrap_err(),
            JavaMigrationContractError::UnsupportedClientTopology("member")
        );

        assert!(SpringCacheMode::Native.lazy_dynamic_cache_names());
        assert!(SpringCacheMode::JCache.requires_jcache_provider());
        assert!(!SpringCacheMode::None.lazy_dynamic_cache_names());

        assert_eq!(
            JavaMapOperation::Get.protocol_family(),
            JavaMapProtocolFamily::Get
        );
        assert_eq!(
            JavaMapOperation::ContainsKey.protocol_family(),
            JavaMapProtocolFamily::Get
        );
        assert_eq!(
            JavaMapOperation::PutIfAbsent.protocol_family(),
            JavaMapProtocolFamily::ConditionalPutIfAbsent
        );
        assert_eq!(
            JavaMapOperation::Remove.protocol_family(),
            JavaMapProtocolFamily::Invalidate
        );
        assert_eq!(
            JavaMapOperation::GetAll.protocol_family(),
            JavaMapProtocolFamily::BatchGet
        );
        assert_eq!(
            JavaMapOperation::PutAll.protocol_family(),
            JavaMapProtocolFamily::BatchPut
        );
        assert_eq!(
            JavaMapOperation::EvictRegion.protocol_family(),
            JavaMapProtocolFamily::EvictRegion
        );
    }
}