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 index_schema() -> Option<AdapterSchema> {
118 None
119 }
120
121 fn description() -> AdapterDescription {
123 let schema = schema_for::<Self>();
124 let secrets = secret_paths(&schema);
125 AdapterDescription {
126 port: Self::PORT,
127 kind: Self::KIND.to_owned(),
128 schema,
129 example: Options::from_serialize(&Self::example()).unwrap_or_default(),
130 secrets,
131 index_schema: Self::index_schema(),
132 }
133 }
134}
135
136pub type AdapterSchema = Schema;
139
140pub fn schema_for<T: JsonSchema>() -> AdapterSchema {
144 schemars::generate::SchemaSettings::draft07()
145 .into_generator()
146 .into_root_schema_for::<T>()
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct AdapterDescription {
154 pub port: Port,
156 pub kind: String,
158 pub schema: AdapterSchema,
160 pub example: Options,
162 pub secrets: Vec<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub index_schema: Option<AdapterSchema>,
168}
169
170impl AdapterDescription {
171 pub fn override_vars<'a>(&'a self, entry: &'a str) -> impl Iterator<Item = String> + 'a {
174 self.secrets
175 .iter()
176 .map(move |path| override_var(entry, &self.kind, path))
177 }
178}
179
180pub fn override_var(entry: &str, kind: &str, field_path: &str) -> String {
191 format!("{entry}_{kind}_{}", field_path.replace('.', "_")).to_uppercase()
192}
193
194fn secret_paths(schema: &Schema) -> Vec<String> {
198 let root = schema.as_value();
199 let defs = root
200 .get("$defs")
201 .or_else(|| root.get("definitions"))
202 .and_then(serde_json::Value::as_object);
203 let mut out = Vec::new();
204 walk(root, defs, &mut Vec::new(), &mut out, 0);
205 out.sort();
206 out.dedup();
207 out
208}
209
210fn walk(
211 node: &serde_json::Value,
212 defs: Option<&serde_json::Map<String, serde_json::Value>>,
213 path: &mut Vec<String>,
214 out: &mut Vec<String>,
215 depth: usize,
216) {
217 if depth > 16 {
218 return;
219 }
220 if node.get(SECRET_MARKER).and_then(serde_json::Value::as_bool) == Some(true)
221 && !path.is_empty()
222 {
223 out.push(path.join("."));
224 return;
225 }
226 if let Some(reference) = node.get("$ref").and_then(serde_json::Value::as_str) {
227 let name = reference.rsplit('/').next().unwrap_or(reference);
228 if let Some(target) = defs.and_then(|d| d.get(name)) {
229 walk(target, defs, path, out, depth + 1);
230 }
231 return;
232 }
233 for key in ["anyOf", "oneOf", "allOf"] {
234 if let Some(alternatives) = node.get(key).and_then(serde_json::Value::as_array) {
235 for alternative in alternatives {
236 walk(alternative, defs, path, out, depth + 1);
237 }
238 }
239 }
240 if let Some(properties) = node
241 .get("properties")
242 .and_then(serde_json::Value::as_object)
243 {
244 for (name, property) in properties {
245 path.push(name.clone());
246 walk(property, defs, path, out, depth + 1);
247 path.pop();
248 }
249 }
250}
251
252#[cfg(test)]
253#[allow(clippy::unwrap_used)]
254mod tests {
255 use super::*;
256 use crate::Secret;
257
258 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
259 #[serde(deny_unknown_fields)]
260 struct Nested {
261 password: Option<Secret>,
262 host: String,
263 }
264
265 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
266 #[serde(deny_unknown_fields)]
267 struct Demo {
268 url: Secret,
269 #[serde(default)]
270 token: Option<Secret>,
271 connection: Nested,
272 #[serde(default = "default_retries")]
273 retries: u32,
274 }
275
276 fn default_retries() -> u32 {
277 3
278 }
279
280 impl AdapterConfig for Demo {
281 const PORT: Port = Port::Sink;
282 const KIND: &'static str = "demo";
283
284 fn example() -> Self {
285 Self {
286 url: Secret::Value("https://x".into()),
287 token: None,
288 connection: Nested {
289 password: Some(Secret::Env("PW".into())),
290 host: "db".into(),
291 },
292 retries: 5,
293 }
294 }
295 }
296
297 #[test]
298 fn description_lists_secret_paths_including_nested_and_optional() {
299 let description = Demo::description();
300 assert_eq!(
301 description.secrets,
302 vec!["connection.password", "token", "url"]
303 );
304 let vars: Vec<String> = description.override_vars("primary").collect();
305 assert_eq!(
306 vars,
307 [
308 "PRIMARY_DEMO_CONNECTION_PASSWORD",
309 "PRIMARY_DEMO_TOKEN",
310 "PRIMARY_DEMO_URL"
311 ]
312 );
313 }
314
315 #[test]
316 fn description_carries_defaults_and_docs_from_the_struct() {
317 let description = Demo::description();
318 let retries = description.schema.pointer("/properties/retries").unwrap();
319 assert_eq!(retries.get("default"), Some(&serde_json::json!(3)));
320 assert_eq!(
321 description.example.get("retries").and_then(|v| v.as_i64()),
322 Some(5)
323 );
324 assert_eq!(description.port, Port::Sink);
325 assert_eq!(Port::Source.singleton_entry(), Some("source"));
326 assert_eq!(Port::Sink.singleton_entry(), None);
327 assert_eq!(Port::Stream.to_string(), "stream");
328 }
329
330 #[test]
331 fn from_options_is_strict() {
332 let mut options = Options::empty();
333 options.insert("url", "https://x");
334 options.insert("retriez", 1i64);
335 let error = Demo::from_options(options).unwrap_err().to_string();
336 assert!(error.contains("unknown field `retriez`"), "{error}");
337 }
338}