use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ConnectorConfig {
Http(HttpConnectorConfig),
Kafka(KafkaConnectorConfig),
Db(DbConnectorConfig),
Cache(CacheConnectorConfig),
Es(EsConnectorConfig),
}
impl ConnectorConfig {
pub fn connector_type(&self) -> ConnectorType {
match self {
ConnectorConfig::Http(_) => ConnectorType::Http,
ConnectorConfig::Kafka(_) => ConnectorType::Kafka,
ConnectorConfig::Db(_) => ConnectorType::Db,
ConnectorConfig::Cache(_) => ConnectorType::Cache,
ConnectorConfig::Es(_) => ConnectorType::Es,
}
}
pub fn is_mongo(&self) -> bool {
matches!(self, ConnectorConfig::Db(c) if is_mongo_url(&c.connection_string))
}
pub fn operation_gates(&self) -> Option<&OperationGates> {
match self {
ConnectorConfig::Db(c) => Some(&c.operations),
ConnectorConfig::Es(c) => Some(&c.operations),
_ => None,
}
}
pub fn dialect_guards(&self) -> Option<&DialectGuards> {
match self {
ConnectorConfig::Db(c) => Some(&c.dialect),
ConnectorConfig::Es(c) => Some(&c.dialect),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DialectGuards {
pub require_schema: bool,
pub allowed_entities: Vec<String>,
}
impl DialectGuards {
pub fn schema_is_sufficient(&self, declared_entities: bool, identity_mode: bool) -> bool {
!self.require_schema || (declared_entities && !identity_mode)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct OperationGates {
pub read: bool,
pub insert: bool,
pub update: bool,
pub delete: bool,
pub upsert: bool,
pub raw_write: bool,
}
impl Default for OperationGates {
fn default() -> Self {
Self {
read: true,
insert: true,
update: true,
delete: true,
upsert: true,
raw_write: true,
}
}
}
impl OperationGates {
pub fn allows(&self, op: &str) -> bool {
match op {
"read" => self.read,
"insert" => self.insert,
"update" => self.update,
"delete" => self.delete,
"upsert" => self.upsert,
"raw_write" => self.raw_write,
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheOperationGates {
pub read: bool,
pub write: bool,
}
impl Default for CacheOperationGates {
fn default() -> Self {
Self {
read: true,
write: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct KafkaOperationGates {
pub publish: bool,
}
impl Default for KafkaOperationGates {
fn default() -> Self {
Self { publish: true }
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct HttpOperationGates {
pub methods: Vec<String>,
}
impl HttpOperationGates {
pub fn allows_method(&self, method: &str) -> bool {
self.methods.is_empty()
|| self
.methods
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(method))
}
}
pub const VALID_HTTP_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HttpConnectorConfig {
pub url: String,
#[serde(default)]
pub method: String,
#[serde(default)]
pub headers: HashMap<String, String>,
pub auth: Option<AuthConfig>,
#[serde(default)]
pub retry: RetryConfig,
#[serde(default)]
pub retry_non_idempotent: bool,
#[serde(default = "default_max_response_size")]
pub max_response_size: usize,
#[serde(default)]
pub allow_private_urls: bool,
#[serde(default)]
pub operations: HttpOperationGates,
}
fn default_max_response_size() -> usize {
10 * 1024 * 1024 }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum AuthConfig {
Bearer { token: String },
Basic { username: String, password: String },
ApiKey { header: String, key: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryConfig {
#[serde(default = "default_max_retries")]
pub max_retries: u32,
#[serde(default = "default_retry_delay_ms")]
pub retry_delay_ms: u64,
}
fn default_max_retries() -> u32 {
3
}
fn default_retry_delay_ms() -> u64 {
1000
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: default_max_retries(),
retry_delay_ms: default_retry_delay_ms(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KafkaConnectorConfig {
pub brokers: Vec<String>,
pub topic: String,
#[serde(default)]
pub allow_private_urls: bool,
#[serde(default)]
pub operations: KafkaOperationGates,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DbConnectorConfig {
pub connection_string: String,
#[serde(default)]
pub max_connections: Option<u32>,
#[serde(default)]
pub connect_timeout_ms: Option<u64>,
#[serde(default)]
pub query_timeout_ms: Option<u64>,
#[serde(default)]
pub allow_private_urls: bool,
#[serde(default)]
pub operations: OperationGates,
#[serde(default)]
pub dialect: DialectGuards,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheConnectorConfig {
pub backend: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub allow_private_urls: bool,
#[serde(default)]
pub operations: CacheOperationGates,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EsConnectorConfig {
pub url: String,
pub auth: Option<AuthConfig>,
#[serde(default)]
pub request_timeout_ms: Option<u64>,
#[serde(default)]
pub allow_private_urls: bool,
#[serde(default = "default_max_response_size")]
pub max_response_size: usize,
#[serde(default)]
pub operations: OperationGates,
#[serde(default)]
pub dialect: DialectGuards,
}
pub fn is_mongo_url(connection_string: &str) -> bool {
connection_string.starts_with("mongodb://") || connection_string.starts_with("mongodb+srv://")
}
pub const VALID_CONNECTOR_TYPES: &[&str] = &["http", "kafka", "db", "cache", "es"];
pub const VALID_CACHE_BACKENDS: &[&str] = &["redis", "memory"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ConnectorType {
Http,
Kafka,
Db,
Cache,
Es,
}
impl ConnectorType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Http => "http",
Self::Kafka => "kafka",
Self::Db => "db",
Self::Cache => "cache",
Self::Es => "es",
}
}
pub fn operation_gate_keys(self) -> &'static [&'static str] {
match self {
Self::Db | Self::Es => &["read", "insert", "update", "delete", "upsert", "raw_write"],
Self::Cache => &["read", "write"],
Self::Kafka => &["publish"],
Self::Http => &["methods"],
}
}
}
impl std::fmt::Display for ConnectorType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for ConnectorType {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
match s.to_ascii_lowercase().as_str() {
"http" => Ok(Self::Http),
"kafka" => Ok(Self::Kafka),
"db" => Ok(Self::Db),
"cache" => Ok(Self::Cache),
"es" => Ok(Self::Es),
other => Err(serde::de::Error::unknown_variant(
other,
VALID_CONNECTOR_TYPES,
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operation_gate_keys_match_the_gate_structs() {
fn fields<T: Serialize>(value: &T) -> std::collections::BTreeSet<String> {
serde_json::to_value(value)
.expect("gates serialize")
.as_object()
.expect("gates are a JSON object")
.keys()
.cloned()
.collect()
}
fn declared(t: ConnectorType) -> std::collections::BTreeSet<String> {
t.operation_gate_keys()
.iter()
.map(|k| (*k).to_string())
.collect()
}
for connector_type in [ConnectorType::Db, ConnectorType::Es] {
assert_eq!(
fields(&OperationGates::default()),
declared(connector_type),
"{connector_type}"
);
}
assert_eq!(
fields(&CacheOperationGates::default()),
declared(ConnectorType::Cache)
);
assert_eq!(
fields(&KafkaOperationGates::default()),
declared(ConnectorType::Kafka)
);
assert_eq!(
fields(&HttpOperationGates::default()),
declared(ConnectorType::Http)
);
}
#[test]
fn test_valid_connector_types() {
assert!(VALID_CONNECTOR_TYPES.contains(&"http"));
assert!(VALID_CONNECTOR_TYPES.contains(&"kafka"));
assert!(!VALID_CONNECTOR_TYPES.contains(&"grpc"));
}
#[test]
fn test_retry_config_default() {
let config = RetryConfig::default();
assert_eq!(config.max_retries, 3);
assert_eq!(config.retry_delay_ms, 1000);
}
#[test]
fn test_connector_config_deserialization_http() {
let json = r#"{"type":"http","url":"https://api.example.com","headers":{},"retry":{"max_retries":2,"retry_delay_ms":500}}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
match config {
ConnectorConfig::Http(http) => {
assert_eq!(http.url, "https://api.example.com");
assert_eq!(http.retry.max_retries, 2);
assert_eq!(http.retry.retry_delay_ms, 500);
assert_eq!(http.max_response_size, 10 * 1024 * 1024);
}
_ => unreachable!("Expected Http config"),
}
}
#[test]
fn test_connector_config_deserialization_kafka() {
let json = r#"{"type":"kafka","brokers":["localhost:9092"],"topic":"test-topic","group_id":"test-group"}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
match config {
ConnectorConfig::Kafka(kafka) => {
assert_eq!(kafka.brokers, vec!["localhost:9092"]);
assert_eq!(kafka.topic, "test-topic");
}
_ => unreachable!("Expected Kafka config"),
}
}
#[test]
fn test_connector_config_deserialization_db() {
let json =
r#"{"type":"db","connection_string":"postgres://localhost/mydb","max_connections":5}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
match config {
ConnectorConfig::Db(db) => {
assert_eq!(db.connection_string, "postgres://localhost/mydb");
assert_eq!(db.max_connections, Some(5));
}
_ => unreachable!("Expected Db config"),
}
}
#[test]
fn test_connector_config_deserialization_cache_redis() {
let json = r#"{"type":"cache","backend":"redis","url":"redis://localhost:6379","default_ttl_secs":300}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
match config {
ConnectorConfig::Cache(cache) => {
assert_eq!(cache.backend, "redis");
assert_eq!(cache.url, Some("redis://localhost:6379".to_string()));
}
_ => unreachable!("Expected Cache config"),
}
}
#[test]
fn test_connector_config_deserialization_cache_memory() {
let json = r#"{"type":"cache","backend":"memory","default_ttl_secs":60}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
match config {
ConnectorConfig::Cache(cache) => {
assert_eq!(cache.backend, "memory");
assert!(cache.url.is_none());
}
_ => unreachable!("Expected Cache config"),
}
}
#[test]
fn legacy_configs_with_removed_fields_still_load() {
let db = r#"{"type":"db","connection_string":"postgres://localhost/mydb",
"driver":"mysql","retry":{"max_retries":5,"retry_delay_ms":250},
"auth":{"type":"basic","username":"u","password":"p"}}"#;
match serde_json::from_str::<ConnectorConfig>(db).expect("0.3.x db config must still load")
{
ConnectorConfig::Db(db) => {
assert_eq!(db.connection_string, "postgres://localhost/mydb");
}
_ => unreachable!("Expected Db config"),
}
let cache = r#"{"type":"cache","backend":"redis","url":"redis://localhost:6379",
"default_ttl_secs":300,"max_connections":10,"retry":{"max_retries":3}}"#;
match serde_json::from_str::<ConnectorConfig>(cache)
.expect("0.3.x cache config must still load")
{
ConnectorConfig::Cache(c) => assert_eq!(c.backend, "redis"),
_ => unreachable!("Expected Cache config"),
}
let kafka = r#"{"type":"kafka","brokers":["b:9092"],"topic":"t","group_id":"g"}"#;
match serde_json::from_str::<ConnectorConfig>(kafka)
.expect("0.3.x kafka config must still load")
{
ConnectorConfig::Kafka(k) => assert_eq!(k.topic, "t"),
_ => unreachable!("Expected Kafka config"),
}
let es = r#"{"type":"es","url":"http://localhost:9200",
"retry":{"max_retries":9,"retry_delay_ms":100}}"#;
match serde_json::from_str::<ConnectorConfig>(es).expect("0.3.x es config must still load")
{
ConnectorConfig::Es(es) => assert_eq!(es.url, "http://localhost:9200"),
_ => unreachable!("Expected Es config"),
}
}
#[test]
fn only_the_http_connector_advertises_a_retry_policy() {
let rendered = |json: &str| {
let config: ConnectorConfig = serde_json::from_str(json).expect("config parses");
serde_json::to_value(&config).expect("config serializes")
};
assert!(
!rendered(r#"{"type":"db","connection_string":"sqlite::memory:"}"#)["retry"]
.is_object(),
"a db connector must not advertise a retry policy nothing applies"
);
assert!(
!rendered(r#"{"type":"es","url":"http://localhost:9200"}"#)["retry"].is_object(),
"an es connector must not advertise a retry policy nothing applies"
);
assert!(
!rendered(r#"{"type":"cache","backend":"memory"}"#)["retry"].is_object(),
"a cache connector must not advertise a retry policy nothing applies"
);
assert_eq!(
rendered(r#"{"type":"http","url":"https://example.com"}"#)["retry"]["max_retries"],
3
);
}
#[test]
fn test_connector_config_deserialization_cache_missing_backend() {
let json = r#"{"type":"cache","url":"redis://localhost:6379"}"#;
let result = serde_json::from_str::<ConnectorConfig>(json);
assert!(result.is_err());
}
#[test]
fn storage_connector_type_is_rejected() {
let json = r#"{"type":"storage","provider":"s3","bucket":"my-bucket"}"#;
serde_json::from_str::<ConnectorConfig>(json)
.expect_err("`storage` must no longer deserialize");
assert!(!VALID_CONNECTOR_TYPES.contains(&"storage"));
}
#[test]
fn test_connector_config_deserialization_es() {
let json = r#"{"type":"es","url":"http://localhost:9200"}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
match config {
ConnectorConfig::Es(es) => {
assert_eq!(es.url, "http://localhost:9200");
assert!(es.auth.is_none());
assert!(!es.allow_private_urls);
}
_ => unreachable!("Expected Es config"),
}
}
#[test]
fn test_valid_connector_types_expanded() {
assert!(VALID_CONNECTOR_TYPES.contains(&"http"));
assert!(VALID_CONNECTOR_TYPES.contains(&"kafka"));
assert!(VALID_CONNECTOR_TYPES.contains(&"db"));
assert!(VALID_CONNECTOR_TYPES.contains(&"cache"));
assert!(VALID_CONNECTOR_TYPES.contains(&"es"));
assert!(!VALID_CONNECTOR_TYPES.contains(&"grpc"));
}
#[test]
fn test_operation_gates_default_all_allowed() {
let json = r#"{"type":"db","connection_string":"sqlite::memory:"}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
let gates = config.operation_gates().expect("db has gates");
for op in ["read", "insert", "update", "delete", "upsert", "raw_write"] {
assert!(gates.allows(op), "{op} should default to allowed");
}
assert!(!gates.allows("unknown"), "unknown ops are denied");
}
#[test]
fn test_operation_gates_partial_override() {
let json = r#"{"type":"db","connection_string":"sqlite::memory:",
"operations":{"delete":false,"raw_write":false}}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
let gates = config.operation_gates().expect("db has gates");
assert!(!gates.allows("delete"));
assert!(!gates.allows("raw_write"));
assert!(gates.allows("read"));
assert!(gates.allows("insert"));
assert!(gates.allows("update"));
assert!(gates.allows("upsert"));
}
#[test]
fn test_operation_gates_on_es() {
let json = r#"{"type":"es","url":"http://localhost:9200","operations":{"update":false}}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
let gates = config.operation_gates().expect("es has gates");
assert!(!gates.allows("update"));
assert!(gates.allows("insert"));
}
#[test]
fn dialect_guards_default_to_off_on_db_and_es() {
for json in [
r#"{"type":"db","connection_string":"sqlite::memory:"}"#,
r#"{"type":"es","url":"http://localhost:9200"}"#,
] {
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
let guards = config.dialect_guards().expect("db/es carry dialect guards");
assert!(!guards.require_schema, "{json}");
assert!(guards.allowed_entities.is_empty(), "{json}");
}
let http: ConnectorConfig =
serde_json::from_str(r#"{"type":"http","url":"https://example.com"}"#).expect("test");
assert!(http.dialect_guards().is_none(), "http has no dialect");
}
#[test]
fn dialect_guards_parse_from_connector_config() {
let json = r#"{"type":"db","connection_string":"sqlite::memory:",
"dialect":{"require_schema":true,"allowed_entities":["users","orders"]}}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
let guards = config.dialect_guards().expect("db has guards");
assert!(guards.require_schema);
assert_eq!(guards.allowed_entities, vec!["users", "orders"]);
}
#[test]
fn require_schema_refuses_identity_mode_and_empty_schemas() {
let off = DialectGuards::default();
assert!(
off.schema_is_sufficient(false, true),
"off permits anything"
);
let on = DialectGuards {
require_schema: true,
allowed_entities: vec![],
};
assert!(
on.schema_is_sufficient(true, false),
"entities + reject mode is a real schema"
);
assert!(
!on.schema_is_sufficient(false, false),
"no entities declared is not a schema"
);
assert!(
!on.schema_is_sufficient(true, true),
"entities under identity mode still let every other name through"
);
assert!(!on.schema_is_sufficient(false, true));
}
#[test]
fn a_misspelled_dialect_guard_is_rejected() {
for bad in [
r#"{"type":"db","connection_string":"sqlite::memory:",
"dialect":{"requireSchema":true}}"#,
r#"{"type":"db","connection_string":"sqlite::memory:",
"dialect":{"allowed_entites":["users"]}}"#,
r#"{"type":"es","url":"http://localhost:9200",
"dialect":{"require_schemas":true}}"#,
] {
let err = serde_json::from_str::<ConnectorConfig>(bad)
.expect_err("a misspelled dialect key must not parse as no guard");
assert!(err.to_string().contains("unknown field"), "{err}");
}
}
#[test]
fn test_operation_gates_absent_on_http() {
let json = r#"{"type":"http","url":"https://example.com"}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
assert!(config.operation_gates().is_none());
}
#[test]
fn every_connector_type_defaults_to_fully_open() {
let cache: ConnectorConfig =
serde_json::from_str(r#"{"type":"cache","backend":"memory"}"#).expect("test");
match cache {
ConnectorConfig::Cache(c) => {
assert!(c.operations.read);
assert!(c.operations.write);
}
_ => unreachable!("Expected Cache config"),
}
let kafka: ConnectorConfig =
serde_json::from_str(r#"{"type":"kafka","brokers":["b:9092"],"topic":"t"}"#)
.expect("test");
match kafka {
ConnectorConfig::Kafka(k) => {
assert!(k.operations.publish);
}
_ => unreachable!("Expected Kafka config"),
}
let http: ConnectorConfig =
serde_json::from_str(r#"{"type":"http","url":"https://example.com"}"#).expect("test");
match http {
ConnectorConfig::Http(h) => {
assert!(h.operations.methods.is_empty());
for method in VALID_HTTP_METHODS {
assert!(h.operations.allows_method(method), "{method} must be open");
}
}
_ => unreachable!("Expected Http config"),
}
}
#[test]
fn a_cache_connector_can_be_made_read_only() {
let json = r#"{"type":"cache","backend":"memory","operations":{"write":false}}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
match config {
ConnectorConfig::Cache(c) => {
assert!(!c.operations.write);
assert!(c.operations.read);
}
_ => unreachable!("Expected Cache config"),
}
}
#[test]
fn a_kafka_connector_can_be_made_publish_proof() {
let json =
r#"{"type":"kafka","brokers":["b:9092"],"topic":"t","operations":{"publish":false}}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
match config {
ConnectorConfig::Kafka(k) => assert!(!k.operations.publish),
_ => unreachable!("Expected Kafka config"),
}
}
#[test]
fn an_http_method_allow_list_is_exhaustive_and_case_insensitive() {
let json = r#"{"type":"http","url":"https://example.com",
"operations":{"methods":["get","POST"]}}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
match config {
ConnectorConfig::Http(h) => {
assert!(h.operations.allows_method("GET"));
assert!(h.operations.allows_method("post"));
assert!(!h.operations.allows_method("DELETE"));
assert!(!h.operations.allows_method("PUT"));
}
_ => unreachable!("Expected Http config"),
}
}
#[test]
fn test_http_connector_config_defaults() {
let json = r#"{"type":"http","url":"https://example.com"}"#;
let config: ConnectorConfig = serde_json::from_str(json).expect("test");
match config {
ConnectorConfig::Http(http) => {
assert!(http.headers.is_empty());
assert!(http.auth.is_none());
assert_eq!(http.retry.max_retries, 3);
assert_eq!(http.retry.retry_delay_ms, 1000);
assert_eq!(http.max_response_size, 10 * 1024 * 1024);
}
_ => unreachable!("Expected Http config"),
}
}
}