Skip to main content

featherbit/plugins/native/
store_set.rs

1//! `store-set` — writes a key into a named store.
2
3use async_trait::async_trait;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use crate::context::Context;
8use crate::plugins::resources::PluginResources;
9use crate::plugins::util::store_kv::{self, StoreHandle};
10use crate::plugins::{Plugin, PluginResult};
11use crate::vars::template::Template;
12
13#[cfg(feature = "redis-store")]
14use crate::plugins::PluginOutput;
15
16pub struct StoreSetPlugin {
17    store: StoreHandle,
18    key: Template,
19    value: Template,
20    ttl_seconds: Option<u64>,
21}
22
23// `StoreHandle`, `Template` and `Option<u64>` are all `Debug`, so this could
24// derive -- except `ttl_seconds` is only read inside the
25// `#[cfg(feature = "redis-store")]` `execute` body, which does not exist in
26// a headless (`--no-default-features`) build. A derived impl doesn't count
27// as a read for the dead-code pass, so deriving would leave `ttl_seconds`
28// read nowhere there and fail `-D warnings`; `#[allow(dead_code)]` is off
29// the table. Reading it here, unconditionally, keeps the headless build
30// clean without the attribute.
31impl std::fmt::Debug for StoreSetPlugin {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.debug_struct("StoreSetPlugin")
34            .field("store", &self.store)
35            .field("key", &self.key)
36            .field("value", &self.value)
37            .field("ttl_seconds", &self.ttl_seconds)
38            .finish()
39    }
40}
41
42impl StoreSetPlugin {
43    /// Accepted keys:
44    /// - `store` (string, required): a declared `stores:` entry.
45    /// - `key` (string, required, templated): the key to write.
46    /// - `value` (string, required, templated): the value to write.
47    /// - `ttl_seconds` (integer, optional): expiry; omit for no expiry. `0` is
48    ///   a config error, not "no expiry".
49    pub fn from_config(
50        config: &HashMap<String, serde_json::Value>,
51        resources: &Arc<PluginResources>,
52    ) -> Result<Self, String> {
53        let key = store_kv::required_template(config, "key", "store-set")?;
54        let value = store_kv::required_template(config, "value", "store-set")?;
55        let ttl_seconds = store_kv::optional_ttl(config, "store-set")?;
56        Ok(Self {
57            store: store_kv::resolve(config, resources, "store-set")?,
58            key,
59            value,
60            ttl_seconds,
61        })
62    }
63}
64
65#[async_trait]
66impl Plugin for StoreSetPlugin {
67    fn plugin_type(&self) -> &str {
68        "store-set"
69    }
70
71    fn reads_response_body(&self) -> bool {
72        self.key.references_response_body() || self.value.references_response_body()
73    }
74
75    #[cfg(feature = "redis-store")]
76    async fn execute(&self, ctx: Context) -> PluginResult {
77        use redis::AsyncCommands;
78
79        let rendered = self.key.render(&ctx).to_string();
80        if rendered.is_empty() {
81            return Err(store_kv::key_invalid(
82                ctx,
83                "store-set",
84                "SET",
85                &self.store.name,
86            ));
87        }
88        let key = self.store.key_for(&rendered);
89        let value = self.value.render(&ctx).to_string();
90
91        let mut conn = match self.store.conn().await {
92            Ok(c) => c,
93            Err(e) => {
94                return Err(store_kv::store_error(
95                    ctx,
96                    "store-set",
97                    "SET",
98                    &self.store.name,
99                    e,
100                ))
101            }
102        };
103
104        let result = match self.ttl_seconds {
105            Some(ttl) => conn.set_ex::<_, _, ()>(&key, value, ttl).await,
106            None => conn.set::<_, _, ()>(&key, value).await,
107        };
108
109        match result {
110            Ok(()) => Ok(PluginOutput::success(ctx)),
111            Err(e) => Err(store_kv::store_error(
112                ctx,
113                "store-set",
114                "SET",
115                &self.store.name,
116                e.to_string(),
117            )),
118        }
119    }
120
121    #[cfg(not(feature = "redis-store"))]
122    async fn execute(&self, ctx: Context) -> PluginResult {
123        Err(store_kv::store_error(
124            ctx,
125            "store-set",
126            "SET",
127            &self.store.name,
128            "built without the redis-store feature".to_string(),
129        ))
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::plugins::resources::PluginResources;
137    use std::collections::HashMap;
138
139    fn cfg(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
140        serde_json::from_value(json).unwrap()
141    }
142
143    #[test]
144    fn test_requires_a_value() {
145        let r = PluginResources::empty();
146        let err =
147            StoreSetPlugin::from_config(&cfg(serde_json::json!({ "store": "s", "key": "k" })), &r)
148                .unwrap_err();
149        assert!(err.contains("value"), "{err}");
150    }
151
152    /// `0` is a config error, not "no expiry" — and the check must happen
153    /// before store resolution so the message is about the real problem.
154    #[test]
155    fn test_rejects_zero_ttl() {
156        let r = PluginResources::empty();
157        let err = StoreSetPlugin::from_config(
158            &cfg(serde_json::json!({ "store": "s", "key": "k", "value": "1", "ttl_seconds": 0 })),
159            &r,
160        )
161        .unwrap_err();
162        assert!(err.contains("ttl_seconds"), "{err}");
163    }
164}