use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const MAX_TENANT_ID_LEN: usize = 63;
pub const MAX_CONNECTION_NAME_LEN: usize = 63;
pub const CONNECT_SESSION_TTL_SECS: i64 = 600;
pub fn validate_slug(s: &str, what: &str, max: usize) -> Result<(), String> {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return Err(format!("{what} must not be empty"));
};
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(format!(
"{what} '{s}' must start with a lowercase letter or a digit"
));
}
if s.len() > max {
return Err(format!("{what} '{s}' is longer than {max} characters"));
}
if let Some(bad) = s
.chars()
.find(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_' || *c == '-'))
{
return Err(format!(
"{what} '{s}' contains '{bad}'; use lowercase letters, digits, '_' and '-'"
));
}
Ok(())
}
pub fn validate_tenant_id(id: &str) -> Result<(), String> {
validate_slug(id, "tenant id", MAX_TENANT_ID_LEN)
}
pub fn validate_connection_name(name: &str) -> Result<(), String> {
validate_slug(name, "connection name", MAX_CONNECTION_NAME_LEN)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TenantLimits {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_records_per_run: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_bytes_per_run: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_duration_secs: Option<u64>,
}
impl TenantLimits {
pub fn validate(&self) -> Result<(), String> {
let zero = |name: &str| Err(format!("limits.{name} must be greater than 0"));
if self.max_concurrent_runs == Some(0) {
return zero("max_concurrent_runs");
}
if self.max_records_per_run == Some(0) {
return zero("max_records_per_run");
}
if self.max_bytes_per_run == Some(0) {
return zero("max_bytes_per_run");
}
if self.max_duration_secs == Some(0) {
return zero("max_duration_secs");
}
Ok(())
}
pub fn budget(&self) -> Option<faucet_core::BudgetSpec> {
if self.max_records_per_run.is_none()
&& self.max_bytes_per_run.is_none()
&& self.max_duration_secs.is_none()
{
return None;
}
Some(faucet_core::BudgetSpec {
max_records: self.max_records_per_run,
max_bytes: self.max_bytes_per_run,
max_duration_secs: self.max_duration_secs,
allowed_sinks: Vec::new(),
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TenantRecord {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default)]
pub limits: TenantLimits,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notifications: Vec<serde_json::Value>,
#[serde(default)]
pub suspended: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub created_by: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionStatus {
#[default]
Active,
NeedsReauth,
}
impl ConnectionStatus {
pub fn as_str(self) -> &'static str {
match self {
ConnectionStatus::Active => "active",
ConnectionStatus::NeedsReauth => "needs_reauth",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConnectionRecord {
pub tenant: String,
pub name: String,
pub provider_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_provider: Option<String>,
pub sealed: String,
#[serde(default)]
pub status: ConnectionStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reauth_reason: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub updated_by: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConnectSession {
pub state: String,
pub tenant: String,
pub provider: String,
pub connection: String,
pub redirect: String,
pub sealed_verifier: String,
pub created_by: String,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TenantStateRef {
pub tenant: String,
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(untagged)]
pub enum TenantSelector {
All(String),
Named(Vec<String>),
}
impl TenantSelector {
pub fn validate(&self) -> Result<(), String> {
match self {
TenantSelector::All(s) if s == "all" => Ok(()),
TenantSelector::All(s) => Err(format!(
"tenants must be \"all\" or a list of tenant ids (got \"{s}\")"
)),
TenantSelector::Named(ids) if ids.is_empty() => {
Err("tenants must name at least one tenant".into())
}
TenantSelector::Named(ids) => ids.iter().try_for_each(|id| validate_tenant_id(id)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selector_shapes_validate() {
let all: TenantSelector = serde_json::from_value(serde_json::json!("all")).unwrap();
assert!(all.validate().is_ok());
let named: TenantSelector = serde_json::from_value(serde_json::json!(["a", "b"])).unwrap();
assert!(named.validate().is_ok());
assert!(
TenantSelector::All("every".into())
.validate()
.unwrap_err()
.contains("\"all\"")
);
assert!(
TenantSelector::Named(vec![])
.validate()
.unwrap_err()
.contains("at least one")
);
assert!(
TenantSelector::Named(vec!["Bad".into()])
.validate()
.is_err()
);
}
#[test]
fn tenant_ids_are_slugs() {
assert!(validate_tenant_id("acme").is_ok());
assert!(validate_tenant_id("acme-co_2").is_ok());
assert!(validate_tenant_id("9lives").is_ok());
assert!(validate_tenant_id(&"a".repeat(MAX_TENANT_ID_LEN)).is_ok());
assert!(
validate_tenant_id(&"a".repeat(MAX_TENANT_ID_LEN + 1))
.unwrap_err()
.contains("longer than")
);
assert!(validate_tenant_id("").unwrap_err().contains("empty"));
assert!(validate_tenant_id("-x").unwrap_err().contains("start with"));
assert!(
validate_tenant_id("Acme")
.unwrap_err()
.contains("start with")
);
assert!(validate_tenant_id("a.b").unwrap_err().contains("'.'"));
assert!(
validate_connection_name("a/b")
.unwrap_err()
.starts_with("connection name")
);
}
#[test]
fn limits_validate_and_become_a_budget() {
assert!(TenantLimits::default().validate().is_ok());
assert_eq!(TenantLimits::default().budget(), None);
for (limits, field) in [
(
TenantLimits {
max_concurrent_runs: Some(0),
..Default::default()
},
"max_concurrent_runs",
),
(
TenantLimits {
max_records_per_run: Some(0),
..Default::default()
},
"max_records_per_run",
),
(
TenantLimits {
max_bytes_per_run: Some(0),
..Default::default()
},
"max_bytes_per_run",
),
(
TenantLimits {
max_duration_secs: Some(0),
..Default::default()
},
"max_duration_secs",
),
] {
assert!(limits.validate().unwrap_err().contains(field));
}
let only_concurrency = TenantLimits {
max_concurrent_runs: Some(2),
..Default::default()
};
assert_eq!(only_concurrency.budget(), None);
let b = TenantLimits {
max_records_per_run: Some(10),
max_bytes_per_run: Some(20),
max_duration_secs: Some(30),
..Default::default()
}
.budget()
.unwrap();
assert_eq!(
(b.max_records, b.max_bytes, b.max_duration_secs),
(Some(10), Some(20), Some(30))
);
assert!(b.allowed_sinks.is_empty());
}
#[test]
fn connection_status_strings() {
assert_eq!(ConnectionStatus::Active.as_str(), "active");
assert_eq!(ConnectionStatus::NeedsReauth.as_str(), "needs_reauth");
assert_eq!(ConnectionStatus::default(), ConnectionStatus::Active);
assert_eq!(
serde_json::to_value(ConnectionStatus::NeedsReauth).unwrap(),
"needs_reauth"
);
}
}