// file: kb_rpc/src/endpoint_role.rs // version: 2 //! Endpoint role helpers shared by HTTP and WebSocket pools. /// Snapshot of one endpoint role and its local limits. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct EndpointRoleSnapshot { /// Role code used by endpoint pools. pub role: std::string::String, /// Enables this role on the endpoint. pub enabled: bool, /// Request or subscription kinds handled by this role. pub request_kinds: std::vec::Vec, /// Role priority where lower values are preferred. pub priority: u32, /// Requests per second allowed for this role on this URL. pub requests_per_second: u32, /// Burst capacity allowed for this role on this URL. pub burst_capacity: u32, /// Maximum concurrent requests allowed for this role on this URL. pub max_concurrent_requests: u32, /// Maximum subscriptions allowed for this role on this URL. pub max_subscriptions: u32, /// Pause after a rate limit response in milliseconds. pub pause_after_rate_limit_ms: u64, } impl crate::EndpointRoleSnapshot { /// Builds a serializable snapshot from configuration. pub fn from_config(config: &kb_config::EndpointRoleConfig) -> Self { return Self { role: config.role.clone(), enabled: config.enabled, request_kinds: config.request_kinds.clone(), priority: config.priority, requests_per_second: config.requests_per_second, burst_capacity: config.burst_capacity, max_concurrent_requests: config.max_concurrent_requests, max_subscriptions: config.max_subscriptions, pause_after_rate_limit_ms: config.pause_after_rate_limit_ms, }; } } /// Converts a JSON-RPC method name into a stable snake_case request kind. pub fn request_kind_from_method(method: &str) -> std::string::String { let trimmed = method.trim(); if trimmed == "logsSubscribe" { return "logs_subscribe_mentions".to_string(); } return crate::endpoint_role::camel_or_pascal_to_snake(trimmed); } /// Returns true when one endpoint role can handle the requested role and kind. pub(crate) fn role_matches( role_config: &kb_config::EndpointRoleConfig, required_role: &str, request_kind: &str, ) -> bool { if !role_config.enabled { return false; } if role_config.role != required_role { return false; } for configured_kind in &role_config.request_kinds { if configured_kind == request_kind { return true; } if configured_kind == "*" { return true; } } return false; } fn camel_or_pascal_to_snake(value: &str) -> std::string::String { let mut output = std::string::String::new(); let mut previous_was_lower_or_digit = false; for character in value.chars() { if character == '-' || character == ' ' || character == '.' { if !output.ends_with('_') && !output.is_empty() { output.push('_'); } previous_was_lower_or_digit = false; continue; } if character.is_ascii_uppercase() { if previous_was_lower_or_digit && !output.ends_with('_') && !output.is_empty() { output.push('_'); } output.push(character.to_ascii_lowercase()); previous_was_lower_or_digit = false; continue; } if character == '_' { if !output.ends_with('_') && !output.is_empty() { output.push('_'); } previous_was_lower_or_digit = false; continue; } output.push(character); previous_was_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit(); } return output.trim_matches('_').to_string(); } #[cfg(test)] mod tests { fn role_config( role: &str, enabled: bool, request_kinds: std::vec::Vec, ) -> kb_config::EndpointRoleConfig { return kb_config::EndpointRoleConfig { role: role.to_string(), enabled, request_kinds, priority: 1, requests_per_second: 10, burst_capacity: 10, max_concurrent_requests: 4, max_subscriptions: 16, pause_after_rate_limit_ms: 1500, }; } #[test] fn request_kind_converts_camel_case_methods() { assert_eq!(crate::request_kind_from_method("getLatestBlockhash"), "get_latest_blockhash"); assert_eq!(crate::request_kind_from_method("sendTransaction"), "send_transaction"); } #[test] fn request_kind_keeps_existing_snake_case_methods() { assert_eq!(crate::request_kind_from_method("get_latest_blockhash"), "get_latest_blockhash"); assert_eq!( crate::request_kind_from_method("logs_subscribe_mentions"), "logs_subscribe_mentions" ); } #[test] fn request_kind_normalizes_separators() { assert_eq!(crate::request_kind_from_method("get-Block"), "get_block"); assert_eq!(crate::request_kind_from_method("program.Subscribe"), "program_subscribe"); } #[test] fn request_kind_maps_logs_subscribe_to_mentions_role_kind() { assert_eq!(crate::request_kind_from_method("logsSubscribe"), "logs_subscribe_mentions"); } #[test] fn role_matches_exact_request_kind() { let role = role_config("http_queries", true, std::vec!["get_version".to_string()]); assert!(crate::endpoint_role::role_matches(&role, "http_queries", "get_version")); } #[test] fn role_matches_wildcard_request_kind() { let role = role_config("http_queries", true, std::vec!["*".to_string()]); assert!(crate::endpoint_role::role_matches(&role, "http_queries", "get_block")); } #[test] fn role_does_not_match_when_disabled() { let role = role_config("http_queries", false, std::vec!["*".to_string()]); assert!(!crate::endpoint_role::role_matches(&role, "http_queries", "get_version")); } #[test] fn role_does_not_match_different_role() { let role = role_config("http_queries", true, std::vec!["*".to_string()]); assert!(!crate::endpoint_role::role_matches(&role, "http_heavy", "get_block")); } #[test] fn endpoint_role_snapshot_preserves_limits() { let role = role_config("http_queries", true, std::vec!["get_version".to_string()]); let snapshot = crate::EndpointRoleSnapshot::from_config(&role); assert_eq!(snapshot.role, "http_queries"); assert_eq!(snapshot.request_kinds, std::vec!["get_version".to_string()]); assert_eq!(snapshot.requests_per_second, 10); assert_eq!(snapshot.max_subscriptions, 16); } }