Skip to content

Commit 76186d3

Browse files
fix: support JMESPath backtick string literals and improve module upload error (#511)
- Add normalize_backtick_literals() to convert unquoted backtick literals to proper JSON format (e.g., `foo` -> `"foo"`) - The jmespath crate requires valid JSON inside backticks, but the JMESPath spec allows elided quotes for string literals - Queries like [?module_name==`jmespath`] now work correctly - Improve error message when module upload returns HTTP 405 - Redis Enterprise 8.x doesn't support module upload via REST API - Now shows helpful message directing users to Admin UI or rladmin CLI Fixes: JMESPath filter expressions with backtick literals failing Fixes: Unclear error when module upload not supported
1 parent 7f98e1b commit 76186d3

File tree

4 files changed

+199
-9
lines changed

4 files changed

+199
-9
lines changed

Cargo.lock

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/redis-enterprise/src/modules.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ impl ModuleHandler {
8686
}
8787

8888
/// Upload new module (tries v2 first, falls back to v1)
89+
///
90+
/// Note: Some Redis Enterprise versions (particularly RE 8.x) do not support
91+
/// module upload via the REST API. In those cases, use the Admin UI or
92+
/// node-level CLI tools (rladmin) to upload modules.
8993
pub async fn upload(&self, module_data: Vec<u8>, file_name: &str) -> Result<Value> {
9094
// Try v2 first (returns action_uid for async tracking)
9195
match self
@@ -96,9 +100,26 @@ impl ModuleHandler {
96100
Ok(response) => Ok(response),
97101
Err(crate::error::RestError::NotFound) => {
98102
// v2 endpoint doesn't exist, try v1
99-
self.client
103+
match self
104+
.client
100105
.post_multipart("/v1/modules", module_data, "module", file_name)
101106
.await
107+
{
108+
Ok(response) => Ok(response),
109+
Err(crate::error::RestError::ApiError { code: 405, .. }) => {
110+
Err(crate::error::RestError::ValidationError(
111+
"Module upload via REST API is not supported in this Redis Enterprise version. \
112+
Use the Admin UI or rladmin CLI to upload modules.".to_string()
113+
))
114+
}
115+
Err(e) => Err(e),
116+
}
117+
}
118+
Err(crate::error::RestError::ApiError { code: 405, .. }) => {
119+
Err(crate::error::RestError::ValidationError(
120+
"Module upload via REST API is not supported in this Redis Enterprise version. \
121+
Use the Admin UI or rladmin CLI to upload modules.".to_string()
122+
))
102123
}
103124
Err(e) => Err(e),
104125
}

crates/redisctl/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ keyring = { version = "3.6", optional = true, features = ["apple-native", "windo
6060
# Tower and resilience patterns
6161
tower = { version = "0.5", features = ["util", "timeout", "buffer", "ready-cache"] }
6262
tower-resilience = { version = "0.1", features = ["circuitbreaker", "retry", "ratelimiter"] }
63+
regex = "1.12.2"
6364

6465
[target.'cfg(unix)'.dependencies]
6566
pager = "0.16"

crates/redisctl/src/output.rs

Lines changed: 171 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use anyhow::{Context, Result};
44
use comfy_table::Table;
55
use jmespath::Runtime;
6+
use regex::Regex;
67
use serde::Serialize;
78
use serde_json::Value;
89
use std::sync::OnceLock;
@@ -20,11 +21,54 @@ pub fn get_jmespath_runtime() -> &'static Runtime {
2021
})
2122
}
2223

23-
/// Compile a JMESPath expression using the extended runtime
24+
/// Normalize backtick literals in JMESPath expressions.
25+
///
26+
/// The JMESPath specification allows "elided quotes" in backtick literals,
27+
/// meaning `` `foo` `` is equivalent to `` `"foo"` ``. However, the Rust
28+
/// jmespath crate requires valid JSON inside backticks.
29+
///
30+
/// This function converts unquoted string literals like `` `foo` `` to
31+
/// properly quoted JSON strings like `` `"foo"` ``.
32+
///
33+
/// Examples:
34+
/// - `` `foo` `` -> `` `"foo"` ``
35+
/// - `` `true` `` -> `` `true` `` (unchanged, valid JSON boolean)
36+
/// - `` `123` `` -> `` `123` `` (unchanged, valid JSON number)
37+
/// - `` `"already quoted"` `` -> `` `"already quoted"` `` (unchanged)
38+
fn normalize_backtick_literals(query: &str) -> String {
39+
static BACKTICK_RE: OnceLock<Regex> = OnceLock::new();
40+
let re = BACKTICK_RE.get_or_init(|| {
41+
// Match backtick-delimited content, handling escaped backticks
42+
Regex::new(r"`([^`\\]*(?:\\.[^`\\]*)*)`").unwrap()
43+
});
44+
45+
re.replace_all(query, |caps: &regex::Captures| {
46+
let content = &caps[1];
47+
let trimmed = content.trim();
48+
49+
// Check if it's already valid JSON
50+
if serde_json::from_str::<Value>(trimmed).is_ok() {
51+
// Already valid JSON (number, boolean, null, quoted string, array, object)
52+
format!("`{}`", content)
53+
} else {
54+
// Not valid JSON - treat as unquoted string literal and add quotes
55+
// Escape any double quotes in the content
56+
let escaped = trimmed.replace('\\', "\\\\").replace('"', "\\\"");
57+
format!("`\"{}\"`", escaped)
58+
}
59+
})
60+
.into_owned()
61+
}
62+
63+
/// Compile a JMESPath expression using the extended runtime.
64+
///
65+
/// This function normalizes backtick literals to handle the JMESPath
66+
/// specification's "elided quotes" feature before compilation.
2467
pub fn compile_jmespath(
2568
query: &str,
2669
) -> Result<jmespath::Expression<'static>, jmespath::JmespathError> {
27-
get_jmespath_runtime().compile(query)
70+
let normalized = normalize_backtick_literals(query);
71+
get_jmespath_runtime().compile(&normalized)
2872
}
2973

