use std::fmt;
use schemars::{JsonSchema, Schema};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::options::{Options, OptionsError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Port {
Source,
Stream,
Sink,
}
impl Port {
pub fn singleton_entry(self) -> Option<&'static str> {
match self {
Port::Source => Some("source"),
Port::Stream => Some("stream"),
Port::Sink => None,
}
}
}
impl fmt::Display for Port {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Port::Source => "source",
Port::Stream => "stream",
Port::Sink => "sink",
})
}
}
pub const SECRET_MARKER: &str = "x-flusso-secret";
pub trait AdapterConfig: JsonSchema + Serialize + DeserializeOwned + Sized {
const PORT: Port;
const KIND: &'static str;
fn example() -> Self;
fn from_options(options: Options) -> Result<Self, OptionsError> {
options.deserialize_into()
}
fn description() -> AdapterDescription {
let schema = schemars::generate::SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<Self>();
let secrets = secret_paths(&schema);
AdapterDescription {
port: Self::PORT,
kind: Self::KIND.to_owned(),
schema,
example: Options::from_serialize(&Self::example()).unwrap_or_default(),
secrets,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdapterDescription {
pub port: Port,
pub kind: String,
pub schema: Schema,
pub example: Options,
pub secrets: Vec<String>,
}
impl AdapterDescription {
pub fn override_vars<'a>(&'a self, entry: &'a str) -> impl Iterator<Item = String> + 'a {
self.secrets
.iter()
.map(move |path| override_var(entry, &self.kind, path))
}
}
pub fn override_var(entry: &str, kind: &str, field_path: &str) -> String {
format!("{entry}_{kind}_{}", field_path.replace('.', "_")).to_uppercase()
}
fn secret_paths(schema: &Schema) -> Vec<String> {
let root = schema.as_value();
let defs = root
.get("$defs")
.or_else(|| root.get("definitions"))
.and_then(serde_json::Value::as_object);
let mut out = Vec::new();
walk(root, defs, &mut Vec::new(), &mut out, 0);
out.sort();
out.dedup();
out
}
fn walk(
node: &serde_json::Value,
defs: Option<&serde_json::Map<String, serde_json::Value>>,
path: &mut Vec<String>,
out: &mut Vec<String>,
depth: usize,
) {
if depth > 16 {
return;
}
if node.get(SECRET_MARKER).and_then(serde_json::Value::as_bool) == Some(true)
&& !path.is_empty()
{
out.push(path.join("."));
return;
}
if let Some(reference) = node.get("$ref").and_then(serde_json::Value::as_str) {
let name = reference.rsplit('/').next().unwrap_or(reference);
if let Some(target) = defs.and_then(|d| d.get(name)) {
walk(target, defs, path, out, depth + 1);
}
return;
}
for key in ["anyOf", "oneOf", "allOf"] {
if let Some(alternatives) = node.get(key).and_then(serde_json::Value::as_array) {
for alternative in alternatives {
walk(alternative, defs, path, out, depth + 1);
}
}
}
if let Some(properties) = node
.get("properties")
.and_then(serde_json::Value::as_object)
{
for (name, property) in properties {
path.push(name.clone());
walk(property, defs, path, out, depth + 1);
path.pop();
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::Secret;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct Nested {
password: Option<Secret>,
host: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct Demo {
url: Secret,
#[serde(default)]
token: Option<Secret>,
connection: Nested,
#[serde(default = "default_retries")]
retries: u32,
}
fn default_retries() -> u32 {
3
}
impl AdapterConfig for Demo {
const PORT: Port = Port::Sink;
const KIND: &'static str = "demo";
fn example() -> Self {
Self {
url: Secret::Value("https://x".into()),
token: None,
connection: Nested {
password: Some(Secret::Env("PW".into())),
host: "db".into(),
},
retries: 5,
}
}
}
#[test]
fn description_lists_secret_paths_including_nested_and_optional() {
let description = Demo::description();
assert_eq!(
description.secrets,
vec!["connection.password", "token", "url"]
);
let vars: Vec<String> = description.override_vars("primary").collect();
assert_eq!(
vars,
[
"PRIMARY_DEMO_CONNECTION_PASSWORD",
"PRIMARY_DEMO_TOKEN",
"PRIMARY_DEMO_URL"
]
);
}
#[test]
fn description_carries_defaults_and_docs_from_the_struct() {
let description = Demo::description();
let retries = description.schema.pointer("/properties/retries").unwrap();
assert_eq!(retries.get("default"), Some(&serde_json::json!(3)));
assert_eq!(
description.example.get("retries").and_then(|v| v.as_i64()),
Some(5)
);
assert_eq!(description.port, Port::Sink);
assert_eq!(Port::Source.singleton_entry(), Some("source"));
assert_eq!(Port::Sink.singleton_entry(), None);
assert_eq!(Port::Stream.to_string(), "stream");
}
#[test]
fn from_options_is_strict() {
let mut options = Options::empty();
options.insert("url", "https://x");
options.insert("retriez", 1i64);
let error = Demo::from_options(options).unwrap_err().to_string();
assert!(error.contains("unknown field `retriez`"), "{error}");
}
}