use std::time::{Duration, Instant};
use crate::types::{ConsistencyToken, Context, Decision, Relationship};
use crate::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Transport {
#[default]
Grpc,
Http,
Mock,
}
impl Transport {
pub fn is_grpc(&self) -> bool {
matches!(self, Transport::Grpc)
}
pub fn is_http(&self) -> bool {
matches!(self, Transport::Http)
}
pub fn is_mock(&self) -> bool {
matches!(self, Transport::Mock)
}
}
impl std::fmt::Display for Transport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Transport::Grpc => write!(f, "gRPC"),
Transport::Http => write!(f, "HTTP/REST"),
Transport::Mock => write!(f, "Mock"),
}
}
}
#[derive(Debug, Clone)]
pub enum TransportStrategy {
GrpcOnly,
RestOnly,
PreferGrpc {
fallback_on: FallbackTrigger,
},
PreferRest {
fallback_on: FallbackTrigger,
},
}
impl Default for TransportStrategy {
fn default() -> Self {
TransportStrategy::PreferGrpc {
fallback_on: FallbackTrigger::default(),
}
}
}
impl TransportStrategy {
pub fn preferred_transport(&self) -> Transport {
match self {
TransportStrategy::GrpcOnly | TransportStrategy::PreferGrpc { .. } => Transport::Grpc,
TransportStrategy::RestOnly | TransportStrategy::PreferRest { .. } => Transport::Http,
}
}
pub fn fallback_transport(&self) -> Option<Transport> {
match self {
TransportStrategy::GrpcOnly | TransportStrategy::RestOnly => None,
TransportStrategy::PreferGrpc { .. } => Some(Transport::Http),
TransportStrategy::PreferRest { .. } => Some(Transport::Grpc),
}
}
pub fn has_fallback(&self) -> bool {
self.fallback_transport().is_some()
}
}
#[derive(Debug, Clone)]
pub struct FallbackTrigger {
pub connection_error: bool,
pub protocol_error: bool,
pub status_codes: Vec<u16>,
pub connect_timeout: bool,
}
impl Default for FallbackTrigger {
fn default() -> Self {
Self {
connection_error: true,
protocol_error: true,
status_codes: vec![502, 503],
connect_timeout: true,
}
}
}
impl FallbackTrigger {
pub fn on_any_error() -> Self {
Self {
connection_error: true,
protocol_error: true,
status_codes: vec![500, 502, 503, 504],
connect_timeout: true,
}
}
pub fn on_connection_only() -> Self {
Self {
connection_error: true,
protocol_error: false,
status_codes: vec![],
connect_timeout: true,
}
}
pub fn should_fallback_on_status(&self, status: u16) -> bool {
self.status_codes.contains(&status)
}
}
#[derive(Debug, Clone, Default)]
pub struct TransportStats {
pub active_transport: Transport,
pub fallback_count: u64,
pub last_fallback_reason: Option<FallbackReason>,
pub last_fallback_at: Option<Instant>,
pub grpc: Option<GrpcStats>,
pub rest: Option<RestStats>,
}
#[derive(Debug, Clone)]
pub enum FallbackReason {
ConnectionRefused,
ProtocolError(String),
StatusCode(u16),
ConnectTimeout,
}
impl std::fmt::Display for FallbackReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FallbackReason::ConnectionRefused => write!(f, "connection refused"),
FallbackReason::ProtocolError(msg) => write!(f, "protocol error: {}", msg),
FallbackReason::StatusCode(code) => write!(f, "HTTP status {}", code),
FallbackReason::ConnectTimeout => write!(f, "connect timeout"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct GrpcStats {
pub requests_sent: u64,
pub requests_failed: u64,
pub streams_opened: u64,
pub streams_active: u32,
}
#[derive(Debug, Clone, Default)]
pub struct RestStats {
pub requests_sent: u64,
pub requests_failed: u64,
pub sse_connections: u64,
pub sse_active: u32,
}
#[derive(Debug, Clone)]
pub enum TransportEvent {
FallbackTriggered {
from: Transport,
to: Transport,
reason: FallbackReason,
},
Restored {
transport: Transport,
},
}
impl std::fmt::Display for TransportEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransportEvent::FallbackTriggered { from, to, reason } => {
write!(f, "fallback {} -> {}: {}", from, to, reason)
}
TransportEvent::Restored { transport } => {
write!(f, "restored {}", transport)
}
}
}
}
#[derive(Debug, Clone)]
pub struct CheckRequest {
pub subject: String,
pub permission: String,
pub resource: String,
pub context: Option<Context>,
pub consistency: Option<ConsistencyToken>,
pub trace: bool,
}
#[derive(Debug, Clone)]
pub struct CheckResponse {
pub allowed: bool,
pub decision: Decision,
pub trace: Option<DecisionTrace>,
}
#[derive(Debug, Clone)]
pub struct DecisionTrace {
pub duration_micros: u64,
pub relationships_read: u64,
pub relations_evaluated: u64,
pub root: Option<EvaluationNode>,
}
#[derive(Debug, Clone)]
pub struct EvaluationNode {
pub node_type: EvaluationNodeType,
pub result: bool,
pub children: Vec<EvaluationNode>,
}
#[derive(Debug, Clone)]
pub enum EvaluationNodeType {
DirectCheck {
resource: String,
relation: String,
subject: String,
},
ComputedUserset { relation: String },
RelatedObjectUserset {
relationship: String,
computed: String,
},
Union,
Intersection,
Exclusion,
WasmModule { module_name: String },
}
#[derive(Debug, Clone)]
pub struct WriteRequest {
pub relationship: Relationship<'static>,
pub idempotency_key: Option<String>,
}
#[derive(Debug, Clone)]
pub struct WriteResponse {
pub consistency_token: ConsistencyToken,
}
#[derive(Debug, Clone)]
pub struct SimulateRequest {
pub subject: String,
pub permission: String,
pub resource: String,
pub context: Option<Context>,
pub additions: Vec<Relationship<'static>>,
pub removals: Vec<Relationship<'static>>,
}
#[derive(Debug, Clone)]
pub struct SimulateResponse {
pub allowed: bool,
pub decision: Decision,
}
#[async_trait::async_trait]
pub trait TransportClient: Send + Sync {
async fn check(&self, request: CheckRequest) -> Result<CheckResponse, Error>;
async fn check_batch(&self, requests: Vec<CheckRequest>) -> Result<Vec<CheckResponse>, Error>;
async fn write(&self, request: WriteRequest) -> Result<WriteResponse, Error>;
async fn write_batch(&self, requests: Vec<WriteRequest>) -> Result<WriteResponse, Error>;
async fn delete(&self, relationship: Relationship<'static>) -> Result<(), Error>;
async fn list_relationships(
&self,
resource: Option<&str>,
relation: Option<&str>,
subject: Option<&str>,
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<ListRelationshipsResponse, Error>;
async fn list_resources(
&self,
subject: &str,
permission: &str,
resource_type: Option<&str>,
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<ListResourcesResponse, Error>;
async fn list_subjects(
&self,
permission: &str,
resource: &str,
subject_type: Option<&str>,
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<ListSubjectsResponse, Error>;
fn transport_type(&self) -> Transport;
fn stats(&self) -> TransportStats;
async fn health_check(&self) -> Result<(), Error>;
async fn simulate(&self, request: SimulateRequest) -> Result<SimulateResponse, Error>;
}
#[derive(Debug, Clone)]
pub struct ListRelationshipsResponse {
pub relationships: Vec<Relationship<'static>>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ListResourcesResponse {
pub resources: Vec<String>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ListSubjectsResponse {
pub subjects: Vec<String>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PoolConfig {
pub max_connections: u32,
pub idle_timeout: Duration,
pub max_idle_per_host: u32,
pub pool_timeout: Duration,
pub http2_only: bool,
pub http2_keepalive: Duration,
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
max_connections: 100,
idle_timeout: Duration::from_secs(90),
max_idle_per_host: 10,
pool_timeout: Duration::from_secs(30),
http2_only: false,
http2_keepalive: Duration::from_secs(20),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transport_default() {
assert_eq!(Transport::default(), Transport::Grpc);
}
#[test]
fn test_transport_checks() {
assert!(Transport::Grpc.is_grpc());
assert!(!Transport::Grpc.is_http());
assert!(!Transport::Grpc.is_mock());
assert!(!Transport::Http.is_grpc());
assert!(Transport::Http.is_http());
assert!(!Transport::Http.is_mock());
assert!(!Transport::Mock.is_grpc());
assert!(!Transport::Mock.is_http());
assert!(Transport::Mock.is_mock());
}
#[test]
fn test_transport_display() {
assert_eq!(Transport::Grpc.to_string(), "gRPC");
assert_eq!(Transport::Http.to_string(), "HTTP/REST");
assert_eq!(Transport::Mock.to_string(), "Mock");
}
#[test]
fn test_transport_strategy_default() {
let strategy = TransportStrategy::default();
assert_eq!(strategy.preferred_transport(), Transport::Grpc);
assert_eq!(strategy.fallback_transport(), Some(Transport::Http));
assert!(strategy.has_fallback());
}
#[test]
fn test_transport_strategy_grpc_only() {
let strategy = TransportStrategy::GrpcOnly;
assert_eq!(strategy.preferred_transport(), Transport::Grpc);
assert_eq!(strategy.fallback_transport(), None);
assert!(!strategy.has_fallback());
}
#[test]
fn test_fallback_trigger_default() {
let trigger = FallbackTrigger::default();
assert!(trigger.connection_error);
assert!(trigger.protocol_error);
assert!(trigger.connect_timeout);
assert!(trigger.should_fallback_on_status(502));
assert!(trigger.should_fallback_on_status(503));
assert!(!trigger.should_fallback_on_status(500));
}
#[test]
fn test_fallback_reason_display() {
assert_eq!(
FallbackReason::ConnectionRefused.to_string(),
"connection refused"
);
assert_eq!(
FallbackReason::StatusCode(502).to_string(),
"HTTP status 502"
);
}
#[test]
fn test_pool_config_default() {
let config = PoolConfig::default();
assert_eq!(config.max_connections, 100);
assert_eq!(config.idle_timeout, Duration::from_secs(90));
}
#[test]
fn test_transport_event_display() {
let fallback_event = TransportEvent::FallbackTriggered {
from: Transport::Grpc,
to: Transport::Http,
reason: FallbackReason::ConnectionRefused,
};
assert!(fallback_event.to_string().contains("gRPC"));
assert!(fallback_event.to_string().contains("HTTP"));
let restored_event = TransportEvent::Restored {
transport: Transport::Grpc,
};
assert!(restored_event.to_string().contains("restored"));
assert!(restored_event.to_string().contains("gRPC"));
}
#[test]
fn test_transport_stats_default() {
let stats = TransportStats::default();
assert_eq!(stats.active_transport, Transport::default());
assert_eq!(stats.fallback_count, 0);
assert!(stats.grpc.is_none());
assert!(stats.rest.is_none());
}
#[test]
fn test_grpc_stats_default() {
let stats = GrpcStats::default();
assert_eq!(stats.requests_sent, 0);
assert_eq!(stats.requests_failed, 0);
assert_eq!(stats.streams_opened, 0);
assert_eq!(stats.streams_active, 0);
}
#[test]
fn test_rest_stats_default() {
let stats = RestStats::default();
assert_eq!(stats.requests_sent, 0);
assert_eq!(stats.requests_failed, 0);
assert_eq!(stats.sse_connections, 0);
assert_eq!(stats.sse_active, 0);
}
#[test]
fn test_fallback_reason_connect_timeout() {
assert_eq!(
FallbackReason::ConnectTimeout.to_string(),
"connect timeout"
);
}
#[test]
fn test_fallback_reason_protocol_error() {
let reason = FallbackReason::ProtocolError("invalid frame".to_string());
assert!(reason.to_string().contains("protocol error"));
assert!(reason.to_string().contains("invalid frame"));
}
#[test]
fn test_transport_strategy_rest_only() {
let strategy = TransportStrategy::RestOnly;
assert_eq!(strategy.preferred_transport(), Transport::Http);
assert_eq!(strategy.fallback_transport(), None);
assert!(!strategy.has_fallback());
}
#[test]
fn test_transport_strategy_prefer_rest() {
let strategy = TransportStrategy::PreferRest {
fallback_on: FallbackTrigger::default(),
};
assert_eq!(strategy.preferred_transport(), Transport::Http);
assert_eq!(strategy.fallback_transport(), Some(Transport::Grpc));
assert!(strategy.has_fallback());
}
#[test]
fn test_fallback_trigger_on_any_error() {
let trigger = FallbackTrigger::on_any_error();
assert!(trigger.connection_error);
assert!(trigger.protocol_error);
assert!(trigger.connect_timeout);
assert!(trigger.should_fallback_on_status(500));
assert!(trigger.should_fallback_on_status(502));
assert!(trigger.should_fallback_on_status(503));
assert!(trigger.should_fallback_on_status(504));
}
#[test]
fn test_fallback_trigger_on_connection_only() {
let trigger = FallbackTrigger::on_connection_only();
assert!(trigger.connection_error);
assert!(!trigger.protocol_error);
assert!(trigger.connect_timeout);
assert!(trigger.status_codes.is_empty());
assert!(!trigger.should_fallback_on_status(502));
}
}