Skip to main content

kernel/
adapter.rs

1//! What an adapter declares about itself: which port it implements, its kind
2//! string, and the shape of its options.
3//!
4//! An adapter owns its configuration (ADR 0001). It writes one struct, derives
5//! [`AdapterConfig`] on it, and from that single declaration the composition
6//! root gets everything it renders: the editor JSON schema, the Reference
7//! option tables, the designer's forms, and the environment variables that
8//! override the adapter's secrets. Nothing in the kernel or the config layer
9//! names the adapter.
10//!
11//! ```
12//! use kernel::{AdapterConfig, Options, Port, Secret};
13//! use schemars::JsonSchema;
14//! use serde::{Deserialize, Serialize};
15//!
16//! /// Where the documents go.
17//! #[derive(Debug, Serialize, Deserialize, JsonSchema)]
18//! #[serde(deny_unknown_fields)]
19//! struct DemoConfig {
20//!     /// The cluster URL.
21//!     url: Secret,
22//!     /// Documents per request.
23//!     #[serde(default = "default_batch")]
24//!     batch_size: u32,
25//! }
26//! fn default_batch() -> u32 { 1000 }
27//!
28//! impl AdapterConfig for DemoConfig {
29//!     const PORT: Port = Port::Sink;
30//!     const KIND: &'static str = "demo";
31//!     fn example() -> Self {
32//!         Self { url: Secret::Value("https://search:9200".into()), batch_size: 500 }
33//!     }
34//! }
35//!
36//! let description = DemoConfig::description();
37//! assert_eq!(description.kind, "demo");
38//! // Every `Secret` field gets an override variable, named per entry.
39//! let vars: Vec<String> = description.override_vars("primary").collect();
40//! assert_eq!(vars, ["PRIMARY_DEMO_URL"]);
41//!
42//! let mut options = Options::empty();
43//! options.insert("url", "https://other:9200");
44//! let config = DemoConfig::from_options(options).unwrap();
45//! assert_eq!(config.batch_size, 1000);
46//! ```
47
48use std::fmt;
49
50use schemars::{JsonSchema, Schema};
51use serde::de::DeserializeOwned;
52use serde::{Deserialize, Serialize};
53
54use crate::options::{Options, OptionsError};
55
56/// The three contracts the engine drives. Every adapter implements exactly one.
57#[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    /// The entry name of a singleton port in `flusso.toml`, also its `<NAME>`
67    /// in override variables: `source` and `stream`. Sinks are named by the
68    /// user, so this is `None` for them.
69    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
88/// The schema-property marker a [`Secret`](crate::Secret) plants so a
89/// description can find the fields that take an override variable.
90pub const SECRET_MARKER: &str = "x-flusso-secret";
91
92/// An adapter's configuration type: its port, its kind, and an example.
93///
94/// The provided [`description`](Self::description) and
95/// [`from_options`](Self::from_options) are what the composition root uses;
96/// adapters implement only the three required items, usually through
97/// `#[derive(AdapterConfig)]`.
98pub trait AdapterConfig: JsonSchema + Serialize + DeserializeOwned + Sized {
99    /// Which port this adapter implements.
100    const PORT: Port;
101
102    /// The `type = "…"` token that selects this adapter in `flusso.toml`.
103    const KIND: &'static str;
104
105    /// A realistic, complete example, rendered into the docs and the schema.
106    fn example() -> Self;
107
108    /// Turn a port entry's options into this config, strictly.
109    fn from_options(options: Options) -> Result<Self, OptionsError> {
110        options.deserialize_into()
111    }
112
113    /// The schema of the options this adapter takes **per index**, under an
114    /// `[[index]]` entry's `sinks.<name>` table. `None` (the default) means the
115    /// adapter takes none, so naming it there is an error. The derive's
116    /// `index_options = Type` key fills it.
117    fn index_schema() -> Option<AdapterSchema> {
118        None
119    }
120
121    /// Everything the composition root renders from this declaration.
122    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
136/// A JSON schema an adapter publishes: [`schemars::Schema`], named here so a
137/// derive expands without depending on `schemars` directly.
138pub type AdapterSchema = Schema;
139
140/// The draft-07 root schema of `T`, the dialect every adapter description
141/// uses: `definitions`, no `$defs`, which TOML/YAML editor tooling supports
142/// most widely.
143pub fn schema_for<T: JsonSchema>() -> AdapterSchema {
144    schemars::generate::SchemaSettings::draft07()
145        .into_generator()
146        .into_root_schema_for::<T>()
147}
148
149/// The rendered facts about one adapter's options: the JSON schema (draft-07;
150/// doc comments as descriptions, serde defaults as defaults), an example, and
151/// the field paths that accept an override variable.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct AdapterDescription {
154    /// Which port the adapter implements.
155    pub port: Port,
156    /// The adapter's `type` token.
157    pub kind: String,
158    /// The JSON schema of the options struct.
159    pub schema: AdapterSchema,
160    /// A complete example, as it would appear under the entry.
161    pub example: Options,
162    /// The `Secret` fields, as `.`-joined paths from the entry root.
163    pub secrets: Vec<String>,
164    /// The schema of the options the adapter takes per index (an `[[index]]`
165    /// entry's `sinks.<name>` table), or `None` when it takes none.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub index_schema: Option<AdapterSchema>,
168}
169
170impl AdapterDescription {
171    /// The override variable of every secret field for the entry named
172    /// `entry` (a sink's name, or `source`/`stream` for the singletons).
173    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
180/// The environment variable that overrides one option of one port entry:
181/// `<ENTRY>_<KIND>_<FIELD>`, uppercased, nested path segments joined with `_`.
182///
183/// ```
184/// assert_eq!(kernel::override_var("primary", "opensearch", "url"), "PRIMARY_OPENSEARCH_URL");
185/// assert_eq!(
186///     kernel::override_var("source", "postgres", "connection_url.password"),
187///     "SOURCE_POSTGRES_CONNECTION_URL_PASSWORD",
188/// );
189/// ```
190pub fn override_var(entry: &str, kind: &str, field_path: &str) -> String {
191    format!("{entry}_{kind}_{}", field_path.replace('.', "_")).to_uppercase()
192}
193
194/// Walk a schema's `properties` (through `$ref`s into `$defs`, `anyOf`/`oneOf`
195/// alternatives, and `Option` wrappers) and collect the paths of every property
196/// carrying [`SECRET_MARKER`].
197fn 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}