1use std::fmt;
49
50use schemars::{JsonSchema, Schema};
51use serde::de::DeserializeOwned;
52use serde::{Deserialize, Serialize};
53
54use crate::options::{Options, OptionsError};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
58#[serde(rename_all = "snake_case")]
59pub enum Port {
60 Source,
61 Stream,
62 Sink,
63}
64
65impl Port {
66 pub fn singleton_entry(self) -> Option<&'static str> {
70 match self {
71 Port::Source => Some("source"),
72 Port::Stream => Some("stream"),
73 Port::Sink => None,
74 }
75 }
76}
77
78impl fmt::Display for Port {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.write_str(match self {
81 Port::Source => "source",
82 Port::Stream => "stream",
83 Port::Sink => "sink",
84 })
85 }
86}
87
88pub const SECRET_MARKER: &str = "x-flusso-secret";
91
92pub trait AdapterConfig: JsonSchema + Serialize + DeserializeOwned + Sized {
99 const PORT: Port;
101
102 const KIND: &'static str;
104
105 fn example() -> Self;
107
108 fn from_options(options: Options) -> Result<Self, OptionsError> {
110 options.deserialize_into()
111 }
112
113 fn description() -> AdapterDescription {
115 let schema = schemars::generate::SchemaSettings::draft07()
118 .into_generator()
119 .into_root_schema_for::<Self>();
120 let secrets = secret_paths(&schema);
121 AdapterDescription {
122 port: Self::PORT,
123 kind: Self::KIND.to_owned(),
124 schema,
125 example: Options::from_serialize(&Self::example()).unwrap_or_default(),
126 secrets,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct AdapterDescription {
136 pub port: Port,
138 pub kind: String,
140 pub schema: Schema,
142 pub example: Options,
144 pub secrets: Vec<String>,
146}
147
148impl AdapterDescription {
149 pub fn override_vars<'a>(&'a self, entry: &'a str) -> impl Iterator<Item = String> + 'a {
152 self.secrets
153 .iter()
154 .map(move |path| override_var(entry, &self.kind, path))
155 }
156}
157
158pub fn override_var(entry: &str, kind: &str, field_path: &str) -> String {
169 format!("{entry}_{kind}_{}", field_path.replace('.', "_")).to_uppercase()
170}
171
172fn secret_paths(schema: &Schema) -> Vec<String> {
176 let root = schema.as_value();
177 let defs = root
178 .get("$defs")
179 .or_else(|| root.get("definitions"))
180 .and_then(serde_json::Value::as_object);
181 let mut out = Vec::new();
182 walk(root, defs, &mut Vec::new(), &mut out, 0);
183 out.sort();
184 out.dedup();
185 out
186}
187
188fn walk(
189 node: &serde_json::Value,
190 defs: Option<&serde_json::Map<String, serde_json::Value>>,
191 path: &mut Vec<String>,
192 out: &mut Vec<String>,
193 depth: usize,
194) {
195 if depth > 16 {
196 return;
197 }
198 if node.get(SECRET_MARKER).and_then(serde_json::Value::as_bool) == Some(true)
199 && !path.is_empty()
200 {
201 out.push(path.join("."));
202 return;
203 }
204 if let Some(reference) = node.get("$ref").and_then(serde_json::Value::as_str) {
205 let name = reference.rsplit('/').next().unwrap_or(reference);
206 if let Some(target) = defs.and_then(|d| d.get(name)) {
207 walk(target, defs, path, out, depth + 1);
208 }
209 return;
210 }
211 for key in ["anyOf", "oneOf", "allOf"] {
212 if let Some(alternatives) = node.get(key).and_then(serde_json::Value::as_array) {
213 for alternative in alternatives {
214 walk(alternative, defs, path, out, depth + 1);
215 }
216 }
217 }
218 if let Some(properties) = node
219 .get("properties")
220 .and_then(serde_json::Value::as_object)
221 {
222 for (name, property) in properties {
223 path.push(name.clone());
224 walk(property, defs, path, out, depth + 1);
225 path.pop();
226 }
227 }
228}
229
230#[cfg(test)]
231#[allow(clippy::unwrap_used)]
232mod tests {
233 use super::*;
234 use crate::Secret;
235
236 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
237 #[serde(deny_unknown_fields)]
238 struct Nested {
239 password: Option<Secret>,
240 host: String,
241 }
242
243 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
244 #[serde(deny_unknown_fields)]
245 struct Demo {
246 url: Secret,
247 #[serde(default)]
248 token: Option<Secret>,
249 connection: Nested,
250 #[serde(default = "default_retries")]
251 retries: u32,
252 }
253
254 fn default_retries() -> u32 {
255 3
256 }
257
258 impl AdapterConfig for Demo {
259 const PORT: Port = Port::Sink;
260 const KIND: &'static str = "demo";
261
262 fn example() -> Self {
263 Self {
264 url: Secret::Value("https://x".into()),
265 token: None,
266 connection: Nested {
267 password: Some(Secret::Env("PW".into())),
268 host: "db".into(),
269 },
270 retries: 5,
271 }
272 }
273 }
274
275 #[test]
276 fn description_lists_secret_paths_including_nested_and_optional() {
277 let description = Demo::description();
278 assert_eq!(
279 description.secrets,
280 vec!["connection.password", "token", "url"]
281 );
282 let vars: Vec<String> = description.override_vars("primary").collect();
283 assert_eq!(
284 vars,
285 [
286 "PRIMARY_DEMO_CONNECTION_PASSWORD",
287 "PRIMARY_DEMO_TOKEN",
288 "PRIMARY_DEMO_URL"
289 ]
290 );
291 }
292
293 #[test]
294 fn description_carries_defaults_and_docs_from_the_struct() {
295 let description = Demo::description();
296 let retries = description.schema.pointer("/properties/retries").unwrap();
297 assert_eq!(retries.get("default"), Some(&serde_json::json!(3)));
298 assert_eq!(
299 description.example.get("retries").and_then(|v| v.as_i64()),
300 Some(5)
301 );
302 assert_eq!(description.port, Port::Sink);
303 assert_eq!(Port::Source.singleton_entry(), Some("source"));
304 assert_eq!(Port::Sink.singleton_entry(), None);
305 assert_eq!(Port::Stream.to_string(), "stream");
306 }
307
308 #[test]
309 fn from_options_is_strict() {
310 let mut options = Options::empty();
311 options.insert("url", "https://x");
312 options.insert("retriez", 1i64);
313 let error = Demo::from_options(options).unwrap_err().to_string();
314 assert!(error.contains("unknown field `retriez`"), "{error}");
315 }
316}