3074
#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
@@ -54,10 +98,11 @@ pub fn print_output<T: Serialize>(
5498

5599
// Apply JMESPath query if provided (using extended runtime with 300+ functions)
56100
if let Some(query_str) = query {
101+
let normalized = normalize_backtick_literals(query_str);
57102
let runtime = get_jmespath_runtime();
58103
let expr = runtime
59-
.compile(query_str)
60-
.context("Invalid JMESPath expression")?;
104+
.compile(&normalized)
105+
.with_context(|| format!("Invalid JMESPath expression: {}", query_str))?;
61106
// Convert Value to string then parse as Variable
62107
let json_str = serde_json::to_string(&json_value)?;
63108
let data = jmespath::Variable::from_json(&json_str)
@@ -142,3 +187,125 @@ fn format_value(value: &Value) -> String {
142187
Value::Object(obj) => format!("{{{} fields}}", obj.len()),
143188
}
144189
}
190+
191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
195+
#[test]
196+
fn test_normalize_backtick_unquoted_string() {
197+
// Standard JMESPath backtick literal without quotes
198+
assert_eq!(
199+
normalize_backtick_literals(r#"[?name==`foo`]"#),
200+
r#"[?name==`"foo"`]"#
201+
);
202+
}
203+
204+
#[test]
205+
fn test_normalize_backtick_already_quoted() {
206+
// Already properly quoted - should not double-quote
207+
assert_eq!(
208+
normalize_backtick_literals(r#"[?name==`"foo"`]"#),
209+
r#"[?name==`"foo"`]"#
210+
);
211+
}
212+
213+
#[test]
214+
fn test_normalize_backtick_number() {
215+
// Numbers are valid JSON - should not be quoted
216+
assert_eq!(
217+
normalize_backtick_literals(r#"[?count==`123`]"#),
218+
r#"[?count==`123`]"#
219+
);
220+
}
221+
222+
#[test]
223+
fn test_normalize_backtick_boolean() {
224+
// Booleans are valid JSON - should not be quoted
225+
assert_eq!(
226+
normalize_backtick_literals(r#"[?enabled==`true`]"#),
227+
r#"[?enabled==`true`]"#
228+
);
229+
assert_eq!(
230+
normalize_backtick_literals(r#"[?enabled==`false`]"#),
231+
r#"[?enabled==`false`]"#
232+
);
233+
}
234+
235+
#[test]
236+
fn test_normalize_backtick_null() {
237+
// null is valid JSON - should not be quoted
238+
assert_eq!(
239+
normalize_backtick_literals(r#"[?value==`null`]"#),
240+
r#"[?value==`null`]"#
241+
);
242+
}
243+
244+
#[test]
245+
fn test_normalize_backtick_array() {
246+
// Arrays are valid JSON - should not be modified
247+
assert_eq!(
248+
normalize_backtick_literals(r#"`[1, 2, 3]`"#),
249+
r#"`[1, 2, 3]`"#
250+
);
251+
}
252+
253+
#[test]
254+
fn test_normalize_backtick_object() {
255+
// Objects are valid JSON - should not be modified
256+
assert_eq!(
257+
normalize_backtick_literals(r#"`{"key": "value"}`"#),
258+
r#"`{"key": "value"}`"#
259+
);
260+
}
261+
262+
#[test]
263+
fn test_normalize_multiple_backticks() {
264+
// Multiple backtick literals in one expression
265+
assert_eq!(
266+
normalize_backtick_literals(r#"[?name==`foo` && type==`bar`]"#),
267+
r#"[?name==`"foo"` && type==`"bar"`]"#
268+
);
269+
}
270+
271+
#[test]
272+
fn test_jmespath_backtick_literal_compiles() {
273+
// The original failing case should now work
274+
let query = r#"[?module_name==`jmespath`]"#;
275+
let result = compile_jmespath(query);
276+
assert!(
277+
result.is_ok(),
278+
"Backtick literals should be supported: {:?}",
279+
result
280+
);
281+
}
282+
283+
#[test]
284+
fn test_jmespath_complex_filter() {
285+
// Complex filter expression from the bug report
286+
let query = r#"[?module_name==`jmespath`].uid | [0]"#;
287+
let result = compile_jmespath(query);
288+
assert!(
289+
result.is_ok(),
290+
"Complex filter with backtick should work: {:?}",
291+
result
292+
);
293+
}
294+
295+
#[test]
296+
fn test_jmespath_double_quote_literal() {
297+
// Double quotes work as field references, not literals
298+
let query = r#"[?module_name=="jmespath"]"#;
299+
let result = compile_jmespath(query);
300+
// This compiles but semantically compares field to field, not field to literal
301+
assert!(result.is_ok());
302+
}
303+
304+
#[test]
305+
fn test_jmespath_single_quote_literal() {
306+
// Single quotes are raw string literals in JMESPath
307+
let query = "[?module_name=='jmespath']";
308+
let result = compile_jmespath(query);
309+
assert!(result.is_ok());
310+
}
311+
}

0 commit comments

Comments
 (0)