Skip to main content

featherbit/plugins/native/
store_get.rs

1//! `store-get` — reads a key from a named store into `context.message`.
2//!
3//! A missing key is not an error: it exits the dedicated `miss` outcome port,
4//! which the compiler forces the policy to wire. A store *outage* exits `error`
5//! instead — conflating the two would make a redis failure look exactly like
6//! "nothing recorded", and a policy would take its happy path during precisely
7//! the incident where that is most wrong.
8
9use async_trait::async_trait;
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use crate::context::Context;
14use crate::plugins::resources::PluginResources;
15use crate::plugins::util::store_kv::{self, StoreHandle};
16use crate::plugins::{Plugin, PluginResult};
17use crate::vars::template::Template;
18
19#[cfg(feature = "redis-store")]
20use crate::plugins::PluginOutput;
21
22pub struct StoreGetPlugin {
23    store: StoreHandle,
24    key: Template,
25    name: String,
26    json: bool,
27    extend_ttl_seconds: Option<u64>,
28}
29
30// `StoreHandle` now derives `Debug` on its own, so this struct *could*
31// derive too -- except `name`/`json` are only ever read inside the
32// `#[cfg(feature = "redis-store")]` `execute` body. In a headless
33// (`--no-default-features`) build that body doesn't exist, so a derived
34// impl (which the dead-code pass ignores) would leave both fields read
35// nowhere at all, and `-D warnings` fails the build; adding
36// `#[allow(dead_code)]` to silence it is off the table. A hand-written impl
37// reads them unconditionally, which is enough to keep the headless build
38// clean without the attribute.
39impl std::fmt::Debug for StoreGetPlugin {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("StoreGetPlugin")
42            .field("store", &self.store)
43            .field("key", &self.key)
44            .field("name", &self.name)
45            .field("json", &self.json)
46            .field("extend_ttl_seconds", &self.extend_ttl_seconds)
47            .finish()
48    }
49}
50
51/// Parses the optional `json` flag, which defaults to `false`. A present but
52/// wrong-typed value is a config error, the same treatment `by` and
53/// `ttl_seconds` already get -- silently ignoring, say, `json: "true"` would
54/// be the only config key in this node's set that fails that way.
55fn parse_json_flag(config: &HashMap<String, serde_json::Value>) -> Result<bool, String> {
56    match config.get("json") {
57        None | Some(serde_json::Value::Null) => Ok(false),
58        Some(v) => v
59            .as_bool()
60            .ok_or_else(|| "store-get: 'json' must be a boolean".to_string()),
61    }
62}
63
64impl StoreGetPlugin {
65    /// Accepted keys:
66    /// - `store` (string, required): a declared `stores:` entry.
67    /// - `key` (string, required, templated): the key to read.
68    /// - `name` (string, required): `context.message` key to write.
69    /// - `json` (bool, default `false`): parse a JSON object and flatten its
70    ///   top-level fields into `message` as `<name>.<field>`.
71    pub fn from_config(
72        config: &HashMap<String, serde_json::Value>,
73        resources: &Arc<PluginResources>,
74    ) -> Result<Self, String> {
75        let name = config
76            .get("name")
77            .and_then(|v| v.as_str())
78            .filter(|s| !s.is_empty())
79            .ok_or_else(|| {
80                "store-get: 'name' is required (the context.message key to write)".to_string()
81            })?
82            .to_string();
83        let json = parse_json_flag(config)?;
84        let extend_ttl_seconds =
85            store_kv::optional_seconds(config, "extend_ttl_seconds", "store-get")?;
86        Ok(Self {
87            key: store_kv::required_template(config, "key", "store-get")?,
88            extend_ttl_seconds,
89            store: store_kv::resolve(config, resources, "store-get")?,
90            name,
91            json,
92        })
93    }
94}
95
96/// Writes `value` into `message` under `name`.
97///
98/// An object is flattened one level into `<name>.<field>` keys, because
99/// `context.message` is a flat namespace: `message_str` does a plain
100/// `get(key)` and `{{message.a.b}}` resolves the literal key `"a.b"` rather
101/// than traversing. Anything else — scalar or array — is written under `name`
102/// unchanged, so `$msg_<name>` keeps working for the counter case.
103///
104/// Only reachable from the redis-backed `execute` path (there is no value to
105/// flatten without a store to have read it from), so this is
106/// `#[cfg(feature = "redis-store")]` like the rest of that path.
107#[cfg(feature = "redis-store")]
108fn flatten_into(
109    message: &mut HashMap<String, serde_json::Value>,
110    name: &str,
111    value: serde_json::Value,
112) {
113    match value {
114        serde_json::Value::Object(map) => {
115            for (k, v) in map {
116                message.insert(format!("{}.{}", name, k), v);
117            }
118        }
119        other => {
120            message.insert(name.to_string(), other);
121        }
122    }
123}
124
125#[async_trait]
126impl Plugin for StoreGetPlugin {
127    fn plugin_type(&self) -> &str {
128        "store-get"
129    }
130
131    fn reads_response_body(&self) -> bool {
132        self.key.references_response_body()
133    }
134
135    #[cfg(feature = "redis-store")]
136    async fn execute(&self, mut ctx: Context) -> PluginResult {
137        use redis::AsyncCommands;
138
139        let rendered = self.key.render(&ctx).to_string();
140        if rendered.is_empty() {
141            return Err(store_kv::key_invalid(
142                ctx,
143                "store-get",
144                "GET",
145                &self.store.name,
146            ));
147        }
148        let key = self.store.key_for(&rendered);
149
150        let mut conn = match self.store.conn().await {
151            Ok(c) => c,
152            Err(e) => {
153                return Err(store_kv::store_error(
154                    ctx,
155                    "store-get",
156                    "GET",
157                    &self.store.name,
158                    e,
159                ))
160            }
161        };
162
163        // With `extend_ttl_seconds`, GETEX reads and re-arms the expiry in one
164        // round trip, so an entry stays alive while it is being used. GETEX on
165        // a missing key returns nil and creates nothing, so a miss stays a
166        // miss -- a keep-alive read must not manufacture the entries it is
167        // meant to keep warm.
168        let read = match self.extend_ttl_seconds {
169            Some(ttl) => conn.get_ex(&key, redis::Expiry::EX(ttl)).await,
170            None => conn.get(&key).await,
171        };
172        let op = if self.extend_ttl_seconds.is_some() {
173            "GETEX"
174        } else {
175            "GET"
176        };
177
178        let raw: Option<String> = match read {
179            Ok(v) => v,
180            Err(e) => {
181                return Err(store_kv::store_error(
182                    ctx,
183                    "store-get",
184                    op,
185                    &self.store.name,
186                    e.to_string(),
187                ))
188            }
189        };
190
191        let Some(raw) = raw else {
192            return Ok(PluginOutput::on_port(ctx, "miss"));
193        };
194
195        if self.json {
196            match serde_json::from_str::<serde_json::Value>(&raw) {
197                Ok(v) => flatten_into(&mut ctx.message, &self.name, v),
198                Err(e) => {
199                    return Err(store_kv::value_invalid(
200                        ctx,
201                        "store-get",
202                        "GET",
203                        &self.store.name,
204                        format!("value at '{}' is not valid JSON: {}", rendered, e),
205                    ))
206                }
207            }
208        } else {
209            ctx.message
210                .insert(self.name.clone(), serde_json::Value::String(raw));
211        }
212
213        Ok(PluginOutput::success(ctx))
214    }
215
216    #[cfg(not(feature = "redis-store"))]
217    async fn execute(&self, ctx: Context) -> PluginResult {
218        Err(store_kv::store_error(
219            ctx,
220            "store-get",
221            "GET",
222            &self.store.name,
223            "built without the redis-store feature".to_string(),
224        ))
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::plugins::resources::PluginResources;
232    use std::collections::HashMap;
233
234    fn cfg(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
235        serde_json::from_value(json).unwrap()
236    }
237
238    #[test]
239    fn test_requires_a_name_to_write_into_message() {
240        let r = PluginResources::empty();
241        let err =
242            StoreGetPlugin::from_config(&cfg(serde_json::json!({ "store": "s", "key": "k" })), &r)
243                .unwrap_err();
244        assert!(err.contains("name"), "{err}");
245    }
246
247    /// A JSON object is flattened into dotted message keys, because
248    /// `{{message.a.b}}` resolves the literal key "a.b" rather than traversing
249    /// (`src/vars/mod.rs` message_str is a flat lookup).
250    ///
251    /// `flatten_into` only exists on the redis-backed data path (see its
252    /// doc comment), hence the same feature gate here.
253    #[cfg(feature = "redis-store")]
254    #[test]
255    fn test_flatten_object_writes_dotted_keys() {
256        let mut msg = HashMap::new();
257        flatten_into(
258            &mut msg,
259            "profile",
260            serde_json::json!({"tier": "gold", "seats": 3}),
261        );
262        assert_eq!(msg.get("profile.tier").unwrap(), "gold");
263        assert_eq!(msg.get("profile.seats").unwrap(), 3);
264        assert!(
265            !msg.contains_key("profile"),
266            "the object itself must not be written"
267        );
268    }
269
270    /// A JSON scalar keeps the plain name so `$msg_<name>` still works, which is
271    /// the common case for a counter read back after store-incr.
272    #[cfg(feature = "redis-store")]
273    #[test]
274    fn test_flatten_scalar_writes_the_plain_name() {
275        let mut msg = HashMap::new();
276        flatten_into(&mut msg, "retry_count", serde_json::json!(3));
277        assert_eq!(msg.get("retry_count").unwrap(), 3);
278    }
279
280    /// Nested values are written as their own dotted key, not recursed into:
281    /// one level is what a flat namespace can express honestly.
282    #[cfg(feature = "redis-store")]
283    #[test]
284    fn test_flatten_does_not_recurse() {
285        let mut msg = HashMap::new();
286        flatten_into(&mut msg, "cfg", serde_json::json!({"limits": {"rps": 10}}));
287        assert_eq!(
288            msg.get("cfg.limits").unwrap(),
289            &serde_json::json!({"rps": 10})
290        );
291        assert!(!msg.contains_key("cfg.limits.rps"));
292    }
293
294    /// `json` gets the same treatment as `by`/`ttl_seconds`: a present but
295    /// wrong-typed value is a config error, not a silent fallback to `false`.
296    #[test]
297    fn test_json_rejects_a_non_bool_value() {
298        let r = PluginResources::empty();
299        let err = StoreGetPlugin::from_config(
300            &cfg(serde_json::json!({ "store": "s", "key": "k", "name": "n", "json": "true" })),
301            &r,
302        )
303        .unwrap_err();
304        assert!(err.contains("json"), "{err}");
305    }
306
307    /// `extend_ttl_seconds: 0` is a config error for the same reason
308    /// `ttl_seconds: 0` is: there is no sensible "extend by zero", and
309    /// omitting the field is how you say "do not extend".
310    #[test]
311    fn test_extend_ttl_seconds_rejects_zero() {
312        let r = PluginResources::empty();
313        let err = StoreGetPlugin::from_config(
314            &cfg(serde_json::json!({
315                "store": "s", "key": "k", "name": "v", "extend_ttl_seconds": 0
316            })),
317            &r,
318        )
319        .unwrap_err();
320        assert!(err.contains("extend_ttl_seconds"), "{err}");
321    }
322}