use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::card::common::NonSecretValue;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct SourceSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub source: SourceKind,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub defaults: BTreeMap<String, NonSecretValue>,
}
impl SourceSpec {
pub fn validate(&self) -> Result<(), SourceValidationError> {
self.source.validate()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[non_exhaustive]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SourceKind {
ObjectStore {
uri: String,
format: ObjectFormat,
#[serde(default)]
auth: SourceAuth,
},
SqlWarehouse {
connection: SqlConnection,
},
Metrics {
connection: MetricsConnection,
},
Logs {
connection: LogConnection,
},
Traces {
connection: TraceConnection,
},
}
impl SourceKind {
fn validate(&self) -> Result<(), SourceValidationError> {
match self {
Self::ObjectStore { uri, auth, .. } => {
non_empty("uri", uri)?;
if uri_has_embedded_credentials(uri) {
return Err(SourceValidationError::EmbeddedCredential { field: "uri" });
}
auth.validate()
}
Self::SqlWarehouse { connection } => connection.validate(),
Self::Metrics { connection } => connection.validate(),
Self::Logs { connection } => connection.validate(),
Self::Traces { connection } => connection.validate(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ObjectFormat {
Parquet,
Jsonl,
ArrowIpc,
Csv,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[non_exhaustive]
#[serde(tag = "vendor", rename_all = "snake_case")]
pub enum SqlConnection {
BigQuery {
project: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
dataset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
location: Option<String>,
#[serde(default)]
auth: SourceAuth,
},
Snowflake {
account: String,
warehouse: String,
database: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
schema: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
role: Option<String>,
#[serde(default)]
auth: SourceAuth,
},
Postgres {
host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
port: Option<u16>,
database: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
sslmode: Option<String>,
#[serde(default)]
auth: SourceAuth,
},
}
impl SqlConnection {
fn validate(&self) -> Result<(), SourceValidationError> {
match self {
Self::BigQuery { project, auth, .. } => {
non_empty("project", project)?;
auth.validate()
}
Self::Snowflake {
account,
warehouse,
database,
auth,
..
} => {
non_empty("account", account)?;
non_empty("warehouse", warehouse)?;
non_empty("database", database)?;
auth.validate()
}
Self::Postgres {
host,
database,
auth,
..
} => {
non_empty("host", host)?;
non_empty("database", database)?;
auth.validate()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[non_exhaustive]
#[serde(tag = "vendor", rename_all = "snake_case")]
pub enum MetricsConnection {
Prometheus {
endpoint: String,
#[serde(default)]
auth: SourceAuth,
},
Datadog {
site: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
api_scopes: Vec<String>,
#[serde(default)]
auth: SourceAuth,
},
Cloudwatch {
region: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
namespace: Option<String>,
#[serde(default)]
auth: SourceAuth,
},
}
impl MetricsConnection {
fn validate(&self) -> Result<(), SourceValidationError> {
match self {
Self::Prometheus { endpoint, auth } => {
non_empty("endpoint", endpoint)?;
auth.validate()
}
Self::Datadog { site, auth, .. } => {
non_empty("site", site)?;
auth.validate()
}
Self::Cloudwatch { region, auth, .. } => {
non_empty("region", region)?;
auth.validate()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[non_exhaustive]
#[serde(tag = "vendor", rename_all = "snake_case")]
pub enum LogConnection {
Loki {
endpoint: String,
#[serde(default)]
auth: SourceAuth,
},
Elasticsearch {
endpoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
index: Option<String>,
#[serde(default)]
auth: SourceAuth,
},
Splunk {
endpoint: String,
#[serde(default)]
auth: SourceAuth,
},
}
impl LogConnection {
fn validate(&self) -> Result<(), SourceValidationError> {
match self {
Self::Loki { endpoint, auth }
| Self::Splunk { endpoint, auth }
| Self::Elasticsearch { endpoint, auth, .. } => {
non_empty("endpoint", endpoint)?;
auth.validate()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[non_exhaustive]
#[serde(tag = "vendor", rename_all = "snake_case")]
pub enum TraceConnection {
Tempo {
endpoint: String,
#[serde(default)]
auth: SourceAuth,
},
DatadogApm {
site: String,
#[serde(default)]
auth: SourceAuth,
},
Jaeger {
endpoint: String,
#[serde(default)]
auth: SourceAuth,
},
}
impl TraceConnection {
fn validate(&self) -> Result<(), SourceValidationError> {
match self {
Self::Tempo { endpoint, auth } | Self::Jaeger { endpoint, auth } => {
non_empty("endpoint", endpoint)?;
auth.validate()
}
Self::DatadogApm { site, auth } => {
non_empty("site", site)?;
auth.validate()
}
}
}
}
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[non_exhaustive]
#[serde(tag = "scheme", rename_all = "snake_case")]
pub enum SourceAuth {
#[default]
None,
Env {
env: String,
},
Basic {
username: String,
password_env: String,
},
MultiEnv {
vars: BTreeMap<String, String>,
},
SecretStore {
provider: String,
name: String,
},
}
impl fmt::Debug for SourceAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::None => write!(f, "None"),
Self::Env { env } => f.debug_struct("Env").field("env", env).finish(),
Self::Basic { password_env, .. } => f
.debug_struct("Basic")
.field("username", &"[redacted]")
.field("password_env", password_env)
.finish(),
Self::MultiEnv { vars } => f.debug_struct("MultiEnv").field("vars", vars).finish(),
Self::SecretStore { provider, name } => f
.debug_struct("SecretStore")
.field("provider", provider)
.field("name", name)
.finish(),
}
}
}
impl SourceAuth {
fn validate(&self) -> Result<(), SourceValidationError> {
match self {
Self::None => Ok(()),
Self::Env { env } => non_empty("auth.env", env),
Self::Basic {
username,
password_env,
} => {
non_empty("auth.username", username)?;
non_empty("auth.password_env", password_env)
}
Self::MultiEnv { vars } => {
if vars.is_empty() {
return Err(SourceValidationError::EmptyField { field: "auth.vars" });
}
for (key, env) in vars {
non_empty("auth.vars.key", key)?;
non_empty("auth.vars.value", env)?;
}
Ok(())
}
Self::SecretStore { provider, name } => {
non_empty("auth.provider", provider)?;
non_empty("auth.name", name)
}
}
}
}
fn non_empty(field: &'static str, value: &str) -> Result<(), SourceValidationError> {
if value.trim().is_empty() {
return Err(SourceValidationError::EmptyField { field });
}
Ok(())
}
fn uri_has_embedded_credentials(uri: &str) -> bool {
let Some((_, rest)) = uri.split_once("://") else {
return false;
};
let authority = rest.split('/').next().unwrap_or(rest);
let decoded = authority
.replace("%40", "@")
.replace("%3A", ":")
.replace("%3a", ":");
decoded.contains('@')
&& decoded
.split('@')
.next()
.is_some_and(|info| info.contains(':'))
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SourceValidationError {
#[error("source field `{field}` must not be empty")]
EmptyField {
field: &'static str,
},
#[error("source field `{field}` must not contain embedded credentials")]
EmbeddedCredential {
field: &'static str,
},
}
impl From<SourceValidationError> for crate::error::WyrdError {
fn from(e: SourceValidationError) -> Self {
let field = match &e {
SourceValidationError::EmptyField { field }
| SourceValidationError::EmbeddedCredential { field } => *field,
};
Self::SourceValidation {
message: e.to_string(),
details: serde_json::json!({ "field": field }),
}
}
}