featherbit/plugins/native/
store_get.rs1use 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
30impl 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
51fn 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 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#[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 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 #[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 #[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 #[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 #[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 #[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}