use std::collections::HashMap;
use std::sync::RwLock;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConnectorKind {
Github,
Web,
File,
}
impl ConnectorKind {
pub fn parse(value: &str) -> Result<Self, String> {
match value.trim().to_ascii_lowercase().as_str() {
"github" => Ok(Self::Github),
"web" => Ok(Self::Web),
"file" => Ok(Self::File),
other => Err(other.to_string()),
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Github => "github",
Self::Web => "web",
Self::File => "file",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectorConfig {
pub id: String,
pub org_id: String,
pub name: String,
pub kind: ConnectorKind,
pub config: Value,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl ConnectorConfig {
#[must_use]
pub fn auth_ref(&self) -> Option<&str> {
self.config
.get("auth_ref")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
}
}
pub trait ConnectorConfigStore: Send + Sync {
fn list(&self, org_id: &str) -> Vec<ConnectorConfig>;
fn get(&self, org_id: &str, id: &str) -> Option<ConnectorConfig>;
fn upsert(&self, config: ConnectorConfig);
fn delete(&self, org_id: &str, id: &str) -> bool;
}
#[derive(Default)]
pub struct InMemoryConnectorConfigStore {
rows: RwLock<HashMap<(String, String), ConnectorConfig>>,
}
impl InMemoryConnectorConfigStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl ConnectorConfigStore for InMemoryConnectorConfigStore {
fn list(&self, org_id: &str) -> Vec<ConnectorConfig> {
let Ok(rows) = self.rows.read() else {
return Vec::new();
};
let mut out: Vec<ConnectorConfig> = rows
.values()
.filter(|c| c.org_id == org_id)
.cloned()
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)));
out
}
fn get(&self, org_id: &str, id: &str) -> Option<ConnectorConfig> {
let rows = self.rows.read().ok()?;
rows.get(&(org_id.to_string(), id.to_string())).cloned()
}
fn upsert(&self, config: ConnectorConfig) {
if let Ok(mut rows) = self.rows.write() {
rows.insert((config.org_id.clone(), config.id.clone()), config);
}
}
fn delete(&self, org_id: &str, id: &str) -> bool {
if let Ok(mut rows) = self.rows.write() {
rows.remove(&(org_id.to_string(), id.to_string())).is_some()
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn cfg(org: &str, id: &str, name: &str, kind: ConnectorKind, config: Value) -> ConnectorConfig {
let now = Utc::now();
ConnectorConfig {
id: id.into(),
org_id: org.into(),
name: name.into(),
kind,
config,
enabled: true,
created_at: now,
updated_at: now,
}
}
#[test]
fn kind_parse_roundtrips_and_rejects_unknown() {
assert_eq!(
ConnectorKind::parse("github").unwrap(),
ConnectorKind::Github
);
assert_eq!(ConnectorKind::parse(" WEB ").unwrap(), ConnectorKind::Web);
assert_eq!(ConnectorKind::parse("File").unwrap(), ConnectorKind::File);
assert_eq!(ConnectorKind::Github.as_str(), "github");
assert_eq!(ConnectorKind::parse("slack").unwrap_err(), "slack");
}
#[test]
fn upsert_list_get_are_org_scoped() {
let store = InMemoryConnectorConfigStore::new();
store.upsert(cfg(
"org-a",
"1",
"beta",
ConnectorKind::Web,
json!({"url": "https://b"}),
));
store.upsert(cfg(
"org-a",
"2",
"alpha",
ConnectorKind::Web,
json!({"url": "https://a"}),
));
store.upsert(cfg(
"org-b",
"3",
"gamma",
ConnectorKind::Web,
json!({"url": "https://g"}),
));
let a = store.list("org-a");
assert_eq!(a.len(), 2);
assert_eq!(a[0].name, "alpha");
assert_eq!(a[1].name, "beta");
assert_eq!(store.list("org-b").len(), 1);
assert!(store.get("org-b", "1").is_none());
assert!(store.get("org-a", "1").is_some());
}
#[test]
fn upsert_updates_in_place() {
let store = InMemoryConnectorConfigStore::new();
store.upsert(cfg(
"o",
"1",
"name-1",
ConnectorKind::Web,
json!({"url": "https://1"}),
));
store.upsert(cfg(
"o",
"1",
"name-2",
ConnectorKind::Web,
json!({"url": "https://2"}),
));
let got = store.get("o", "1").unwrap();
assert_eq!(got.name, "name-2");
assert_eq!(store.list("o").len(), 1, "upsert replaces, not appends");
}
#[test]
fn delete_is_org_scoped_and_reports_removal() {
let store = InMemoryConnectorConfigStore::new();
store.upsert(cfg(
"o",
"1",
"n",
ConnectorKind::File,
json!({"path": "/d"}),
));
assert!(!store.delete("other", "1"));
assert!(store.get("o", "1").is_some());
assert!(store.delete("o", "1"));
assert!(!store.delete("o", "1"));
assert!(store.get("o", "1").is_none());
}
#[test]
fn auth_ref_reads_secret_name_not_value() {
let with = cfg(
"o",
"1",
"n",
ConnectorKind::Github,
json!({"owner": "o", "repo": "r", "auth_ref": "GITHUB_TOKEN"}),
);
assert_eq!(with.auth_ref(), Some("GITHUB_TOKEN"));
let without = cfg(
"o",
"2",
"n",
ConnectorKind::Github,
json!({"owner": "o", "repo": "r"}),
);
assert_eq!(without.auth_ref(), None);
let blank = cfg(
"o",
"3",
"n",
ConnectorKind::Github,
json!({"auth_ref": " "}),
);
assert_eq!(blank.auth_ref(), None);
}
}